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 11:13:47 +02:00
committed by GitHub
parent e80a62f555
commit 11d0ed699b
17 changed files with 653 additions and 94 deletions
+10
View File
@@ -192,6 +192,16 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload
func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) {
if job.IsMatrixDeferred {
// The needs were erased before the placeholder was persisted, so jobparser.Parse no longer
// recognises the raw matrix it still carries and would re-expand it: see ParseRawSingleWorkflow.
_, workflowJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, fmt.Errorf("job %d deferred matrix placeholder: unable to parse: %w", job.ID, err)
}
return workflowJob, nil
}
// job.WorkflowPayload is a SingleWorkflow created from an ActionRun's workflow, which exactly contains this job's YAML definition.
// Ideally it shouldn't be called "Workflow", it is just a job with global workflow fields + trigger
parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload)
+41
View File
@@ -4,6 +4,7 @@
package actions
import (
"fmt"
"testing"
"gitea.dev/models/db"
@@ -197,3 +198,43 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
}
func TestParseJobDeferredMatrixPlaceholder(t *testing.T) {
// A placeholder is persisted with the raw matrix and without its needs, so routing its payload
// through jobparser.Parse re-expands that matrix. The job emitter reads `if:` (and so ParseJob)
// before it can expand the placeholder, and it only logs a parse failure: getting this wrong
// leaves the job Blocked on every pass, the run never finishes and its concurrency group is
// never released.
payload := func(matrix string) []byte {
return fmt.Appendf(nil, `name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
%s
steps:
- run: echo
`, matrix)
}
// The shape Parse happens to survive, so only the name tells the two paths apart. What Parse makes
// of every other shape is asserted in TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder.
t.Run("a placeholder is read back, not re-expanded", func(t *testing.T) {
job := &ActionRunJob{ID: 1, JobID: "build", IsMatrixDeferred: true, WorkflowPayload: payload("version: ${{ fromJson(needs.setup.outputs.m) }}")}
parsed, err := job.ParseJob()
require.NoError(t, err)
require.NotNil(t, parsed)
assert.Equal(t, "build", parsed.Name)
})
t.Run("an expanded job still goes through the full parse", func(t *testing.T) {
job := &ActionRunJob{ID: 1, JobID: "build", WorkflowPayload: payload("version: [1]")}
parsed, err := job.ParseJob()
require.NoError(t, err)
require.NotNil(t, parsed)
// Parse bakes the combination into the name, ParseRawSingleWorkflow would not.
assert.Equal(t, "build (1)", parsed.Name)
})
}
+68 -7
View File
@@ -32,11 +32,72 @@ func rawMatrixReadsNeeds(node *yaml.Node) bool {
return slices.ContainsFunc(node.Content, rawMatrixReadsNeeds)
}
// ParseRawSingleWorkflow decodes a SingleWorkflow payload into the workflow and its single job
// without expanding `strategy.matrix`.
//
// A deferred-matrix placeholder's payload still carries the raw, unevaluated matrix, which Parse
// would try to expand: depending on the matrix's shape that yields several workflows (a static
// vector crossed with the unevaluated expression) or an error (an `include`/`exclude` that is still
// a scalar), neither of which describes the one job the payload stands for.
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
swf := &SingleWorkflow{}
if err := yaml.Unmarshal(payload, swf); err != nil {
return nil, nil, fmt.Errorf("unmarshal single workflow: %w", err)
}
id, job := swf.Job()
if job == nil {
return nil, nil, errors.New("payload contains no job")
}
if job.Name == "" {
job.Name = id // Parse defaults it the same way, and callers use it as the job's display name
}
return swf, job, nil
}
// expressionReadsNeeds reports whether value holds a ${{ }} expression reading the needs context.
// Every other context (github, vars, inputs, ...) is already available while planning, so deferring
// those too would replace their combinations with one placeholder and change the commit status
// contexts the run publishes, which a repository's required checks are configured against.
func expressionReadsNeeds(value string) bool {
return expressionReadsContext(value, "needs")
}
// ExpressionReadsMatrix reports whether a job's `if:` reads the matrix context.
// A deferred-matrix placeholder has no combination yet, so such an expression cannot be decided.
func ExpressionReadsMatrix(ifValue string) bool {
return expressionReadsContext(asIfExpression(ifValue), "matrix")
}
// ExpressionIgnoresNeedResults reports whether a job's `if:` calls always(), failure() or cancelled(),
// the status functions that run a job whatever its needs did rather than under the implicit success().
// Keep in sync with act's exprparser, which owns the same list for the evaluation itself.
func ExpressionIgnoresNeedResults(ifValue string) bool {
return expressionsMatch(asIfExpression(ifValue), func(node actionlint.ExprNode) bool {
call, ok := node.(*actionlint.FuncCallNode)
return ok && slices.Contains([]string{"always", "failure", "cancelled"}, strings.ToLower(call.Callee))
})
}
// asIfExpression wraps an `if:` that omits the `${{ }}`, which GitHub evaluates as one expression anyway.
// `if:` is the only field with that exception: every other value is interpolated, so a bare matrix or
// `runs-on` is a literal there and must not be parsed as an expression.
func asIfExpression(ifValue string) string {
if ifValue == "" || strings.Contains(ifValue, "${{") {
return ifValue
}
return "${{ " + ifValue + " }}"
}
// expressionReadsContext reports whether value holds a ${{ }} expression reading the named context.
func expressionReadsContext(value, contextName string) bool {
return expressionsMatch(value, func(node actionlint.ExprNode) bool {
variable, ok := node.(*actionlint.VariableNode)
return ok && strings.EqualFold(variable.Name, contextName)
})
}
// expressionsMatch reports whether any ${{ }} expression in value holds a node the predicate accepts.
func expressionsMatch(value string, match func(node actionlint.ExprNode) bool) bool {
for rest := value; ; {
_, after, found := strings.Cut(rest, "${{")
if !found {
@@ -48,13 +109,13 @@ func expressionReadsNeeds(value string) bool {
if err != nil {
return true // unparseable here, let the expansion report it against the real values
}
readsNeeds := false
matched := false
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
if variable, ok := node.(*actionlint.VariableNode); entering && ok && strings.EqualFold(variable.Name, "needs") {
readsNeeds = true
if entering && match(node) {
matched = true
}
})
if readsNeeds {
if matched {
return true
}
}
@@ -124,7 +185,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
}
}
for _, combo := range combos {
swf := workflow.cloneHeader()
swf := workflow.CloneHeader()
if err := swf.SetJob(id, combo); err != nil {
return nil, fmt.Errorf("SetJob: %w", err)
}
@@ -134,8 +195,8 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
return ret, nil
}
// cloneHeader returns a copy of w with its workflow-global fields but no jobs.
func (w *SingleWorkflow) cloneHeader() *SingleWorkflow {
// CloneHeader returns a copy of w with its workflow-global fields but no jobs.
func (w *SingleWorkflow) CloneHeader() *SingleWorkflow {
return &SingleWorkflow{
Name: w.Name,
RawOn: w.RawOn,
+153 -8
View File
@@ -236,23 +236,168 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
})
}
// evaluateJobIf builds a one-job workflow around the given `matrix:` value and `if:`, and decides it.
func evaluateJobIf(t *testing.T, matrixYAML, ifExpr string, deferred bool) (bool, error) {
t.Helper()
var strategy Strategy
require.NoError(t, yaml.Unmarshal(fmt.Appendf(nil, "matrix:\n %s\n", matrixYAML), &strategy))
job := &Job{Name: "build", Strategy: strategy}
require.NoError(t, job.If.Encode(ifExpr))
return EvaluateJobIfExpression("build", job, map[string]any{}, map[string]*JobResult{"build": {}}, nil, nil, deferred)
}
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
// act dereferences include/exclude entries as mappings without checking, so an unevaluated
// expression panics there. Every entry point into act's matrix expansion must reject it: Parse
// sees one in a workflow file and in a deferred placeholder's payload, and the emitter reads a
// placeholder's `if:` while its matrix is still raw.
// expression panics there. Every entry point into act's matrix expansion must reject it. The
// expression here reads `vars`, which is available while planning, so the job is not deferred and
// nothing will ever resolve the filter: the error is the right answer at both entry points.
// A deferred placeholder is the other case, covered by TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable.
for _, filter := range []string{"include", "exclude"} {
t.Run(filter, func(t *testing.T) {
_, err := Parse(fmt.Appendf(nil,
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter))
require.ErrorContains(t, err, "must be a list of mappings")
var strategy Strategy
require.NoError(t, yaml.Unmarshal(fmt.Appendf(nil, "matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n", filter), &strategy))
job := &Job{Name: "build", Strategy: strategy}
require.NoError(t, job.If.Encode("${{ true }}"))
_, err = EvaluateJobIfExpression("build", job, map[string]any{}, map[string]*JobResult{"build": {}}, nil, nil)
_, err = evaluateJobIf(t, fmt.Sprintf("os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}", filter), "${{ true }}", false)
require.ErrorContains(t, err, "must be a list of mappings")
})
}
}
func TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder(t *testing.T) {
// The server persists a placeholder the way insertRunJob does: erase the needs, then marshal.
// Reading it back must yield that one job again. Parse cannot do it: it only keeps a matrix raw
// while the job still declares needs, so on the stored payload it falls through to expanding the
// raw matrix instead - which either fails or splits the placeholder into several workflows, and
// in both cases leaves the job unexpandable for good.
const workflow = `
on: push
jobs:
setup:
steps: [{run: echo}]
build:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
matrix string
parseCount int // what Parse makes of the stored payload
parseErrHas string // ... or the error it fails with
}{
// The canonical GitHub dynamic-matrix idiom. act dereferences include entries as mappings, so
// validateMatrixFilters rejects the still-scalar expression outright.
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: "must be a list of mappings"},
// A static vector crossed with the unevaluated expression: one workflow per static value.
{name: "static vector and expression", matrix: "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}", parseCount: 2},
// The single-key case the feature shipped with happens to survive Parse, so it must keep working.
{name: "single expression vector", matrix: "version: ${{ fromJson(needs.setup.outputs.m) }}", parseCount: 1},
} {
t.Run(tt.name, func(t *testing.T) {
planned, err := Parse(fmt.Appendf(nil, workflow, tt.matrix))
require.NoError(t, err)
var payload []byte
for _, w := range planned {
id, job := w.Job()
if id != "build" {
continue
}
require.True(t, HasDeferredMatrix(job), "build must be planned as a placeholder")
require.NoError(t, w.SetJob(id, job.EraseNeeds()))
payload, err = w.Marshal()
require.NoError(t, err)
}
require.NotEmpty(t, payload, "no placeholder was planned for build")
// The stored payload keeps the raw matrix, but no longer the needs that made Parse defer it.
_, job, err := ParseRawSingleWorkflow(payload)
require.NoError(t, err)
assert.Equal(t, "build", job.Name)
// The needs are gone, which is exactly why Parse no longer defers this payload.
assert.Empty(t, job.Needs())
assert.False(t, HasDeferredMatrix(job))
// Guard the reason ParseRawSingleWorkflow exists, so a future Parse change cannot quietly
// make the placeholder re-expandable again without this being noticed.
reparsed, err := Parse(payload)
if tt.parseErrHas != "" {
require.ErrorContains(t, err, tt.parseErrHas)
} else {
require.NoError(t, err)
assert.Len(t, reparsed, tt.parseCount)
}
})
}
}
func TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable(t *testing.T) {
// A placeholder's `if:` is read before its matrix can be resolved. `matrix.*` has to be absent
// there: binding it to the expression's own source text would decide the job against a value no
// combination ever has, and an include/exclude that is still a scalar cannot be read at all.
t.Run("include expression is not read", func(t *testing.T) {
run, err := evaluateJobIf(t, "include: ${{ fromJson(needs.setup.outputs.m) }}", "${{ true }}", true)
require.NoError(t, err)
assert.True(t, run)
})
t.Run("matrix context is null, not the raw expression", func(t *testing.T) {
const matrix = "version: ${{ fromJson(needs.setup.outputs.m) }}"
run, err := evaluateJobIf(t, matrix, "${{ matrix.version == null }}", true)
require.NoError(t, err)
assert.True(t, run)
run, err = evaluateJobIf(t, matrix, "${{ matrix.version == '${{ fromJson(needs.setup.outputs.m) }}' }}", true)
require.NoError(t, err)
assert.False(t, run)
})
t.Run("an expanded job still reads its combination", func(t *testing.T) {
run, err := evaluateJobIf(t, "version: [1]", "${{ matrix.version == 1 }}", false)
require.NoError(t, err)
assert.True(t, run)
})
}
func TestExpressionReadsMatrix(t *testing.T) {
// Erring toward true only postpones the `if:` to the pass that has the combination, which decides it correctly anyway.
for value, want := range map[string]bool{
"": false,
"true": false, // a bare literal is an expression too, it just reads nothing
"${{ always() }}": false,
"${{ needs.setup.result == 'ok' }}": false,
"${{ vars.MATRIX }}": false, // a name that merely looks like the context
"${{ matrix.os }}": true,
"${{ MATRIX.os }}": true, // contexts are case-insensitive
"${{ always() && matrix.os == 1 }}": true,
"${{ contains(matrix.tags, 'a') }}": true,
"${{ toJSON(matrix) }}": true, // the whole context, not a property of it
"${{ vars.A }}${{ matrix.os }}": true, // only the second of two expressions reads it
"${{ matrix.os == }}": true, // unparseable, postpone rather than decide it here
// An `if:` may omit the `${{ }}`, and is evaluated as one expression either way.
"matrix.os == 'a'": true,
"needs.setup.result == 'ok'": false,
} {
assert.Equal(t, want, ExpressionReadsMatrix(value), "value %q", value)
}
}
func TestExpressionIgnoresNeedResults(t *testing.T) {
for value, want := range map[string]bool{
"": false,
"${{ matrix.os == 'a' }}": false,
"${{ success() }}": false, // the implicit gate, so the fallback already matches it
"${{ always() }}": true,
"${{ ALWAYS() && matrix.os }}": true, // function names are case-insensitive
"${{ failure() }}": true,
"${{ cancelled() }}": true,
"always() && matrix.os == 'a'": true, // the brace-less form of the same gate
"${{ vars.always }}": false,
} {
assert.Equal(t, want, ExpressionIgnoresNeedResults(value), "value %q", value)
}
}
+15 -7
View File
@@ -497,7 +497,8 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
}
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) {
// EvaluateJobIfExpression evaluates a job's `if:`.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any, matrixDeferred bool) (bool, error) {
actJob := &model.Job{
Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
@@ -509,13 +510,20 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
// otherwise `matrix.*` references in `if:` evaluate to null.
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
//
// A deferred-matrix placeholder is the exception: its combinations do not exist yet, and reading the
// raw matrix here would either fail outright (an `include` that is still a scalar expression) or bind
// `matrix.*` to the expression's own source text. Leaving it nil is safe: the caller checks
// ExpressionReadsMatrix first, so an `if:` that reads `matrix.*` is deferred to the post-expansion pass.
var matrix map[string]any
matrixes, err := matrixesOf(actJob)
if err != nil {
return false, err
}
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
if !matrixDeferred {
matrixes, err := matrixesOf(actJob)
if err != nil {
return false, err
}
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
expr, err := rewriteSubExpression(job.If.Value, false)
+2 -2
View File
@@ -502,7 +502,7 @@ jobs:
got := make(map[string]bool, len(swfs))
for _, swf := range swfs {
id, job := swf.Job()
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil)
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil, false)
require.NoError(t, err)
got[job.Name] = shouldRun
}
@@ -562,7 +562,7 @@ jobs:
"job1": {Result: kase.needResult},
"job2": {Needs: []string{"job1"}},
}
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil, false)
require.NoError(t, err)
assert.Equal(t, kase.expected, got)
})
+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)
})
}
@@ -26,6 +26,10 @@ import (
// planned as a single placeholder and expanded once its dependency completes. `build` exercises the
// expansion, `report` that a downstream job sees the combinations' outputs, `gated` and `partial` the
// `if:` gates on either side of it, and `static` that a matrix expanded at plan time is left alone.
// `included` covers the placeholder shapes that only survive as long as nothing re-parses their
// payload: `include:` is still a scalar there, which act refuses to read at all.
// `partial` and `strict` gate on `matrix.*` from either direction: neither may be decided before the
// combinations exist, or the whole job is skipped instead of the combinations the gate excludes.
// A full rerun then re-derives the matrix from the new attempt's outputs instead of reusing the
// previous combinations, keeping the AttemptJobID of every combination that recurs.
func TestDynamicMatrixEvaluation(t *testing.T) {
@@ -47,9 +51,12 @@ jobs:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
combos: ${{ steps.set.outputs.combos }}
steps:
- id: set
run: echo "matrix=[1,2]" >> "$GITHUB_OUTPUT"
run: |
echo "matrix=[1,2]" >> "$GITHUB_OUTPUT"
echo 'combos=[{"name":"x"},{"name":"y"}]' >> "$GITHUB_OUTPUT"
build:
needs: [generate]
runs-on: ubuntu-latest
@@ -69,6 +76,14 @@ jobs:
os: [a, b]
steps:
- run: echo "${{ matrix.os }}"
included:
needs: [generate]
runs-on: ubuntu-latest
strategy:
matrix:
include: ${{ fromJson(needs.generate.outputs.combos) }}
steps:
- run: echo "${{ matrix.name }}"
gated:
needs: [generate]
if: false
@@ -87,6 +102,15 @@ jobs:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
strict:
needs: [generate]
if: matrix.value == 1
runs-on: ubuntu-latest
strategy:
matrix:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
report:
needs: [build]
runs-on: ubuntu-latest
@@ -101,7 +125,7 @@ jobs:
_, _, run := getTaskAndJobAndRunByTaskID(t, generateTask.Id)
runner.execTask(t, generateTask, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{"matrix": "[1,2]"},
outputs: map[string]string{"matrix": "[1,2]", "combos": `[{"name":"x"},{"name":"y"}]`},
})
// execAttempt runs the attempt's remaining tasks, recording each job's name and AttemptJobID(order is not fixed).
@@ -127,13 +151,20 @@ jobs:
return names
}
seen := execAttempt(t, 6)
seen := execAttempt(t, 9)
firstAttemptIDs := maps.Clone(attemptJobIDs)
// `gated` is decided before the matrix is touched, so it never expands and is skipped as one job
// `partial (1)` is decided afterwards, against its own combination.
assert.ElementsMatch(t, []string{"build (1)", "build (2)", "static (a)", "static (b)", "partial (2)", "report"}, seen)
skipped := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "partial (1)"})
assert.Equal(t, actions_model.StatusSkipped, skipped.Status)
// `gated` is decided before the matrix is touched, so it never expands and is skipped as one job;
// `partial (1)` and `strict (2)` are decided afterwards, each against its own combination.
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "included (x)", "included (y)",
"static (a)", "static (b)", "partial (2)", "strict (1)", "report",
}, seen)
for _, name := range []string{"partial (1)", "strict (2)"} {
skipped := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: name})
assert.Equal(t, actions_model.StatusSkipped, skipped.Status)
}
// A gate reading `matrix.*` must not be decided against the unexpanded placeholder.
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "strict"})
buildNeed, ok := reportTask.Needs["build"]
require.True(t, ok, "report must see the expanded combinations as its 'build' need")
@@ -149,14 +180,16 @@ jobs:
require.Equal(t, "generate", getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, generateTask2.Id))
assert.Equal(t, "2", generateTask2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, generateTask2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{"matrix": "[1,2,3]"},
result: runnerv1.Result_RESULT_SUCCESS,
// `combos` is unchanged, so both `included` combinations recur and have to keep their AttemptJobIDs.
outputs: map[string]string{"matrix": "[1,2,3]", "combos": `[{"name":"x"},{"name":"y"}]`},
})
// The matrix now yields a third value, while `static` is cloned as-is rather than collapsed.
seenRerun := execAttempt(t, 8)
seenRerun := execAttempt(t, 11)
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "build (3)", "static (a)", "static (b)", "partial (2)", "partial (3)", "report",
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)",
"static (a)", "static (b)", "partial (2)", "partial (3)", "strict (1)", "report",
}, seenRerun)
for name, firstID := range firstAttemptIDs {
assert.Equal(t, firstID, attemptJobIDs[name], "%s keeps its AttemptJobID across attempts", name)