mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-01 09:30:38 +00:00
5672b1c4cf
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>
227 lines
9.5 KiB
Go
227 lines
9.5 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
"gitea.dev/modules/actions/jobparser"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/timeutil"
|
|
"gitea.dev/modules/util"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// expandDeferredMatrix expands a deferred-matrix placeholder once its needs are done and the job is
|
|
// known to run, using the needs' outputs: the placeholder becomes the first combination and the rest
|
|
// are returned as inserted siblings, all left Blocked so the caller's resolver still applies the
|
|
// concurrency gate.
|
|
//
|
|
// It runs inside the caller's transaction (job_emitter's resolver) and must not open a nested
|
|
// db.WithTx, which would reuse the ambient session and roll the whole emitter pass back on error.
|
|
// The three outcomes are reported through the job itself:
|
|
// - expanded: IsMatrixDeferred is cleared and the job stays StatusBlocked.
|
|
// - the workflow's fault (a matrix that cannot resolve, or one too large): a terminal status is
|
|
// persisted here, reported by the job leaving StatusBlocked. IsMatrixDeferred stays set, marking
|
|
// the payload as still unexpanded for a later rerun to re-derive.
|
|
// - not now: the job is left deferred and blocked for the next emitter pass to retry. This covers
|
|
// a transient failure before anything was written, and losing the claim to a concurrent pass.
|
|
//
|
|
// A returned error is reserved for a failure after the placeholder was claimed and must roll the
|
|
// caller's transaction back: committing it would drop the remaining combinations for good.
|
|
func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob, vars map[string]string) ([]*actions_model.ActionRunJob, error) {
|
|
if !job.IsMatrixDeferred {
|
|
return nil, nil
|
|
}
|
|
|
|
// failTerminal fails the job here rather than through the resolver's status map, which a
|
|
// reusable caller (a job may be both) would drop: its branch only handles waiting and skipped.
|
|
// The commit status is unaffected by the surviving flag: suppression only applies while the job
|
|
// is not done.
|
|
failTerminal := func(cause error) ([]*actions_model.ActionRunJob, error) {
|
|
log.Warn("Matrix expansion failed for job %d (JobID: %s): %v", job.ID, job.JobID, cause)
|
|
prevStatus, prevStopped := job.Status, job.Stopped
|
|
job.Status = actions_model.StatusFailure
|
|
job.Stopped = timeutil.TimeStampNow()
|
|
// The flag survives, so it alone no longer tells a fresh placeholder from one a concurrent
|
|
// pass already failed: the status has to be part of the condition.
|
|
affected, err := actions_model.UpdateRunJob(ctx, job,
|
|
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
|
|
"status", "stopped")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
|
|
}
|
|
if affected != 1 {
|
|
// A concurrent pass already advanced the row. Restore the in-memory state so this pass
|
|
// does not report a failure that was never persisted.
|
|
job.Status, job.Stopped = prevStatus, prevStopped
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// retryLater leaves the placeholder untouched. It is only used before anything is written, so a
|
|
// transient failure neither fails the job nor aborts the emitter pass for the whole run.
|
|
retryLater := func(cause error) ([]*actions_model.ActionRunJob, error) {
|
|
log.Error("Matrix expansion of job %d (JobID: %s) postponed to the next pass: %v", job.ID, job.JobID, cause)
|
|
return nil, nil
|
|
}
|
|
|
|
// The resolver only calls this once every need is done, as it does for job concurrency.
|
|
results, err := findJobNeedsAndFillJobResults(ctx, job)
|
|
if err != nil {
|
|
return retryLater(fmt.Errorf("find needs: %w", err))
|
|
}
|
|
|
|
if err := job.LoadAttributes(ctx); err != nil {
|
|
return retryLater(fmt.Errorf("load attributes: %w", err))
|
|
}
|
|
|
|
// The payload still carries the raw, unevaluated matrix: planning only erases the needs.
|
|
var baseSWF jobparser.SingleWorkflow
|
|
if err := yaml.Unmarshal(job.WorkflowPayload, &baseSWF); err != nil {
|
|
return failTerminal(fmt.Errorf("unmarshal payload: %w", err))
|
|
}
|
|
_, parsedJob := baseSWF.Job()
|
|
if parsedJob == nil {
|
|
return failTerminal(errors.New("payload contains no job"))
|
|
}
|
|
|
|
// `strategy` may reference the inputs context as well as needs, so resolve it like `if:` does.
|
|
inputs, err := getInputsForJob(ctx, job.Run, job)
|
|
if err != nil {
|
|
return retryLater(fmt.Errorf("get inputs: %w", err))
|
|
}
|
|
|
|
existingJobs, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
|
if err != nil {
|
|
return retryLater(fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err))
|
|
}
|
|
// The placeholder is reused as the first combination, so the attempt only grows by len-1.
|
|
maxCombinations := int(actions_model.MaxJobNumPerRun - existingJobs + 1)
|
|
|
|
giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job)
|
|
expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, inputs, maxCombinations)
|
|
if err != nil {
|
|
return failTerminal(fmt.Errorf("expand matrix: %w", err))
|
|
}
|
|
// Combinations differ only in what the matrix feeds: the name, the payload, and a
|
|
// runs-on/continue-on-error that may interpolate matrix.*.
|
|
applyCombo := func(dst *actions_model.ActionRunJob, combo *jobparser.Job) error {
|
|
swf := baseSWF
|
|
if err := swf.SetJob(job.JobID, combo.EraseNeeds()); err != nil {
|
|
return fmt.Errorf("set expanded job: %w", err)
|
|
}
|
|
payload, err := swf.Marshal()
|
|
if err != nil {
|
|
return fmt.Errorf("marshal expanded job: %w", err)
|
|
}
|
|
dst.Name = util.EllipsisDisplayString(combo.Name, 255)
|
|
dst.WorkflowPayload, dst.RunsOn = payload, combo.RunsOn()
|
|
dst.ContinueOnError = combo.GetContinueOnError()
|
|
return nil
|
|
}
|
|
|
|
siblings := make([]*actions_model.ActionRunJob, 0, len(expandedJobs)-1)
|
|
for _, combo := range expandedJobs[1:] {
|
|
// Inherit from the placeholder rather than listing fields, so a sibling cannot silently lose
|
|
// one (scope, permissions, `uses:`) as the job model grows.
|
|
sibling := *job
|
|
sibling.ID, sibling.TaskID, sibling.SourceTaskID, sibling.AttemptJobID = 0, 0, 0, 0
|
|
sibling.Started, sibling.Stopped, sibling.IsMatrixDeferred = 0, 0, false
|
|
sibling.Status = actions_model.StatusBlocked
|
|
sibling.Needs = slices.Clone(job.Needs)
|
|
// Only the placeholder keeps the raw payload, which is what identifies it as the group's anchor.
|
|
sibling.DeferredMatrixPayload = nil
|
|
if err := applyCombo(&sibling, combo); err != nil {
|
|
return failTerminal(err)
|
|
}
|
|
siblings = append(siblings, &sibling)
|
|
}
|
|
|
|
// Keep AttemptJobIDs stable across attempts (best-effort). A single combination reuses the
|
|
// placeholder's row, so there is nothing to look up.
|
|
if len(siblings) > 0 {
|
|
parentAttemptJobID := int64(0)
|
|
if job.ParentJobID > 0 {
|
|
parent, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
|
if err != nil {
|
|
return retryLater(fmt.Errorf("load parent of job %d: %w", job.ID, err))
|
|
}
|
|
parentAttemptJobID = parent.AttemptJobID
|
|
}
|
|
priorCombos, err := actions_model.GetPriorAttemptMatrixCombos(ctx, job.RunID, job.RunAttemptID, parentAttemptJobID, job.JobID)
|
|
if err != nil {
|
|
return retryLater(fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err))
|
|
}
|
|
usedIDs := container.SetOf(job.AttemptJobID)
|
|
for _, sibling := range siblings {
|
|
if prior, ok := priorCombos[sibling.Name]; ok && usedIDs.Add(prior.AttemptJobID) {
|
|
sibling.AttemptJobID = prior.AttemptJobID
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reusing the placeholder leaves no phantom skipped job behind to poison downstream needs. The
|
|
// conditional update is an atomic claim: only the caller that flips IsMatrixDeferred inserts.
|
|
beforeClaim := *job
|
|
if err := applyCombo(job, expandedJobs[0]); err != nil {
|
|
return failTerminal(err)
|
|
}
|
|
job.IsMatrixDeferred = false
|
|
affected, err := actions_model.UpdateRunJob(ctx, job,
|
|
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
|
|
"name", "workflow_payload", "runs_on", "continue_on_error", "is_matrix_deferred")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim placeholder of job %d: %w", job.ID, err)
|
|
}
|
|
if affected != 1 {
|
|
// A concurrent pass won the claim and owns the siblings. Restore the in-memory state so this
|
|
// pass leaves the job alone and picks the winner's rows up on the next one.
|
|
*job = beforeClaim
|
|
return nil, nil
|
|
}
|
|
|
|
if len(siblings) == 0 {
|
|
return nil, nil
|
|
}
|
|
for _, sibling := range siblings {
|
|
if sibling.AttemptJobID != 0 {
|
|
continue // matched a prior attempt above
|
|
}
|
|
if sibling.AttemptJobID, err = actions_model.GetNextAttemptJobID(ctx, job.RunID); err != nil {
|
|
return nil, fmt.Errorf("alloc attempt_job_id for job %d: %w", job.ID, err)
|
|
}
|
|
}
|
|
if err := db.Insert(ctx, siblings); err != nil {
|
|
return nil, fmt.Errorf("insert matrix siblings of job %d: %w", job.ID, err)
|
|
}
|
|
return siblings, nil
|
|
}
|
|
|
|
// restoreDeferredMatrixPlaceholder rewinds a rerun clone of a dynamic-matrix combination into the unexpanded placeholder it grew from
|
|
func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
|
|
var swf jobparser.SingleWorkflow
|
|
if err := yaml.Unmarshal(clone.DeferredMatrixPayload, &swf); err != nil {
|
|
return fmt.Errorf("unmarshal deferred matrix payload: %w", err)
|
|
}
|
|
_, parsed := swf.Job()
|
|
if parsed == nil {
|
|
return errors.New("deferred matrix payload contains no job")
|
|
}
|
|
clone.Name = util.EllipsisDisplayString(parsed.Name, 255)
|
|
clone.WorkflowPayload = slices.Clone(clone.DeferredMatrixPayload)
|
|
clone.RunsOn = parsed.RunsOn()
|
|
clone.ContinueOnError = parsed.GetContinueOnError()
|
|
clone.IsMatrixDeferred = true
|
|
return nil
|
|
}
|