feat: Add support for dynamic matrix evaluation in Gitea Actions workflows (#36564)

Adds dynamic matrix evaluation to Gitea Actions: a job's
`strategy.matrix` can be built from the outputs of the jobs it needs.

```yaml
jobs:
  generate:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.result }}
    steps:
      - id: set
        run: echo "result=[1,2,3]" >> $GITHUB_OUTPUT

  build:
    needs: [generate]
    runs-on: ubuntu-latest
    strategy:
      matrix:
        version: ${{ fromJson(needs.generate.outputs.matrix) }}
    steps:
      - run: echo "building ${{ matrix.version }}"
```

Such a matrix cannot be expanded at planning time, so the job is planned
as a single placeholder and expanded by the job emitter once its needs
finish. Each combination is then gated by `if:` and concurrency as
usual.

- A matrix that resolves to no combination fails the job, as on GitHub.
- Expansion is capped at `MaxJobNumPerRun`.
- Workflows without a needs-dependent matrix are unaffected.

Fixes https://github.com/go-gitea/gitea/issues/25179

---------

Signed-off-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Signed-off-by: ZPascal <pascal.zimmermann@theiotstudio.com>
Co-authored-by: Claude <claude-sonnet-4-5@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.8) <noreply@anthropic.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
Pascal Zimmermann
2026-07-28 17:59:00 +02:00
committed by GitHub
parent 717db275d5
commit 5672b1c4cf
20 changed files with 1252 additions and 69 deletions
+7
View File
@@ -54,6 +54,13 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
}
for _, job := range jobs {
// A deferred-matrix placeholder's name changes when it expands, so a status created while it
// waits would be orphaned. The emitter reloads the jobs after expanding and creates them
// then. A placeholder that reached a final status (skipped by its `if:`, cancelled with the
// run, or failed to expand) never expands, so it keeps its own name and still deserves a status.
if job.IsMatrixDeferred && !job.Status.IsDone() {
continue
}
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
+8
View File
@@ -68,6 +68,14 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
if err != nil {
return false, err
}
// GenerateGiteaContext dereferences the run's repo and trigger user, so load them here instead of
// relying on whatever the caller happened to load before.
if err := run.LoadRepo(ctx); err != nil {
return false, err
}
if err := run.LoadTriggerUser(ctx); err != nil {
return false, err
}
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
}
+77 -25
View File
@@ -17,7 +17,6 @@ import (
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
@@ -270,6 +269,7 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
return result, nil
}
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return nil, err
@@ -282,7 +282,10 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
job.Run = run
}
updates := resolver.Resolve(ctx)
updates, err := resolver.Resolve(ctx)
if err != nil {
return err
}
for _, job := range jobs {
status, ok := updates[job.ID]
if !ok {
@@ -336,7 +339,10 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
return nil, err
}
if expandedAnyCaller {
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
// Caller and matrix expansion both insert Blocked jobs, which only a follow-up pass resolves.
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
if expandedAnyCaller || resolver.matrixChanged {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
}
result.CancelledJobs = resolver.cancelledJobs
@@ -352,6 +358,12 @@ type jobStatusResolver struct {
jobMap map[int64]*actions_model.ActionRunJob
vars map[string]string
cancelledJobs []*actions_model.ActionRunJob
// 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
// 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
}
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
@@ -392,19 +404,22 @@ func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]stri
}
}
func (r *jobStatusResolver) Resolve(ctx context.Context) map[int64]actions_model.Status {
func (r *jobStatusResolver) Resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
ret := map[int64]actions_model.Status{}
for i := 0; i < len(r.statuses); i++ {
updated := r.resolve(ctx)
updated, err := r.resolve(ctx)
if err != nil {
return nil, err
}
if len(updated) == 0 {
return ret
return ret, nil
}
for k, v := range updated {
ret[k] = v
r.statuses[k] = v
}
}
return ret
return ret, nil
}
func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed bool) {
@@ -426,7 +441,7 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
return allDone, allSucceed
}
func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model.Status {
func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
ret := map[int64]actions_model.Status{}
slots := maxParallelSlots{}
@@ -455,13 +470,61 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
continue
}
// 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.
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.
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
continue
}
if !shouldStartJob {
ret[id] = actions_model.StatusSkipped
continue
}
// Expand a needs-dependent matrix now that its needs are done and the job is going to run.
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.
return nil, fmt.Errorf("expand matrix of job %d: %w", id, err)
}
if actionRunJob.Status != actions_model.StatusBlocked {
// expandDeferredMatrix already persisted the failure, so it bypasses `ret`.
r.statuses[id] = actionRunJob.Status
r.matrixUpdatedJobs = append(r.matrixUpdatedJobs, actionRunJob)
r.matrixChanged = true
continue
}
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 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.
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)
continue
}
if !shouldStartJob {
ret[id] = actions_model.StatusSkipped
continue
}
}
// A slot-starved job cannot start, skip the following checks.
if !slots.available(actionRunJob) {
continue
}
// update concurrency and check whether the job can run now
err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
err = updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
if err != nil {
// The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed
// At the moment there is no way to distinguish them.
@@ -470,22 +533,11 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
continue
}
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
newStatus, cancelledJobs, err := PrepareToStartJobWithConcurrency(ctx, actionRunJob)
if err != nil {
// TODO: surface deterministic expression errors to users by failing the job with a message.
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
continue
}
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
if newStatus == actions_model.StatusWaiting {
var cancelledJobs []*actions_model.ActionRunJob
newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
if err != nil {
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
} else {
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
}
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
} else {
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
}
if newStatus == actions_model.StatusWaiting && !slots.take(actionRunJob) {
@@ -496,7 +548,7 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
ret[id] = newStatus
}
}
return ret
return ret, nil
}
func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJob *actions_model.ActionRunJob, vars map[string]string) error {
+9 -3
View File
@@ -14,6 +14,7 @@ import (
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func minimalWorkflowPayload(jobID string) []byte {
@@ -257,7 +258,9 @@ jobs:
}
r := newJobStatusResolver(tt.jobs, nil)
assert.Equal(t, want, r.Resolve(ctx))
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, want, got)
})
}
}
@@ -277,7 +280,9 @@ func Test_maxParallelConverges(t *testing.T) {
}
for cycle := range totalJobs + 1 {
for id, status := range newJobStatusResolver(jobs, nil).Resolve(ctx) {
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
for id, status := range updates {
jobs[id-1].Status = status
}
counts := statusCounts(jobs)
@@ -562,7 +567,8 @@ func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
}
for cycle := range 2 * len(callers) {
promoted := newJobStatusResolver(callers, nil).Resolve(ctx)
promoted, err := newJobStatusResolver(callers, nil).Resolve(ctx)
require.NoError(t, err)
for id, status := range promoted {
caller := idToCaller[id]
assert.False(t, caller.IsExpanded, "cycle %d: resolver re-promoted already-expanded caller %d", cycle, id)
+226
View File
@@ -0,0 +1,226 @@
// 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"
"go.yaml.in/yaml/v4"
"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 reported through the job itself:
// - 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.
//
// 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.
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) ([]*actions_model.ActionRunJob, 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 nil, 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
}
// 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))
}
if err := job.LoadAttributes(ctx); err != nil {
return retryLater(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"))
}
// `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))
}
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))
}
// 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 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
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 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 retryLater(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))
}
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 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 {
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")
}
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
}
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"strings"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testRunIndex hands out the per-repo run index, which is unique per action_run row.
var testRunIndex int64 = 9100
// setupDeferredMatrixJob plants a completed `generate` job exposing outputs and the blocked `build`
// placeholder that depends on them, and returns the placeholder. Both are children of a reusable
// workflow caller, the case where a sibling losing ParentJobID would break needs resolution.
// jobIf is the `build` job's `if:` expression, omitted entirely when empty.
func setupDeferredMatrixJob(t *testing.T, matrixValue, jobIf string, outputs map[string]string) *actions_model.ActionRunJob {
t.Helper()
ctx := t.Context()
ifLine := ""
if jobIf != "" {
ifLine = " if: " + jobIf + "\n"
}
// The `build` job takes its matrix from `generate`'s outputs, so Parse defers it.
workflows, err := jobparser.Parse(fmt.Appendf(nil, `
on: push
jobs:
generate:
steps: [{run: echo}]
build:
needs: generate
%s strategy:
matrix:
value: %s
steps: [{run: echo}]
`, ifLine, matrixValue))
require.NoError(t, err)
var placeholder *jobparser.SingleWorkflow
for _, workflow := range workflows {
if id, _ := workflow.Job(); id == "build" {
placeholder = workflow
}
}
require.NotNil(t, placeholder, "the build job must be planned as a single deferred placeholder")
id, job := placeholder.Job()
require.NoError(t, placeholder.SetJob(id, job.EraseNeeds()))
payload, err := placeholder.Marshal()
require.NoError(t, err)
testRunIndex++
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, Index: testRunIndex,
WorkflowID: "dynamic.yml", Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
// Needs outputs are read straight from action_task_output, so no action_task row is required;
// the run's id doubles as a task id that is unique across subtests.
taskID := run.ID
for key, value := range outputs {
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{TaskID: taskID, OutputKey: key, OutputValue: value}))
}
newJob := func(jobID string) *actions_model.ActionRunJob {
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
require.NoError(t, err)
return &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: attemptJobID, RepoID: 4, OwnerID: 1,
JobID: jobID, Name: jobID, Status: actions_model.StatusRunning,
}
}
caller := newJob("call")
caller.IsReusableCaller, caller.IsExpanded = true, true
require.NoError(t, db.Insert(ctx, caller))
generate := newJob("generate")
generate.ParentJobID, generate.Status, generate.TaskID = caller.ID, actions_model.StatusSuccess, taskID
require.NoError(t, db.Insert(ctx, generate))
build := newJob("build")
build.ParentJobID, build.Status = caller.ID, actions_model.StatusBlocked
build.Needs, build.WorkflowPayload, build.IsMatrixDeferred = []string{"generate"}, payload, true
build.DeferredMatrixPayload = payload
// Values a sibling must inherit rather than silently reset.
build.WorkflowSourceRepoID, build.WorkflowSourceCommitSHA = 42, "abc123"
require.NoError(t, db.Insert(ctx, build))
return build
}
func TestExpandDeferredMatrix(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("expands into siblings", func(t *testing.T) {
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", "", map[string]string{"values": `["a","b","c"]`})
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
require.NoError(t, err)
require.Len(t, siblings, 2)
// The placeholder is reused as the first combination and stays blocked for the `if:` gate.
assert.Equal(t, "build (a)", job.Name)
assert.False(t, job.IsMatrixDeferred)
assert.Equal(t, actions_model.StatusBlocked, job.Status)
names := []string{job.Name}
for _, sibling := range siblings {
names = append(names, sibling.Name)
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
assert.False(t, sibling.IsMatrixDeferred, "a sibling must not be expanded again")
assert.Equal(t, []string{"generate"}, sibling.Needs)
assert.Equal(t, job.ParentJobID, sibling.ParentJobID, "needs resolution is scoped by ParentJobID")
assert.Equal(t, int64(42), sibling.WorkflowSourceRepoID)
assert.Equal(t, "abc123", sibling.WorkflowSourceCommitSHA)
assert.Greater(t, sibling.AttemptJobID, job.AttemptJobID, "siblings take fresh ids from the run-wide counter")
assert.Empty(t, sibling.DeferredMatrixPayload, "only the placeholder anchors the group with the raw payload")
}
assert.ElementsMatch(t, []string{"build (a)", "build (b)", "build (c)"}, names)
reloaded := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, "build (a)", reloaded.Name)
assert.False(t, reloaded.IsMatrixDeferred)
assert.NotEmpty(t, reloaded.DeferredMatrixPayload, "the claim must not erase the raw payload")
})
// A matrix that can never produce runnable combinations fails the job instead of rolling the
// emitter pass back, so it is not retried forever.
for _, tt := range []struct {
name, matrixValue string
outputs map[string]string
}{
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
{
name: "expansion exceeding the per-attempt job limit",
matrixValue: "${{ fromJson(needs.generate.outputs.many) }}",
// One combination more than the attempt has room for: the cap is MaxJobNumPerRun less
// 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]"},
},
} {
t.Run(tt.name+" fails the job", func(t *testing.T) {
outputs := tt.outputs
if outputs == nil {
outputs = map[string]string{"values": `["a","b","c"]`}
}
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
require.NoError(t, err)
assert.Empty(t, siblings)
assert.Equal(t, actions_model.StatusFailure, job.Status)
assert.True(t, job.IsMatrixDeferred, "the flag survives, marking the payload as still unexpanded for reruns")
assert.NotZero(t, job.Stopped)
// A rejected expansion must not leave the first combination's name behind.
assert.Equal(t, "build", unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Name)
})
}
}
// TestDeferredMatrixResolverGating covers the resolver deciding a placeholder's fate around the
// expansion. A need that did not succeed leaves no outputs to build the matrix from, so the job is
// skipped like any other job with such a need rather than failed over a matrix it never had to
// evaluate, and no combination is inserted. A job that does run is then gated by its own
// combination: the placeholder reused as the first one is judged by `matrix.*`, not by the raw
// expression the `if:` would have seen before expansion.
func TestDeferredMatrixResolverGating(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tt := range []struct {
name string
needStatus actions_model.Status
jobIf string
outputs map[string]string
wantBuilds []string
}{
{name: "failed need", needStatus: actions_model.StatusFailure, wantBuilds: []string{"build"}},
{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"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := t.Context()
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", tt.jobIf, tt.outputs)
_, err := db.Exec(ctx, "UPDATE `action_run_job` SET status = ? WHERE run_id = ? AND job_id = ?", int(tt.needStatus), job.RunID, "generate")
require.NoError(t, err)
jobs := runJobs(t, job.RunID, job.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[job.ID])
var names []string
for _, runJob := range runJobs(t, job.RunID, job.RunAttemptID) {
if runJob.JobID == "build" {
names = append(names, runJob.Name)
}
}
assert.ElementsMatch(t, tt.wantBuilds, names)
})
}
}
+3 -1
View File
@@ -188,7 +188,9 @@ func Test_jobStatusResolver_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
for _, job := range jobs {
job.Run = run
}
assert.Empty(t, newJobStatusResolver(jobs, nil).Resolve(t.Context()), "the starved job must stay blocked")
updates, err := newJobStatusResolver(jobs, nil).Resolve(t.Context())
assert.NoError(t, err)
assert.Empty(t, updates, "the starved job must stay blocked")
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status)
+74 -3
View File
@@ -136,6 +136,12 @@ type rerunPlan struct {
// skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs.
// These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh.
skipCloneTemplateJobIDs container.Set[int64]
// matrixPlaceholderTemplateIDs holds, per dynamic-matrix job whose matrix must be re-derived in the new attempt,
// the template DB row ID to clone as a restored unexpanded placeholder.
// The group's remaining combination rows are in matrixSiblingSkipTemplateIDs and are not cloned: they'll be re-expanded.
matrixPlaceholderTemplateIDs container.Set[int64]
matrixSiblingSkipTemplateIDs container.Set[int64]
}
// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database.
@@ -174,6 +180,7 @@ func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUs
return nil, err
}
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
return plan, nil
}
@@ -247,9 +254,19 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
if plan.skipCloneTemplateJobIDs.Contains(templateJob.ID) {
continue
}
// siblings of a collapsed dynamic-matrix job are not cloned either: re-expansion re-inserts them
if plan.matrixSiblingSkipTemplateIDs.Contains(templateJob.ID) {
continue
}
newJob := cloneRunJobForAttempt(templateJob, newAttempt)
if plan.matrixPlaceholderTemplateIDs.Contains(templateJob.ID) {
if err := restoreDeferredMatrixPlaceholder(newJob); err != nil {
return fmt.Errorf("restore matrix placeholder from job %d: %w", templateJob.ID, err)
}
}
// Remap ParentJobID from template attempts's DB ID -> new attempt's DB ID.
if templateJob.ParentJobID != 0 {
newParentID, ok := templateIDToNewID[templateJob.ParentJobID]
@@ -261,7 +278,9 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
}
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob)
// A deferred-matrix placeholder must go through the emitter, which is the only place
// that expands it: dispatching it directly would hand the runner the raw payload.
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) || newJob.IsMatrixDeferred
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
newJob.TaskID = 0
@@ -360,8 +379,9 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...)
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun)
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
if hasWaitingCallerJobs {
// Post-commit kick for expanded callers and restored matrix placeholders: let job_emitter
// resolve child jobs, and re-expand a placeholder whose needs may all be pass-through and done.
if hasWaitingCallerJobs || len(plan.matrixPlaceholderTemplateIDs) > 0 {
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
}
@@ -519,6 +539,8 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
Needs: slices.Clone(templateJob.Needs),
RunsOn: slices.Clone(templateJob.RunsOn),
ContinueOnError: templateJob.ContinueOnError,
IsMatrixDeferred: templateJob.IsMatrixDeferred,
DeferredMatrixPayload: slices.Clone(templateJob.DeferredMatrixPayload),
Status: templateJob.Status,
RawConcurrency: templateJob.RawConcurrency,
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
@@ -600,3 +622,52 @@ func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.A
return actions_model.UpdateRun(ctx, run, "latest_attempt_id")
})
}
// collectMatrixCollapse decides, per dynamic-matrix job in the rerun set, whether the new
// attempt must re-derive the matrix instead of reusing the previous attempt's combinations,
// and fills matrixPlaceholderTemplateIDs / matrixSiblingSkipTemplateIDs accordingly.
func (p *rerunPlan) collectMatrixCollapse() {
p.matrixPlaceholderTemplateIDs = make(container.Set[int64])
p.matrixSiblingSkipTemplateIDs = make(container.Set[int64])
// Group every template row by the key the rows of one matrix job share.
type groupKey struct {
parentJobID int64
jobID string
}
groups := make(map[groupKey][]*actions_model.ActionRunJob)
for _, tj := range p.templateJobs {
key := groupKey{tj.ParentJobID, tj.JobID}
groups[key] = append(groups[key], tj)
}
for _, rows := range groups {
anchorIdx := slices.IndexFunc(rows, func(j *actions_model.ActionRunJob) bool {
return len(j.DeferredMatrixPayload) > 0
})
if anchorIdx < 0 {
continue // not a dynamic-matrix job: a plain job, or a matrix expanded at plan time
}
anchor := rows[anchorIdx]
// Descendants of a reset caller are not cloned at all; the caller's expansion re-inserts the job.
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
}
unexpanded := len(rows) == 1 && anchor.IsMatrixDeferred
if !unexpanded && !p.hasRerunDependency(anchor) {
continue // needs keep their outputs, reuse the combinations
}
p.matrixPlaceholderTemplateIDs.Add(anchor.ID)
for _, row := range rows {
if row.ID != anchor.ID {
p.matrixSiblingSkipTemplateIDs.Add(row.ID)
}
}
}
}
+27 -4
View File
@@ -318,6 +318,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
continue
}
needs := parsedChild.Needs()
isMatrixDeferred := jobparser.HasDeferredMatrix(parsedChild)
if err := sw.SetJob(jobID, parsedChild.EraseNeeds()); err != nil {
return err
}
@@ -328,11 +329,10 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
// AttemptJobID: prefer a prior-attempt match by (JobID, Name) and fall back to a fresh allocator value for newly-appearing logical jobs.
// The two-level key disambiguates matrix instances (same JobID, different Names) and distinct jobs that legally share the same Name (different JobIDs).
// AttemptJobID: prefer a prior-attempt match and fall back to a fresh allocator value for newly-appearing logical jobs.
var attemptJobID int64
if priorChild, ok := priorChildren[jobID][parsedChild.Name]; ok {
attemptJobID = priorChild.AttemptJobID
if priorID, ok := priorAttemptJobID(priorChildren[jobID], parsedChild.Name, isMatrixDeferred); ok {
attemptJobID = priorID
} else {
attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
@@ -359,6 +359,11 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
ParentJobID: caller.ID,
WorkflowSourceRepoID: sourceRepoID,
WorkflowSourceCommitSHA: sourceCommitSHA,
IsMatrixDeferred: isMatrixDeferred,
}
if isMatrixDeferred {
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
child.DeferredMatrixPayload = payload
}
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
child.TokenPermissions = perms
@@ -411,3 +416,21 @@ func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) erro
}
return nil
}
// priorAttemptJobID returns the AttemptJobID a re-inserted child should reuse,
// given the prior attempt's rows of the same JobID indexed by Name.
func priorAttemptJobID(priorSameJobID map[string]*actions_model.ActionRunJob, name string, isMatrixDeferred bool) (int64, bool) {
if isMatrixDeferred {
for _, prior := range priorSameJobID {
if len(prior.DeferredMatrixPayload) > 0 {
return prior.AttemptJobID, true
}
}
return 0, false
}
prior, ok := priorSameJobID[name]
if !ok {
return 0, false
}
return prior.AttemptJobID, true
}
+6
View File
@@ -186,6 +186,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
id, job := workflowJob.Job()
needs := job.Needs()
isMatrixDeferred := jobparser.HasDeferredMatrix(job)
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err
}
@@ -218,8 +219,13 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
IsMatrixDeferred: isMatrixDeferred,
MaxParallel: parseMaxParallel(id, job.Strategy.MaxParallelString),
}
if isMatrixDeferred {
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
runJob.DeferredMatrixPayload = payload
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
runJob.TokenPermissions = perms