Skip to content

Commit 5672b1c

Browse files
ZPascalClaudesilverwindclaudebircni
authored
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 #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>
1 parent 717db27 commit 5672b1c

20 files changed

Lines changed: 1252 additions & 69 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ require (
8989
github.com/prometheus/client_golang v1.24.0
9090
github.com/quasoft/websspi v1.1.2
9191
github.com/redis/go-redis/v9 v9.21.0
92+
github.com/rhysd/actionlint v1.7.12
9293
github.com/robfig/cron/v3 v3.0.1
9394
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
9495
github.com/sassoftware/go-rpmutils v0.4.0
@@ -244,7 +245,6 @@ require (
244245
github.com/prometheus/common v0.70.0 // indirect
245246
github.com/prometheus/procfs v0.21.1 // indirect
246247
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
247-
github.com/rhysd/actionlint v1.7.12 // indirect
248248
github.com/rs/xid v1.6.0 // indirect
249249
github.com/russross/blackfriday/v2 v2.1.0 // indirect
250250
github.com/shopspring/decimal v1.4.0 // indirect

modelmigration/migrations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ func prepareMigrationTasks() []*migration {
424424
// Gitea 1.27.0 ends at migration ID number 342 (database version 343)
425425

426426
newMigration(343, "Add max_parallel column to action_run_job", v1_28.AddMaxParallelToActionRunJob),
427+
newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob),
427428
}
428429
return preparedMigrations
429430
}

modelmigration/v1_28/v344.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_28
5+
6+
import (
7+
"gitea.dev/modelmigration/base"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
// AddDeferredMatrixColumnsToActionRunJob adds the columns backing deferred (dynamic) matrix expansion
13+
func AddDeferredMatrixColumnsToActionRunJob(x base.EngineMigration) error {
14+
type ActionRunJob struct {
15+
// IsMatrixDeferred marks jobs whose matrix depends on other jobs' outputs and is therefore expanded only once those jobs finish;
16+
IsMatrixDeferred bool `xorm:"NOT NULL DEFAULT FALSE"`
17+
// DeferredMatrixPayload preserves the raw, unevaluated payload across expansion so a rerun can re-derive the matrix
18+
DeferredMatrixPayload []byte `xorm:"LONGBLOB"`
19+
}
20+
_, err := x.SyncWithOptions(xorm.SyncOptions{
21+
IgnoreDropIndices: true,
22+
IgnoreConstrains: true,
23+
}, new(ActionRunJob))
24+
return err
25+
}

models/actions/run_job.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"gitea.dev/models/db"
1414
repo_model "gitea.dev/models/repo"
1515
"gitea.dev/modules/actions/jobparser"
16+
"gitea.dev/modules/container"
1617
"gitea.dev/modules/log"
1718
"gitea.dev/modules/timeutil"
1819
"gitea.dev/modules/util"
@@ -72,6 +73,18 @@ type ActionRunJob struct {
7273
// MaxParallel is strategy.max-parallel, shared by all matrix jobs with the same JobID (0 = unlimited).
7374
MaxParallel int `xorm:"NOT NULL DEFAULT 0"`
7475

76+
// IsMatrixDeferred marks a placeholder for a job whose matrix references `needs.*.outputs.*` and so
77+
// could not be expanded at planning time. Its WorkflowPayload still carries the raw, unevaluated
78+
// matrix; the job emitter expands it once the needs finish. Only a successful expansion clears the flag:
79+
// it survives a terminal status (skipped, cancelled, failed expansion) so a rerun can recognize
80+
// the row as unexpanded and re-derive the matrix instead of dispatching the raw payload.
81+
IsMatrixDeferred bool `xorm:"NOT NULL DEFAULT FALSE"`
82+
83+
// DeferredMatrixPayload preserves a deferred-matrix placeholder's original WorkflowPayload (the raw, unevaluated matrix).
84+
// A rerun whose needs re-run collapses the combinations back into a single placeholder built from this payload,
85+
// so the matrix is re-derived from the fresh outputs.
86+
DeferredMatrixPayload []byte `xorm:"LONGBLOB"`
87+
7588
// RunAttemptID identifies the ActionRunAttempt this job belongs to.
7689
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
7790
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
@@ -323,6 +336,53 @@ func GetPriorAttemptChildrenByParent(ctx context.Context, runID, currentAttemptI
323336
return nil, nil //nolint:nilnil // every prior attempt skipped this caller
324337
}
325338

339+
// GetPriorAttemptMatrixCombos returns the most recent prior attempt's combination rows of the given
340+
// dynamic-matrix job, indexed by Name, so re-expansion keeps AttemptJobIDs stable across attempts.
341+
func GetPriorAttemptMatrixCombos(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64, jobID string) (map[string]*ActionRunJob, error) {
342+
// An unexpanded placeholder is not a combination, so it is skipped and the search looks further
343+
// back past it. Only the columns the scope check and the result need are read: the rows carry
344+
// two payload blobs, and every prior attempt of the job is a candidate.
345+
var candidates []*ActionRunJob
346+
if err := db.GetEngine(ctx).
347+
Where("run_id = ? AND job_id = ? AND run_attempt_id < ? AND is_matrix_deferred = ?", runID, jobID, currentAttemptID, false).
348+
Cols("id", "name", "attempt_job_id", "run_attempt_id", "parent_job_id").
349+
Desc("run_attempt_id").
350+
Find(&candidates); err != nil {
351+
return nil, fmt.Errorf("find prior matrix combos: %w", err)
352+
}
353+
354+
// Every combination of one attempt shares a parent, so dedupe before the lookup.
355+
parentIDs := container.FilterSlice(candidates, func(c *ActionRunJob) (int64, bool) {
356+
return c.ParentJobID, c.ParentJobID > 0
357+
})
358+
parentAttemptIDByRowID := make(map[int64]int64, len(parentIDs))
359+
if len(parentIDs) > 0 {
360+
var parents []*ActionRunJob
361+
if err := db.GetEngine(ctx).In("id", parentIDs).Cols("id", "attempt_job_id").Find(&parents); err != nil {
362+
return nil, fmt.Errorf("find prior matrix combo parents: %w", err)
363+
}
364+
for _, p := range parents {
365+
parentAttemptIDByRowID[p.ID] = p.AttemptJobID
366+
}
367+
}
368+
369+
// Rows arrive newest-attempt-first, so the first in-scope row fixes the attempt to take.
370+
combos := map[string]*ActionRunJob{}
371+
newestAttemptID := int64(0)
372+
for _, c := range candidates {
373+
if parentAttemptIDByRowID[c.ParentJobID] != parentAttemptJobID {
374+
continue
375+
}
376+
if newestAttemptID == 0 {
377+
newestAttemptID = c.RunAttemptID
378+
} else if c.RunAttemptID != newestAttemptID {
379+
break
380+
}
381+
combos[c.Name] = c
382+
}
383+
return combos, nil
384+
}
385+
326386
// GetDirectChildJobsByParent returns the direct child jobs of a parent job (e.g. a reusable workflow caller).
327387
func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (ActionJobList, error) {
328388
var jobs []*ActionRunJob

modules/actions/jobparser/jobparser.go

Lines changed: 188 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,61 @@ package jobparser
55

66
import (
77
"bytes"
8+
"errors"
89
"fmt"
10+
"slices"
911
"sort"
1012
"strings"
1113

1214
"gitea.com/gitea/runner/act/exprparser"
1315
"gitea.com/gitea/runner/act/model"
16+
"github.com/rhysd/actionlint"
1417
"go.yaml.in/yaml/v4"
1518
)
1619

20+
// HasDeferredMatrix reports whether the job's matrix can only be expanded once its needs finish:
21+
// it reads the needs context and the job has needs to resolve that context against.
22+
// Parse emits such a job as a single placeholder rather than one job per combination, so every
23+
// caller that persists a job must agree with Parse on this condition.
24+
func HasDeferredMatrix(job *Job) bool {
25+
return len(job.Needs()) > 0 && rawMatrixReadsNeeds(&job.Strategy.RawMatrix)
26+
}
27+
28+
func rawMatrixReadsNeeds(node *yaml.Node) bool {
29+
if node.Kind == yaml.ScalarNode {
30+
return expressionReadsNeeds(node.Value)
31+
}
32+
return slices.ContainsFunc(node.Content, rawMatrixReadsNeeds)
33+
}
34+
35+
// expressionReadsNeeds reports whether value holds a ${{ }} expression reading the needs context.
36+
// Every other context (github, vars, inputs, ...) is already available while planning, so deferring
37+
// those too would replace their combinations with one placeholder and change the commit status
38+
// contexts the run publishes, which a repository's required checks are configured against.
39+
func expressionReadsNeeds(value string) bool {
40+
for rest := value; ; {
41+
_, after, found := strings.Cut(rest, "${{")
42+
if !found {
43+
return false
44+
}
45+
rest = after
46+
// The lexer ends the expression at its closing `}}`, so it can be handed the whole remainder.
47+
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(rest))
48+
if err != nil {
49+
return true // unparseable here, let the expansion report it against the real values
50+
}
51+
readsNeeds := false
52+
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
53+
if variable, ok := node.(*actionlint.VariableNode); entering && ok && strings.EqualFold(variable.Name, "needs") {
54+
readsNeeds = true
55+
}
56+
})
57+
if readsNeeds {
58+
return true
59+
}
60+
}
61+
}
62+
1763
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
1864
origin, err := model.ReadWorkflow(bytes.NewReader(content))
1965
if err != nil {
@@ -37,7 +83,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
3783
results[id] = &JobResult{
3884
Needs: job.Needs(),
3985
Result: pc.jobResults[id],
40-
Outputs: nil, // not supported yet
86+
Outputs: nil, // resolved at expansion time, not at plan time
4187
}
4288
}
4389

@@ -52,35 +98,34 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
5298

5399
for i, id := range ids {
54100
job := jobs[i]
55-
matricxes, err := getMatrixes(origin.GetJob(id))
56-
if err != nil {
57-
return nil, fmt.Errorf("getMatrixes: %w", err)
101+
originJob := origin.GetJob(id)
102+
103+
if originJob == nil {
104+
return nil, fmt.Errorf("job %s not found in origin workflow", id)
58105
}
59-
for _, matrix := range matricxes {
60-
job := job.Clone()
61-
if job.Name == "" {
62-
job.Name = id
63-
}
64-
job.Strategy.RawMatrix = encodeMatrix(matrix)
65-
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
66-
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
67-
runsOn := origin.GetJob(id).RunsOn()
68-
for i, v := range runsOn {
69-
runsOn[i] = evaluator.Interpolate(v)
106+
107+
var combos []*Job
108+
if HasDeferredMatrix(job) {
109+
// The matrix reads values that do not exist yet (a needs output), so emit a single
110+
// placeholder keeping it raw. Re-parsing that placeholder's payload yields it again,
111+
// and the server expands it once the needs finish.
112+
placeholder := job.Clone()
113+
if placeholder.Name == "" {
114+
placeholder.Name = id
70115
}
71-
job.RawRunsOn = encodeRunsOn(runsOn)
72-
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
73-
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", id, err)
116+
combos = []*Job{placeholder}
117+
} else {
118+
matricxes, err := getMatrixes(originJob)
119+
if err != nil {
120+
return nil, fmt.Errorf("getMatrixes: %w", err)
74121
}
75-
swf := &SingleWorkflow{
76-
Name: workflow.Name,
77-
RawOn: workflow.RawOn,
78-
Env: workflow.Env,
79-
Defaults: workflow.Defaults,
80-
RawPermissions: workflow.RawPermissions,
81-
RunName: workflow.RunName,
122+
if combos, err = buildMatrixCombos(id, job, matricxes, originJob, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
123+
return nil, err
82124
}
83-
if err := swf.SetJob(id, job); err != nil {
125+
}
126+
for _, combo := range combos {
127+
swf := workflow.cloneHeader()
128+
if err := swf.SetJob(id, combo); err != nil {
84129
return nil, fmt.Errorf("SetJob: %w", err)
85130
}
86131
ret = append(ret, swf)
@@ -89,6 +134,121 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
89134
return ret, nil
90135
}
91136

137+
// cloneHeader returns a copy of w with its workflow-global fields but no jobs.
138+
func (w *SingleWorkflow) cloneHeader() *SingleWorkflow {
139+
return &SingleWorkflow{
140+
Name: w.Name,
141+
RawOn: w.RawOn,
142+
Env: w.Env,
143+
Defaults: w.Defaults,
144+
RawPermissions: w.RawPermissions,
145+
RunName: w.RunName,
146+
}
147+
}
148+
149+
// ExpandMatrixWithNeeds returns one Job per combination of job's matrix, resolved against the
150+
// completed needs in results. As for EvaluateConcurrency, results must also describe jobID itself,
151+
// which is where NewInterpeter reads the job's own needs from.
152+
// maxCombinations caps how many combinations may be built: the values come from a needs output at
153+
// runtime, so the cap has to be enforced before one Job is materialized per combination.
154+
func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any, maxCombinations int) ([]*Job, error) {
155+
actJob := &model.Job{Strategy: &model.Strategy{
156+
FailFastString: job.Strategy.FailFastString,
157+
MaxParallelString: job.Strategy.MaxParallelString,
158+
RawMatrix: job.Strategy.RawMatrix,
159+
}}
160+
161+
// Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
162+
if err := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs)).
163+
EvaluateYamlNode(&actJob.Strategy.RawMatrix); err != nil {
164+
return nil, fmt.Errorf("evaluate matrix: %w", err)
165+
}
166+
matrixes, err := getMatrixes(actJob)
167+
if err != nil {
168+
return nil, fmt.Errorf("getMatrixes: %w", err)
169+
}
170+
// act collapses a matrix that yields no combination (an empty vector or include, everything
171+
// excluded, a whole-matrix expression that is not a mapping) into one empty combination, which
172+
// would run the job once unparameterized. GitHub rejects such a matrix, so reject it too.
173+
if len(matrixes) == 1 && len(matrixes[0]) == 0 {
174+
return nil, errors.New("matrix must define at least one vector")
175+
}
176+
if len(matrixes) > maxCombinations {
177+
return nil, fmt.Errorf("matrix expands to %d combinations, exceeding the limit of %d", len(matrixes), maxCombinations)
178+
}
179+
return buildMatrixCombos(jobID, job, matrixes, actJob, gitCtx, results, vars, inputs)
180+
}
181+
182+
// matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
183+
// the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
184+
// and the emitter reads its `if:` before expanding it.
185+
// TODO: drop the check once gitea.com/gitea/runner validates the shape itself.
186+
func matrixesOf(job *model.Job) ([]map[string]any, error) {
187+
if err := validateMatrixFilters(job); err != nil {
188+
return nil, err
189+
}
190+
matrixes, err := job.GetMatrixes()
191+
if err != nil {
192+
return nil, fmt.Errorf("GetMatrixes: %w", err)
193+
}
194+
return matrixes, nil
195+
}
196+
197+
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings. act asserts
198+
// that shape without checking, so anything else panics there; an unevaluated ${{ }} expression, which
199+
// is still a scalar, is the usual way to reach it.
200+
func validateMatrixFilters(job *model.Job) error {
201+
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
202+
return nil
203+
}
204+
content := job.Strategy.RawMatrix.Content
205+
for i := 0; i+1 < len(content); i += 2 {
206+
name, value := content[i].Value, content[i+1]
207+
if name != "include" && name != "exclude" {
208+
continue
209+
}
210+
entries := []*yaml.Node{value}
211+
if value.Kind == yaml.SequenceNode {
212+
entries = value.Content
213+
}
214+
for _, entry := range entries {
215+
if entry.Kind == yaml.AliasNode {
216+
entry = entry.Alias
217+
}
218+
if entry.Kind != yaml.MappingNode {
219+
return fmt.Errorf("matrix %s must be a list of mappings", name)
220+
}
221+
}
222+
}
223+
return nil
224+
}
225+
226+
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
227+
// strategy and interpolating the name, runs-on and continue-on-error with it.
228+
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, actJob *model.Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
229+
srcRunsOn := src.RunsOn()
230+
combos := make([]*Job, 0, len(matrixes))
231+
for _, matrix := range matrixes {
232+
combo := src.Clone()
233+
if combo.Name == "" {
234+
combo.Name = jobID
235+
}
236+
combo.Strategy.RawMatrix = encodeMatrix(matrix)
237+
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs))
238+
combo.Name = nameWithMatrix(combo.Name, matrix, evaluator)
239+
runsOn := slices.Clone(srcRunsOn)
240+
for i := range runsOn {
241+
runsOn[i] = evaluator.Interpolate(runsOn[i])
242+
}
243+
combo.RawRunsOn = encodeRunsOn(runsOn)
244+
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
245+
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
246+
}
247+
combos = append(combos, combo)
248+
}
249+
return combos, nil
250+
}
251+
92252
func WithGitContext(context *model.GithubContext) ParseOption {
93253
return func(c *parseContext) {
94254
c.gitContext = context
@@ -117,9 +277,9 @@ type parseContext struct {
117277
type ParseOption func(c *parseContext)
118278

119279
func getMatrixes(job *model.Job) ([]map[string]any, error) {
120-
ret, err := job.GetMatrixes()
280+
ret, err := matrixesOf(job)
121281
if err != nil {
122-
return nil, fmt.Errorf("GetMatrixes: %w", err)
282+
return nil, err
123283
}
124284
sort.Slice(ret, func(i, j int) bool {
125285
return matrixName(ret[i]) < matrixName(ret[j])

0 commit comments

Comments
 (0)