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
+60
View File
@@ -13,6 +13,7 @@ import (
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
@@ -72,6 +73,18 @@ type ActionRunJob struct {
// MaxParallel is strategy.max-parallel, shared by all matrix jobs with the same JobID (0 = unlimited).
MaxParallel int `xorm:"NOT NULL DEFAULT 0"`
// IsMatrixDeferred marks a placeholder for a job whose matrix references `needs.*.outputs.*` and so
// could not be expanded at planning time. Its WorkflowPayload still carries the raw, unevaluated
// matrix; the job emitter expands it once the needs finish. Only a successful expansion clears the flag:
// it survives a terminal status (skipped, cancelled, failed expansion) so a rerun can recognize
// the row as unexpanded and re-derive the matrix instead of dispatching the raw payload.
IsMatrixDeferred bool `xorm:"NOT NULL DEFAULT FALSE"`
// DeferredMatrixPayload preserves a deferred-matrix placeholder's original WorkflowPayload (the raw, unevaluated matrix).
// A rerun whose needs re-run collapses the combinations back into a single placeholder built from this payload,
// so the matrix is re-derived from the fresh outputs.
DeferredMatrixPayload []byte `xorm:"LONGBLOB"`
// RunAttemptID identifies the ActionRunAttempt this job belongs to.
// A value of 0 indicates a legacy job created before ActionRunAttempt existed.
RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"`
@@ -323,6 +336,53 @@ func GetPriorAttemptChildrenByParent(ctx context.Context, runID, currentAttemptI
return nil, nil //nolint:nilnil // every prior attempt skipped this caller
}
// GetPriorAttemptMatrixCombos returns the most recent prior attempt's combination rows of the given
// dynamic-matrix job, indexed by Name, so re-expansion keeps AttemptJobIDs stable across attempts.
func GetPriorAttemptMatrixCombos(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64, jobID string) (map[string]*ActionRunJob, error) {
// An unexpanded placeholder is not a combination, so it is skipped and the search looks further
// back past it. Only the columns the scope check and the result need are read: the rows carry
// two payload blobs, and every prior attempt of the job is a candidate.
var candidates []*ActionRunJob
if err := db.GetEngine(ctx).
Where("run_id = ? AND job_id = ? AND run_attempt_id < ? AND is_matrix_deferred = ?", runID, jobID, currentAttemptID, false).
Cols("id", "name", "attempt_job_id", "run_attempt_id", "parent_job_id").
Desc("run_attempt_id").
Find(&candidates); err != nil {
return nil, fmt.Errorf("find prior matrix combos: %w", err)
}
// Every combination of one attempt shares a parent, so dedupe before the lookup.
parentIDs := container.FilterSlice(candidates, func(c *ActionRunJob) (int64, bool) {
return c.ParentJobID, c.ParentJobID > 0
})
parentAttemptIDByRowID := make(map[int64]int64, len(parentIDs))
if len(parentIDs) > 0 {
var parents []*ActionRunJob
if err := db.GetEngine(ctx).In("id", parentIDs).Cols("id", "attempt_job_id").Find(&parents); err != nil {
return nil, fmt.Errorf("find prior matrix combo parents: %w", err)
}
for _, p := range parents {
parentAttemptIDByRowID[p.ID] = p.AttemptJobID
}
}
// Rows arrive newest-attempt-first, so the first in-scope row fixes the attempt to take.
combos := map[string]*ActionRunJob{}
newestAttemptID := int64(0)
for _, c := range candidates {
if parentAttemptIDByRowID[c.ParentJobID] != parentAttemptJobID {
continue
}
if newestAttemptID == 0 {
newestAttemptID = c.RunAttemptID
} else if c.RunAttemptID != newestAttemptID {
break
}
combos[c.Name] = c
}
return combos, nil
}
// GetDirectChildJobsByParent returns the direct child jobs of a parent job (e.g. a reusable workflow caller).
func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (ActionJobList, error) {
var jobs []*ActionRunJob