Files
Gitea/services/actions/matrix.go
T
bircni 11d0ed699b fix(actions): dynamic matrix expansion correctness fixes (#38690)
Follow-up to https://github.com/go-gitea/gitea/pull/36564 (dynamic
matrix) and https://github.com/go-gitea/gitea/pull/36357 (max-parallel),
fixing issues found reviewing the two features together.

- **A placeholder could stall its run forever.** Its payload keeps the
raw matrix but loses its `needs`, so `ParseJob` re-expanded it instead
of reading it back — fatal for `include: ${{ fromJson(needs.*.outputs.*)
}}`.
- **An `if:` reading `matrix.*` skipped the whole job**, with or without
the `${{ }}`. It now reduces to the needs gate, except under
`always()`/`failure()`/`cancelled()`, and each combination is decided on
its own values once the matrix expands.
- **Dependents could be skipped before the combinations ran**, since
inserted siblings are absent from the resolver's job set. The pass now
stops after an insert and defers to the re-emit it schedules.
- **Expansion failures stranded the placeholder.** A retryable one is
returned so the queue retries it; a malformed payload fails the job
instead of requeueing forever.
- **Rerun could rewind a pass-through row** into a raw placeholder
keeping its old terminal status, which nothing expands. Now gated on the
anchor itself.

Plus: `max-parallel` distinguishes an unevaluated `${{ }}` (debug) from
a non-numeric literal (warn — it silently drops the cap).

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-30 09:13:47 +00:00

218 lines
9.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// expandDeferredMatrix expands a deferred-matrix placeholder once its needs are done and the job is
// known to run, using the needs' outputs: the placeholder becomes the first combination and the rest
// are returned as inserted siblings, all left Blocked so the caller's resolver still applies the
// concurrency gate.
//
// It runs inside the caller's transaction (job_emitter's resolver) and must not open a nested
// db.WithTx, which would reuse the ambient session and roll the whole emitter pass back on error.
// The three outcomes are:
// - expanded: IsMatrixDeferred is cleared and the job stays StatusBlocked.
// - the workflow's fault (a matrix that cannot resolve, or one too large): a terminal status is
// persisted here, reported by the job leaving StatusBlocked. IsMatrixDeferred stays set, marking
// the payload as still unexpanded for a later rerun to re-derive.
// - not now: losing the claim to a concurrent pass leaves the job deferred and blocked, for the
// pass that won the claim to carry through.
//
// Every other failure is returned, which aborts the emitter pass and lets the job-emitter queue retry
// it. Swallowing one would strand the placeholder: nothing re-emits a run whose needs have all already
// finished, so there would be no next pass to retry in. After the placeholder has been claimed a
// returned error additionally has to roll the caller's transaction back, because committing it would
// drop the remaining combinations for good.
func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob, vars map[string]string) ([]*actions_model.ActionRunJob, error) {
if !job.IsMatrixDeferred {
return nil, nil
}
// failTerminal fails the job here rather than through the resolver's status map, which a
// reusable caller (a job may be both) would drop: its branch only handles waiting and skipped.
// The commit status is unaffected by the surviving flag: suppression only applies while the job
// is not done.
failTerminal := func(cause error) error {
log.Warn("Matrix expansion failed for job %d (JobID: %s): %v", job.ID, job.JobID, cause)
prevStatus, prevStopped := job.Status, job.Stopped
job.Status = actions_model.StatusFailure
job.Stopped = timeutil.TimeStampNow()
// The flag survives, so it alone no longer tells a fresh placeholder from one a concurrent
// pass already failed: the status has to be part of the condition.
affected, err := actions_model.UpdateRunJob(ctx, job,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"status", "stopped")
if err != nil {
return fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
}
if affected != 1 {
// A concurrent pass already advanced the row. Restore the in-memory state so this pass
// does not report a failure that was never persisted.
job.Status, job.Stopped = prevStatus, prevStopped
}
return nil
}
// The resolver only calls this once every need is done, as it does for job concurrency.
results, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return nil, fmt.Errorf("find needs: %w", err)
}
if err := job.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("load attributes: %w", err)
}
// The payload still carries the raw, unevaluated matrix: planning only erases the needs.
baseSWF, parsedJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, failTerminal(fmt.Errorf("parse payload: %w", err))
}
// `strategy` may reference the inputs context as well as needs, so resolve it like `if:` does.
inputs, err := getInputsForJob(ctx, job.Run, job)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
// A malformed payload never becomes readable, so retrying would requeue the run forever.
return nil, failTerminal(fmt.Errorf("get inputs: %w", err))
}
return nil, fmt.Errorf("get inputs: %w", err)
}
existingJobs, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
if err != nil {
return nil, fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err)
}
// The placeholder is reused as the first combination, so the attempt only grows by len-1.
maxCombinations := int(actions_model.MaxJobNumPerRun - existingJobs + 1)
giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job)
expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, inputs, maxCombinations)
if err != nil {
return nil, failTerminal(fmt.Errorf("expand matrix: %w", err))
}
// Combinations differ only in what the matrix feeds: the name, the payload, and a
// runs-on/continue-on-error that may interpolate matrix.*.
applyCombo := func(dst *actions_model.ActionRunJob, combo *jobparser.Job) error {
swf := baseSWF.CloneHeader()
if err := swf.SetJob(job.JobID, combo.EraseNeeds()); err != nil {
return fmt.Errorf("set expanded job: %w", err)
}
payload, err := swf.Marshal()
if err != nil {
return fmt.Errorf("marshal expanded job: %w", err)
}
dst.Name = util.EllipsisDisplayString(combo.Name, 255)
dst.WorkflowPayload, dst.RunsOn = payload, combo.RunsOn()
dst.ContinueOnError = combo.GetContinueOnError()
return nil
}
siblings := make([]*actions_model.ActionRunJob, 0, len(expandedJobs)-1)
for _, combo := range expandedJobs[1:] {
// Inherit from the placeholder rather than listing fields, so a sibling cannot silently lose
// one (scope, permissions, `uses:`) as the job model grows.
sibling := *job
sibling.ID, sibling.TaskID, sibling.SourceTaskID, sibling.AttemptJobID = 0, 0, 0, 0
sibling.Started, sibling.Stopped, sibling.IsMatrixDeferred = 0, 0, false
sibling.Status = actions_model.StatusBlocked
sibling.Needs = slices.Clone(job.Needs)
// Only the placeholder keeps the raw payload, which is what identifies it as the group's anchor.
sibling.DeferredMatrixPayload = nil
if err := applyCombo(&sibling, combo); err != nil {
return nil, failTerminal(err)
}
siblings = append(siblings, &sibling)
}
// Keep AttemptJobIDs stable across attempts (best-effort). A single combination reuses the
// placeholder's row, so there is nothing to look up.
if len(siblings) > 0 {
parentAttemptJobID := int64(0)
if job.ParentJobID > 0 {
parent, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
return nil, fmt.Errorf("load parent of job %d: %w", job.ID, err)
}
parentAttemptJobID = parent.AttemptJobID
}
priorCombos, err := actions_model.GetPriorAttemptMatrixCombos(ctx, job.RunID, job.RunAttemptID, parentAttemptJobID, job.JobID)
if err != nil {
return nil, fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err)
}
usedIDs := container.SetOf(job.AttemptJobID)
for _, sibling := range siblings {
if prior, ok := priorCombos[sibling.Name]; ok && usedIDs.Add(prior.AttemptJobID) {
sibling.AttemptJobID = prior.AttemptJobID
}
}
}
// Reusing the placeholder leaves no phantom skipped job behind to poison downstream needs. The
// conditional update is an atomic claim: only the caller that flips IsMatrixDeferred inserts.
beforeClaim := *job
if err := applyCombo(job, expandedJobs[0]); err != nil {
return nil, failTerminal(err)
}
job.IsMatrixDeferred = false
affected, err := actions_model.UpdateRunJob(ctx, job,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"name", "workflow_payload", "runs_on", "continue_on_error", "is_matrix_deferred")
if err != nil {
return nil, fmt.Errorf("claim placeholder of job %d: %w", job.ID, err)
}
if affected != 1 {
// A concurrent pass won the claim and owns the siblings. Restore the in-memory state so this
// pass leaves the job alone and picks the winner's rows up on the next one.
*job = beforeClaim
return nil, nil
}
if len(siblings) == 0 {
return nil, nil
}
for _, sibling := range siblings {
if sibling.AttemptJobID != 0 {
continue // matched a prior attempt above
}
if sibling.AttemptJobID, err = actions_model.GetNextAttemptJobID(ctx, job.RunID); err != nil {
return nil, fmt.Errorf("alloc attempt_job_id for job %d: %w", job.ID, err)
}
}
if err := db.Insert(ctx, siblings); err != nil {
return nil, fmt.Errorf("insert matrix siblings of job %d: %w", job.ID, err)
}
return siblings, nil
}
// restoreDeferredMatrixPlaceholder rewinds a rerun clone of a dynamic-matrix combination into the unexpanded placeholder it grew from
func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
_, parsed, err := jobparser.ParseRawSingleWorkflow(clone.DeferredMatrixPayload)
if err != nil {
return fmt.Errorf("parse deferred matrix payload: %w", err)
}
clone.Name = util.EllipsisDisplayString(parsed.Name, 255)
clone.WorkflowPayload = slices.Clone(clone.DeferredMatrixPayload)
clone.RunsOn = parsed.RunsOn()
clone.ContinueOnError = parsed.GetContinueOnError()
clone.IsMatrixDeferred = true
return nil
}