Files
Gitea/tests/integration/actions_dynamic_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

166 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"maps"
"net/http"
"net/url"
"strings"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDynamicMatrixEvaluation covers a job whose matrix references ${{ needs.*.outputs.* }}: it is
// 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.
// 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) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-dynamic-matrix-eval", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
const workflow = `name: dynamic-matrix
on:
push:
paths: ['.gitea/workflows/dynamic-matrix.yml']
jobs:
generate:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: echo "matrix=[1,2]" >> "$GITHUB_OUTPUT"
build:
needs: [generate]
runs-on: ubuntu-latest
outputs:
result: ${{ steps.out.outputs.result }}
strategy:
matrix:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- id: out
run: echo "result=built-${{ matrix.value }}" >> "$GITHUB_OUTPUT"
static:
needs: [generate]
runs-on: ubuntu-latest
strategy:
matrix:
os: [a, b]
steps:
- run: echo "${{ matrix.os }}"
gated:
needs: [generate]
if: false
runs-on: ubuntu-latest
strategy:
matrix:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
partial:
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
steps:
- run: echo '${{ toJSON(needs.build.outputs) }}'
`
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, "create dynamic-matrix.yml", workflow)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, ".gitea/workflows/dynamic-matrix.yml", opts)
generateTask := runner.fetchTask(t, 10*time.Second)
require.Equal(t, "generate", getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, generateTask.Id))
_, _, run := getTaskAndJobAndRunByTaskID(t, generateTask.Id)
runner.execTask(t, generateTask, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{"matrix": "[1,2]"},
})
// execAttempt runs the attempt's remaining tasks, recording each job's name and AttemptJobID(order is not fixed).
attemptJobIDs := map[string]int64{}
var reportTask *runnerv1.Task
execAttempt := func(t *testing.T, count int) []string {
names := make([]string, 0, count)
for range count {
task := runner.fetchTask(t, 10*time.Second)
_, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id)
names = append(names, taskJob.Name)
attemptJobIDs[taskJob.Name] = taskJob.AttemptJobID
outcome := &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}
if value, ok := strings.CutPrefix(taskJob.Name, "build ("); ok {
outcome.outputs = map[string]string{"result": "built-" + strings.TrimSuffix(value, ")")}
}
if taskJob.JobID == "report" {
reportTask = task
}
runner.execTask(t, task, outcome)
}
runner.fetchNoTask(t, 300*time.Millisecond)
return names
}
seen := execAttempt(t, 6)
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)
buildNeed, ok := reportTask.Needs["build"]
require.True(t, ok, "report must see the expanded combinations as its 'build' need")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, buildNeed.Result)
assert.Contains(t, []string{"built-1", "built-2"}, buildNeed.Outputs["result"])
// Re-running the whole run collapses the combinations back into a placeholder and re-derives
// the matrix from the new attempt's outputs instead of reusing the previous combinations.
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, apiRepo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
generateTask2 := runner.fetchTask(t, 10*time.Second)
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]"},
})
// The matrix now yields a third value, while `static` is cloned as-is rather than collapsed.
seenRerun := execAttempt(t, 8)
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "build (3)", "static (a)", "static (b)", "partial (2)", "partial (3)", "report",
}, seenRerun)
for name, firstID := range firstAttemptIDs {
assert.Equal(t, firstID, attemptJobIDs[name], "%s keeps its AttemptJobID across attempts", name)
}
})
}