mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-05 15:56:19 +00:00
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:
committed by
GitHub
parent
717db275d5
commit
5672b1c4cf
@@ -0,0 +1,165 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user