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
+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)
})
}