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>
This commit is contained in:
bircni
2026-07-30 09:13:47 +00:00
committed by GitHub
co-authored by Zettat123 silverwind
parent e80a62f555
commit 11d0ed699b
17 changed files with 653 additions and 94 deletions
+9 -2
View File
@@ -11,6 +11,7 @@ import (
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
func getWorkflowDispatchInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) {
@@ -42,7 +43,7 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
}
var p api.WorkflowCallPayload
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
return nil, fmt.Errorf("decode caller %d payload: %w", caller.ID, err)
return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err)
}
if p.Inputs == nil {
return map[string]any{}, nil
@@ -60,6 +61,12 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
if len(parsedJob.If.Value) == 0 {
return allNeedsSucceed, nil
}
// A deferred-matrix placeholder has no combination yet, so an `if:` reading `matrix.*` can only be
// decided by the emitter's post-expansion pass, against each combination's own values.
// always()/failure()/cancelled() opt out of the needs gate this falls back to.
if job.IsMatrixDeferred && jobparser.ExpressionReadsMatrix(parsedJob.If.Value) {
return allNeedsSucceed || jobparser.ExpressionIgnoresNeedResults(parsedJob.If.Value), nil
}
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return false, err
@@ -77,7 +84,7 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
return false, err
}
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs, job.IsMatrixDeferred)
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
actions_model "gitea.dev/models/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluateJobIfDefersMatrixExpression(t *testing.T) {
// A placeholder's `if:` reading `matrix.*` can only be decided per combination once the matrix is expanded.
emptyRun := &actions_model.ActionRun{} // if the gate is removed, the emptyRun will cause an error
deferredJob := func(ifExpr string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{
ID: 1, JobID: "build", Needs: []string{"setup"}, IsMatrixDeferred: true,
WorkflowPayload: fmt.Appendf(nil, `name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
matrix:
value: ${{ fromJson(needs.setup.outputs.m) }}
steps:
- run: echo
`, ifExpr),
}
}
for _, tt := range []struct {
ifExpr string
// wantNeedsFailed is what the `if:` decides when the needs did not all succeed.
wantNeedsFailed bool
}{
// These hold only for a real combination, so none can be decided before the matrix expands,
// and the fallback is the needs gate: a job whose needs did not succeed is still skipped.
{ifExpr: "${{ matrix.value == 1 }}"},
{ifExpr: "matrix.value == 1"}, // an `if:` may omit the `${{ }}`
// always() asks to run whatever the needs did, so it must not fall back to their gate.
{ifExpr: "${{ always() && matrix.value == 1 }}", wantNeedsFailed: true},
} {
t.Run(tt.ifExpr, func(t *testing.T) {
got, err := evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, true)
require.NoError(t, err)
assert.True(t, got, "must reach the expansion that gates each combination on its own values")
got, err = evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, false)
require.NoError(t, err)
assert.Equal(t, tt.wantNeedsFailed, got)
})
}
}
+21 -7
View File
@@ -361,6 +361,8 @@ type jobStatusResolver struct {
// matrixChanged is set when matrix expansion inserted siblings or failed a placeholder, both of
// which need a follow-up pass to resolve the dependents.
matrixChanged bool
// matrixInserted is set when matrix expansion inserted sibling rows, which Resolve stops on.
matrixInserted bool
// matrixUpdatedJobs holds jobs whose status matrix expansion persisted itself, so they are
// notified like the ones the caller updates from the resolved status map.
matrixUpdatedJobs []*actions_model.ActionRunJob
@@ -418,6 +420,14 @@ func (r *jobStatusResolver) Resolve(ctx context.Context) (map[int64]actions_mode
ret[k] = v
r.statuses[k] = v
}
if r.matrixInserted {
// Matrix expansion inserted sibling rows this round. They are not in statuses/needs, so
// another round would resolve a dependent of the expanded job against the placeholder's
// own combination alone: if that combination was just skipped by its `if:`, the dependent
// sees all its needs done and gets skipped before any sibling has even started. Stop here
// and let the re-emit, which reloads the full job set, resolve them.
return ret, nil
}
}
return ret, nil
}
@@ -472,8 +482,8 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
// Decide whether the job runs at all before expanding a deferred matrix: a job whose needs
// failed or were skipped has to be skipped too, not failed for a matrix those needs never
// produced the outputs for. A job-level `if:` cannot read `matrix.*`, so it does not need
// the combination, unlike the concurrency expression evaluated below.
// produced the outputs for. An `if:` that reads `matrix.*` cannot be decided this early, so
// evaluateJobIf reduces it to that needs gate and the pass below decides it per combination.
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
// TODO: surface deterministic expression errors to users by failing the job with a message.
@@ -489,8 +499,10 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
wasDeferred := actionRunJob.IsMatrixDeferred
siblings, err := expandDeferredMatrix(ctx, actionRunJob, r.vars)
if err != nil {
// Aborting the pass is required: the placeholder is already claimed as the first
// combination, so committing here would drop the remaining ones for good.
// Aborting the pass is required: once the placeholder is claimed as the first combination,
// committing here would drop the remaining ones for good. Before the claim it is what gets
// the pass retried by the job-emitter queue, since a run whose needs are all done has
// nothing left to trigger another pass on its own.
return nil, fmt.Errorf("expand matrix of job %d: %w", id, err)
}
if actionRunJob.Status != actions_model.StatusBlocked {
@@ -503,10 +515,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
if actionRunJob.IsMatrixDeferred {
continue // could not be expanded yet, it stays blocked and is retried on the next pass
}
r.matrixChanged = r.matrixChanged || len(siblings) > 0
if len(siblings) > 0 {
r.matrixChanged, r.matrixInserted = true, true
}
if wasDeferred {
// The `if:` above was decided against the raw matrix, so this row still has to be gated
// by its own combination like the siblings are on the next pass.
// This row is now the first combination, and the `if:` can be evaluated.
// Gate it on its own combination here, as the siblings will be on the next pass.
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
log.Error("evaluateJobIf failed after matrix expansion, job will stay blocked: job: %d, err: %v", id, err)
+36
View File
@@ -589,3 +589,39 @@ func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
assert.Equal(t, len(callers), statusCounts(callers)[actions_model.StatusSuccess])
}
// Test_jobStatusResolverStopsAfterMatrixInsert covers the invariant that keeps a dynamic matrix's
// dependents honest: a round resolved after an insert would judge them against a job set that is
// missing the siblings. See Resolve for why that is wrong.
func Test_jobStatusResolverStopsAfterMatrixInsert(t *testing.T) {
ctx := t.Context()
// build (2) stands for the expanded anchor: it reaches a terminal status this round, which is
// what would let report (3) resolve in the next one.
newChain := func() actions_model.ActionJobList {
return actions_model.ActionJobList{
{ID: 1, JobID: "generate", Status: actions_model.StatusFailure, WorkflowPayload: minimalWorkflowPayload("generate")},
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{"generate"}, WorkflowPayload: minimalWorkflowPayload("build")},
{ID: 3, JobID: "report", Status: actions_model.StatusBlocked, Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report")},
}
}
t.Run("without an insert the whole chain resolves in one pass", func(t *testing.T) {
got, err := newJobStatusResolver(newChain(), nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{
2: actions_model.StatusSkipped,
3: actions_model.StatusSkipped,
}, got)
})
t.Run("an insert stops the pass before the dependents are resolved", func(t *testing.T) {
r := newJobStatusResolver(newChain(), nil)
r.matrixInserted = true // as resolve() sets it once expansion has inserted siblings
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{2: actions_model.StatusSkipped}, got,
"report must wait for the re-emit, which sees the sibling combinations too")
})
}
+31 -40
View File
@@ -17,7 +17,6 @@ import (
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"go.yaml.in/yaml/v4"
"xorm.io/builder"
)
@@ -28,16 +27,19 @@ import (
//
// 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 reported through the job itself:
// 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: the job is left deferred and blocked for the next emitter pass to retry. This covers
// a transient failure before anything was written, and losing the claim to a concurrent pass.
// - 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.
//
// A returned error is reserved for a failure after the placeholder was claimed and must roll the
// caller's transaction back: committing it would drop the remaining combinations for good.
// 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
@@ -47,7 +49,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
// 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) ([]*actions_model.ActionRunJob, error) {
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
@@ -58,52 +60,45 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"status", "stopped")
if err != nil {
return nil, fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
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, nil
}
// retryLater leaves the placeholder untouched. It is only used before anything is written, so a
// transient failure neither fails the job nor aborts the emitter pass for the whole run.
retryLater := func(cause error) ([]*actions_model.ActionRunJob, error) {
log.Error("Matrix expansion of job %d (JobID: %s) postponed to the next pass: %v", job.ID, job.JobID, cause)
return nil, nil
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 retryLater(fmt.Errorf("find needs: %w", err))
return nil, fmt.Errorf("find needs: %w", err)
}
if err := job.LoadAttributes(ctx); err != nil {
return retryLater(fmt.Errorf("load attributes: %w", err))
return nil, fmt.Errorf("load attributes: %w", err)
}
// The payload still carries the raw, unevaluated matrix: planning only erases the needs.
var baseSWF jobparser.SingleWorkflow
if err := yaml.Unmarshal(job.WorkflowPayload, &baseSWF); err != nil {
return failTerminal(fmt.Errorf("unmarshal payload: %w", err))
}
_, parsedJob := baseSWF.Job()
if parsedJob == nil {
return failTerminal(errors.New("payload contains no job"))
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 {
return retryLater(fmt.Errorf("get inputs: %w", err))
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 retryLater(fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err))
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)
@@ -111,12 +106,12 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job)
expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, inputs, maxCombinations)
if err != nil {
return failTerminal(fmt.Errorf("expand matrix: %w", err))
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
swf := baseSWF.CloneHeader()
if err := swf.SetJob(job.JobID, combo.EraseNeeds()); err != nil {
return fmt.Errorf("set expanded job: %w", err)
}
@@ -142,7 +137,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
// 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 failTerminal(err)
return nil, failTerminal(err)
}
siblings = append(siblings, &sibling)
}
@@ -154,13 +149,13 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
if job.ParentJobID > 0 {
parent, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
return retryLater(fmt.Errorf("load parent of job %d: %w", job.ID, err))
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 retryLater(fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err))
return nil, fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err)
}
usedIDs := container.SetOf(job.AttemptJobID)
for _, sibling := range siblings {
@@ -174,7 +169,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
// conditional update is an atomic claim: only the caller that flips IsMatrixDeferred inserts.
beforeClaim := *job
if err := applyCombo(job, expandedJobs[0]); err != nil {
return failTerminal(err)
return nil, failTerminal(err)
}
job.IsMatrixDeferred = false
affected, err := actions_model.UpdateRunJob(ctx, job,
@@ -209,13 +204,9 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
// restoreDeferredMatrixPlaceholder rewinds a rerun clone of a dynamic-matrix combination into the unexpanded placeholder it grew from
func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
var swf jobparser.SingleWorkflow
if err := yaml.Unmarshal(clone.DeferredMatrixPayload, &swf); err != nil {
return fmt.Errorf("unmarshal deferred matrix payload: %w", err)
}
_, parsed := swf.Job()
if parsed == nil {
return errors.New("deferred matrix payload contains no job")
_, 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)
+52 -1
View File
@@ -142,6 +142,7 @@ func TestExpandDeferredMatrix(t *testing.T) {
for _, tt := range []struct {
name, matrixValue string
outputs map[string]string
prepare func(t *testing.T, job *actions_model.ActionRunJob)
}{
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
{
@@ -151,6 +152,16 @@ func TestExpandDeferredMatrix(t *testing.T) {
// the rows the setup plants, plus the placeholder the first combination reuses.
outputs: map[string]string{"many": "[" + strings.Repeat("0,", actions_model.MaxJobNumPerRun-2) + "0]"},
},
{
// A malformed payload fails the same way on every pass, so returning it would requeue the
// run forever instead of ever reaching a terminal status.
name: "unreadable caller inputs",
matrixValue: "${{ fromJson(needs.generate.outputs.values) }}",
prepare: func(t *testing.T, job *actions_model.ActionRunJob) {
_, err := db.Exec(t.Context(), "UPDATE `action_run_job` SET call_payload = ? WHERE id = ?", "{", job.ParentJobID)
require.NoError(t, err)
},
},
} {
t.Run(tt.name+" fails the job", func(t *testing.T) {
outputs := tt.outputs
@@ -158,6 +169,9 @@ func TestExpandDeferredMatrix(t *testing.T) {
outputs = map[string]string{"values": `["a","b","c"]`}
}
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
if tt.prepare != nil {
tt.prepare(t, job)
}
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
require.NoError(t, err)
@@ -191,7 +205,13 @@ func TestDeferredMatrixResolverGating(t *testing.T) {
{name: "skipped need", needStatus: actions_model.StatusSkipped, wantBuilds: []string{"build"}},
{
name: "`if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "${{ matrix.value != 'a' }}", outputs: map[string]string{"values": `["a","b"]`},
jobIf: "${{ matrix.value == 'b' }}", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
{
// The same gate without the `${{ }}`, which must expand rather than skip the whole job.
name: "brace-less `if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "matrix.value == 'b'", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
} {
@@ -218,3 +238,34 @@ func TestDeferredMatrixResolverGating(t *testing.T) {
})
}
}
func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// `build (a)` is skipped by the `if:` once it carries its own combination, `build (b)` runs.
build := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}",
"${{ matrix.value != 'a' }}", map[string]string{"values": `["a","b"]`})
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, build.RunID)
require.NoError(t, err)
report := &actions_model.ActionRunJob{
RunID: build.RunID, RunAttemptID: build.RunAttemptID, AttemptJobID: attemptJobID,
RepoID: build.RepoID, OwnerID: build.OwnerID, ParentJobID: build.ParentJobID,
JobID: "report", Name: "report", Status: actions_model.StatusBlocked,
Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report"),
}
require.NoError(t, db.Insert(ctx, report))
jobs := runJobs(t, build.RunID, build.RunAttemptID)
require.NoError(t, jobs.LoadRuns(ctx, false))
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSkipped, updates[build.ID], "the combination the `if:` excludes")
assert.NotContains(t, updates, report.ID, "report must wait for the re-emit, which sees `build (b)` too")
// The sibling the pass would otherwise have resolved report against is there, and still to run.
sibling := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: build.RunID, Name: "build (b)"})
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
}
+10 -1
View File
@@ -6,6 +6,7 @@ package actions
import (
"math"
"strconv"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
@@ -20,7 +21,15 @@ func parseMaxParallel(jobID, maxParallelString string) int {
}
maxParallel, err := strconv.ParseFloat(maxParallelString, 64)
if err != nil || math.IsNaN(maxParallel) {
log.Debug("job %s: unsupported max-parallel value %q, treating as unlimited", jobID, maxParallelString)
// Both fall back to unlimited, but an expression is a gap in Gitea while a non-number is the
// author's mistake, so dropping the cap must not be reported the same way.
if strings.Contains(maxParallelString, "${{") {
// TODO: evaluate it against the contexts `if:` and the matrix already resolve, so that an
// expression can actually cap a job instead of quietly disabling the cap.
log.Debug("job %s: max-parallel %q is an expression, which is not evaluated yet: treating as unlimited", jobID, maxParallelString)
} else {
log.Warn("job %s: max-parallel %q is not a number, treating as unlimited", jobID, maxParallelString)
}
return 0
}
// a run can never hold more jobs than MaxJobNumPerRun, so clamping there keeps the cast total
+2 -2
View File
@@ -29,8 +29,8 @@ func TestParseMaxParallel(t *testing.T) {
{"-1.5", 0}, // truncates to -1, which means unlimited
{"1e3", 256}, // clamped to MaxJobNumPerRun
{"nan", 0}, // must not reach the int cast
{"${{ vars.n }}", 0}, // expressions are not evaluated yet
{"abc", 0},
{"${{ vars.n }}", 0}, // expressions are not evaluated yet, logged as such
{"abc", 0}, // a plain workflow error, warned about rather than hidden
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseMaxParallel("job", tt.input), "input %q", tt.input)
+8 -5
View File
@@ -653,11 +653,14 @@ func (p *rerunPlan) collectMatrixCollapse() {
if p.skipCloneTemplateJobIDs.Contains(anchor.ID) {
continue
}
inRerunSet := slices.ContainsFunc(rows, func(j *actions_model.ActionRunJob) bool {
return p.rerunAttemptJobIDs.Contains(j.AttemptJobID)
})
if !inRerunSet {
continue // pass-through group, cloned as-is
// Gate on the anchor rather than on any row of the group: execRerunPlan only resets a row whose
// own AttemptJobID is in the rerun set, so restoring the placeholder onto an anchor it treats
// as pass-through would clone a row that keeps its old terminal status while carrying the raw,
// unexpanded payload, and nothing expands a job that is not blocked. An anchor can be left
// out of the rerun set while a sibling is in it: partially re-running the subtree of a
// matrix-expanded reusable caller puts that combination in ancestorAttemptJobIDs instead.
if !p.rerunAttemptJobIDs.Contains(anchor.AttemptJobID) {
continue // pass-through anchor, the group is cloned as-is
}
unexpanded := len(rows) == 1 && anchor.IsMatrixDeferred
if !unexpanded && !p.hasRerunDependency(anchor) {
+90
View File
@@ -363,3 +363,93 @@ func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
}
return out
}
func TestCollectMatrixCollapse(t *testing.T) {
// A dynamic-matrix group is the anchor (the combination that kept DeferredMatrixPayload) plus its
// sibling combinations. Collapsing it rewinds the anchor to an unexpanded placeholder and drops
// the siblings, so the new attempt re-derives the matrix from the fresh needs outputs.
matrixRow := func(id, attemptJobID int64, jobID string, parentID int64, isAnchor, isCaller bool, needs ...string) *actions_model.ActionRunJob {
job := templateJob(id, attemptJobID, jobID, parentID, isCaller, needs...)
if isAnchor {
job.DeferredMatrixPayload = []byte("name: t\non: push\njobs:\n build:\n steps: [{run: echo}]\n")
}
return job
}
t.Run("a rerun of the needs collapses the group", func(t *testing.T) {
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{generate}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.ElementsMatch(t, rowIDsOf(build1), plan.matrixPlaceholderTemplateIDs.Values())
assert.ElementsMatch(t, rowIDsOf(build2), plan.matrixSiblingSkipTemplateIDs.Values())
})
t.Run("re-running only a combination keeps the group as it is", func(t *testing.T) {
// generate is not re-run, so its outputs still stand and the combinations stay valid.
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build2}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("a pass-through anchor is never rewound", func(t *testing.T) {
// build is a matrix-expanded reusable caller. Re-running a job inside build (1)'s subtree
// together with generate leaves build (1) an ancestor rather than a rerun job, while build (2)
// does join the rerun set. execRerunPlan clones an ancestor with its old terminal status, so
// restoring the placeholder onto it would leave a done job holding the raw, unexpanded payload
// that nothing ever expands - and drop build (2) on top of it.
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, true, "generate")
build2 := matrixRow(103, 3, "build", 0, false, true, "generate")
inner := templateJob(104, 4, "inner", 102, false)
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2, inner}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{inner, generate}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
require.Contains(t, plan.ancestorAttemptJobIDs, build1.AttemptJobID)
require.NotContains(t, plan.rerunAttemptJobIDs, build1.AttemptJobID)
require.Contains(t, plan.rerunAttemptJobIDs, build2.AttemptJobID)
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("an unexpanded placeholder is rewound whenever it is re-run", func(t *testing.T) {
// The previous attempt never got to expand it, so there is nothing to reuse.
generate := templateJob(101, 1, "generate", 0, false)
build := matrixRow(102, 2, "build", 0, true, false, "generate")
build.IsMatrixDeferred = true
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.ElementsMatch(t, rowIDsOf(build), plan.matrixPlaceholderTemplateIDs.Values())
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("a plan-time matrix is left alone", func(t *testing.T) {
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, false, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs(nil))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
}