Files
Gitea/services/actions/matrix_test.go
T
Pascal Zimmermann 5672b1c4cf 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>
2026-07-28 15:59:00 +00:00

221 lines
9.0 KiB
Go

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