mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-08 12:27:44 +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
@@ -54,6 +54,13 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
// A deferred-matrix placeholder's name changes when it expands, so a status created while it
|
||||
// waits would be orphaned. The emitter reloads the jobs after expanding and creates them
|
||||
// then. A placeholder that reached a final status (skipped by its `if:`, cancelled with the
|
||||
// run, or failed to expand) never expands, so it keeps its own name and still deserves a status.
|
||||
if job.IsMatrixDeferred && !job.Status.IsDone() {
|
||||
continue
|
||||
}
|
||||
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
|
||||
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// GenerateGiteaContext dereferences the run's repo and trigger user, so load them here instead of
|
||||
// relying on whatever the caller happened to load before.
|
||||
if err := run.LoadRepo(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := run.LoadTriggerUser(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
|
||||
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -270,6 +269,7 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -282,7 +282,10 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
|
||||
job.Run = run
|
||||
}
|
||||
|
||||
updates := resolver.Resolve(ctx)
|
||||
updates, err := resolver.Resolve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
status, ok := updates[job.ID]
|
||||
if !ok {
|
||||
@@ -336,7 +339,10 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if expandedAnyCaller {
|
||||
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
|
||||
// Caller and matrix expansion both insert Blocked jobs, which only a follow-up pass resolves.
|
||||
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
|
||||
if expandedAnyCaller || resolver.matrixChanged {
|
||||
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
|
||||
}
|
||||
result.CancelledJobs = resolver.cancelledJobs
|
||||
@@ -352,6 +358,12 @@ type jobStatusResolver struct {
|
||||
jobMap map[int64]*actions_model.ActionRunJob
|
||||
vars map[string]string
|
||||
cancelledJobs []*actions_model.ActionRunJob
|
||||
// matrixChanged is set when matrix expansion inserted siblings or failed a placeholder, both of
|
||||
// which need a follow-up pass to resolve the dependents.
|
||||
matrixChanged bool
|
||||
// matrixUpdatedJobs holds jobs whose status matrix expansion persisted itself, so they are
|
||||
// notified like the ones the caller updates from the resolved status map.
|
||||
matrixUpdatedJobs []*actions_model.ActionRunJob
|
||||
}
|
||||
|
||||
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
|
||||
@@ -392,19 +404,22 @@ func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]stri
|
||||
}
|
||||
}
|
||||
|
||||
func (r *jobStatusResolver) Resolve(ctx context.Context) map[int64]actions_model.Status {
|
||||
func (r *jobStatusResolver) Resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
|
||||
ret := map[int64]actions_model.Status{}
|
||||
for i := 0; i < len(r.statuses); i++ {
|
||||
updated := r.resolve(ctx)
|
||||
updated, err := r.resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(updated) == 0 {
|
||||
return ret
|
||||
return ret, nil
|
||||
}
|
||||
for k, v := range updated {
|
||||
ret[k] = v
|
||||
r.statuses[k] = v
|
||||
}
|
||||
}
|
||||
return ret
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed bool) {
|
||||
@@ -426,7 +441,7 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
|
||||
return allDone, allSucceed
|
||||
}
|
||||
|
||||
func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model.Status {
|
||||
func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
|
||||
ret := map[int64]actions_model.Status{}
|
||||
|
||||
slots := maxParallelSlots{}
|
||||
@@ -455,13 +470,61 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
|
||||
continue
|
||||
}
|
||||
|
||||
// Decide whether the job runs at all before expanding a deferred matrix: a job whose needs
|
||||
// failed or were skipped has to be skipped too, not failed for a matrix those needs never
|
||||
// produced the outputs for. A job-level `if:` cannot read `matrix.*`, so it does not need
|
||||
// the combination, unlike the concurrency expression evaluated below.
|
||||
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
if err != nil {
|
||||
// TODO: surface deterministic expression errors to users by failing the job with a message.
|
||||
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
|
||||
continue
|
||||
}
|
||||
if !shouldStartJob {
|
||||
ret[id] = actions_model.StatusSkipped
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand a needs-dependent matrix now that its needs are done and the job is going to run.
|
||||
wasDeferred := actionRunJob.IsMatrixDeferred
|
||||
siblings, err := expandDeferredMatrix(ctx, actionRunJob, r.vars)
|
||||
if err != nil {
|
||||
// Aborting the pass is required: the placeholder is already claimed as the first
|
||||
// combination, so committing here would drop the remaining ones for good.
|
||||
return nil, fmt.Errorf("expand matrix of job %d: %w", id, err)
|
||||
}
|
||||
if actionRunJob.Status != actions_model.StatusBlocked {
|
||||
// expandDeferredMatrix already persisted the failure, so it bypasses `ret`.
|
||||
r.statuses[id] = actionRunJob.Status
|
||||
r.matrixUpdatedJobs = append(r.matrixUpdatedJobs, actionRunJob)
|
||||
r.matrixChanged = true
|
||||
continue
|
||||
}
|
||||
if actionRunJob.IsMatrixDeferred {
|
||||
continue // could not be expanded yet, it stays blocked and is retried on the next pass
|
||||
}
|
||||
r.matrixChanged = r.matrixChanged || len(siblings) > 0
|
||||
if wasDeferred {
|
||||
// The `if:` above was decided against the raw matrix, so this row still has to be gated
|
||||
// by its own combination like the siblings are on the next pass.
|
||||
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
if err != nil {
|
||||
log.Error("evaluateJobIf failed after matrix expansion, job will stay blocked: job: %d, err: %v", id, err)
|
||||
continue
|
||||
}
|
||||
if !shouldStartJob {
|
||||
ret[id] = actions_model.StatusSkipped
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// A slot-starved job cannot start, skip the following checks.
|
||||
if !slots.available(actionRunJob) {
|
||||
continue
|
||||
}
|
||||
|
||||
// update concurrency and check whether the job can run now
|
||||
err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
|
||||
err = updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
|
||||
if err != nil {
|
||||
// The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed
|
||||
// At the moment there is no way to distinguish them.
|
||||
@@ -470,22 +533,11 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
|
||||
continue
|
||||
}
|
||||
|
||||
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
newStatus, cancelledJobs, err := PrepareToStartJobWithConcurrency(ctx, actionRunJob)
|
||||
if err != nil {
|
||||
// TODO: surface deterministic expression errors to users by failing the job with a message.
|
||||
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
|
||||
if newStatus == actions_model.StatusWaiting {
|
||||
var cancelledJobs []*actions_model.ActionRunJob
|
||||
newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
|
||||
if err != nil {
|
||||
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
|
||||
} else {
|
||||
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
|
||||
}
|
||||
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
|
||||
} else {
|
||||
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
|
||||
}
|
||||
|
||||
if newStatus == actions_model.StatusWaiting && !slots.take(actionRunJob) {
|
||||
@@ -496,7 +548,7 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
|
||||
ret[id] = newStatus
|
||||
}
|
||||
}
|
||||
return ret
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJob *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func minimalWorkflowPayload(jobID string) []byte {
|
||||
@@ -257,7 +258,9 @@ jobs:
|
||||
}
|
||||
|
||||
r := newJobStatusResolver(tt.jobs, nil)
|
||||
assert.Equal(t, want, r.Resolve(ctx))
|
||||
got, err := r.Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -277,7 +280,9 @@ func Test_maxParallelConverges(t *testing.T) {
|
||||
}
|
||||
|
||||
for cycle := range totalJobs + 1 {
|
||||
for id, status := range newJobStatusResolver(jobs, nil).Resolve(ctx) {
|
||||
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
for id, status := range updates {
|
||||
jobs[id-1].Status = status
|
||||
}
|
||||
counts := statusCounts(jobs)
|
||||
@@ -562,7 +567,8 @@ func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
for cycle := range 2 * len(callers) {
|
||||
promoted := newJobStatusResolver(callers, nil).Resolve(ctx)
|
||||
promoted, err := newJobStatusResolver(callers, nil).Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
for id, status := range promoted {
|
||||
caller := idToCaller[id]
|
||||
assert.False(t, caller.IsExpanded, "cycle %d: resolver re-promoted already-expanded caller %d", cycle, id)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testRunIndex hands out the per-repo run index, which is unique per action_run row.
|
||||
var testRunIndex int64 = 9100
|
||||
|
||||
// setupDeferredMatrixJob plants a completed `generate` job exposing outputs and the blocked `build`
|
||||
// placeholder that depends on them, and returns the placeholder. Both are children of a reusable
|
||||
// workflow caller, the case where a sibling losing ParentJobID would break needs resolution.
|
||||
// jobIf is the `build` job's `if:` expression, omitted entirely when empty.
|
||||
func setupDeferredMatrixJob(t *testing.T, matrixValue, jobIf string, outputs map[string]string) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
ifLine := ""
|
||||
if jobIf != "" {
|
||||
ifLine = " if: " + jobIf + "\n"
|
||||
}
|
||||
// The `build` job takes its matrix from `generate`'s outputs, so Parse defers it.
|
||||
workflows, err := jobparser.Parse(fmt.Appendf(nil, `
|
||||
on: push
|
||||
jobs:
|
||||
generate:
|
||||
steps: [{run: echo}]
|
||||
build:
|
||||
needs: generate
|
||||
%s strategy:
|
||||
matrix:
|
||||
value: %s
|
||||
steps: [{run: echo}]
|
||||
`, ifLine, matrixValue))
|
||||
require.NoError(t, err)
|
||||
var placeholder *jobparser.SingleWorkflow
|
||||
for _, workflow := range workflows {
|
||||
if id, _ := workflow.Job(); id == "build" {
|
||||
placeholder = workflow
|
||||
}
|
||||
}
|
||||
require.NotNil(t, placeholder, "the build job must be planned as a single deferred placeholder")
|
||||
id, job := placeholder.Job()
|
||||
require.NoError(t, placeholder.SetJob(id, job.EraseNeeds()))
|
||||
payload, err := placeholder.Marshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
testRunIndex++
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: 4, OwnerID: 1, TriggerUserID: 1, Index: testRunIndex,
|
||||
WorkflowID: "dynamic.yml", Ref: "refs/heads/main", Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
attempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
|
||||
// Needs outputs are read straight from action_task_output, so no action_task row is required;
|
||||
// the run's id doubles as a task id that is unique across subtests.
|
||||
taskID := run.ID
|
||||
for key, value := range outputs {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{TaskID: taskID, OutputKey: key, OutputValue: value}))
|
||||
}
|
||||
|
||||
newJob := func(jobID string) *actions_model.ActionRunJob {
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
require.NoError(t, err)
|
||||
return &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: attemptJobID, RepoID: 4, OwnerID: 1,
|
||||
JobID: jobID, Name: jobID, Status: actions_model.StatusRunning,
|
||||
}
|
||||
}
|
||||
caller := newJob("call")
|
||||
caller.IsReusableCaller, caller.IsExpanded = true, true
|
||||
require.NoError(t, db.Insert(ctx, caller))
|
||||
|
||||
generate := newJob("generate")
|
||||
generate.ParentJobID, generate.Status, generate.TaskID = caller.ID, actions_model.StatusSuccess, taskID
|
||||
require.NoError(t, db.Insert(ctx, generate))
|
||||
|
||||
build := newJob("build")
|
||||
build.ParentJobID, build.Status = caller.ID, actions_model.StatusBlocked
|
||||
build.Needs, build.WorkflowPayload, build.IsMatrixDeferred = []string{"generate"}, payload, true
|
||||
build.DeferredMatrixPayload = payload
|
||||
// Values a sibling must inherit rather than silently reset.
|
||||
build.WorkflowSourceRepoID, build.WorkflowSourceCommitSHA = 42, "abc123"
|
||||
require.NoError(t, db.Insert(ctx, build))
|
||||
return build
|
||||
}
|
||||
|
||||
func TestExpandDeferredMatrix(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("expands into siblings", func(t *testing.T) {
|
||||
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", "", map[string]string{"values": `["a","b","c"]`})
|
||||
|
||||
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, siblings, 2)
|
||||
|
||||
// The placeholder is reused as the first combination and stays blocked for the `if:` gate.
|
||||
assert.Equal(t, "build (a)", job.Name)
|
||||
assert.False(t, job.IsMatrixDeferred)
|
||||
assert.Equal(t, actions_model.StatusBlocked, job.Status)
|
||||
|
||||
names := []string{job.Name}
|
||||
for _, sibling := range siblings {
|
||||
names = append(names, sibling.Name)
|
||||
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
|
||||
assert.False(t, sibling.IsMatrixDeferred, "a sibling must not be expanded again")
|
||||
assert.Equal(t, []string{"generate"}, sibling.Needs)
|
||||
assert.Equal(t, job.ParentJobID, sibling.ParentJobID, "needs resolution is scoped by ParentJobID")
|
||||
assert.Equal(t, int64(42), sibling.WorkflowSourceRepoID)
|
||||
assert.Equal(t, "abc123", sibling.WorkflowSourceCommitSHA)
|
||||
assert.Greater(t, sibling.AttemptJobID, job.AttemptJobID, "siblings take fresh ids from the run-wide counter")
|
||||
assert.Empty(t, sibling.DeferredMatrixPayload, "only the placeholder anchors the group with the raw payload")
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"build (a)", "build (b)", "build (c)"}, names)
|
||||
|
||||
reloaded := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, "build (a)", reloaded.Name)
|
||||
assert.False(t, reloaded.IsMatrixDeferred)
|
||||
assert.NotEmpty(t, reloaded.DeferredMatrixPayload, "the claim must not erase the raw payload")
|
||||
})
|
||||
|
||||
// A matrix that can never produce runnable combinations fails the job instead of rolling the
|
||||
// emitter pass back, so it is not retried forever.
|
||||
for _, tt := range []struct {
|
||||
name, matrixValue string
|
||||
outputs map[string]string
|
||||
}{
|
||||
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
|
||||
{
|
||||
name: "expansion exceeding the per-attempt job limit",
|
||||
matrixValue: "${{ fromJson(needs.generate.outputs.many) }}",
|
||||
// One combination more than the attempt has room for: the cap is MaxJobNumPerRun less
|
||||
// the rows the setup plants, plus the placeholder the first combination reuses.
|
||||
outputs: map[string]string{"many": "[" + strings.Repeat("0,", actions_model.MaxJobNumPerRun-2) + "0]"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name+" fails the job", func(t *testing.T) {
|
||||
outputs := tt.outputs
|
||||
if outputs == nil {
|
||||
outputs = map[string]string{"values": `["a","b","c"]`}
|
||||
}
|
||||
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
|
||||
|
||||
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, siblings)
|
||||
assert.Equal(t, actions_model.StatusFailure, job.Status)
|
||||
assert.True(t, job.IsMatrixDeferred, "the flag survives, marking the payload as still unexpanded for reruns")
|
||||
assert.NotZero(t, job.Stopped)
|
||||
// A rejected expansion must not leave the first combination's name behind.
|
||||
assert.Equal(t, "build", unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeferredMatrixResolverGating covers the resolver deciding a placeholder's fate around the
|
||||
// expansion. A need that did not succeed leaves no outputs to build the matrix from, so the job is
|
||||
// skipped like any other job with such a need rather than failed over a matrix it never had to
|
||||
// evaluate, and no combination is inserted. A job that does run is then gated by its own
|
||||
// combination: the placeholder reused as the first one is judged by `matrix.*`, not by the raw
|
||||
// expression the `if:` would have seen before expansion.
|
||||
func TestDeferredMatrixResolverGating(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
needStatus actions_model.Status
|
||||
jobIf string
|
||||
outputs map[string]string
|
||||
wantBuilds []string
|
||||
}{
|
||||
{name: "failed need", needStatus: actions_model.StatusFailure, wantBuilds: []string{"build"}},
|
||||
{name: "skipped need", needStatus: actions_model.StatusSkipped, wantBuilds: []string{"build"}},
|
||||
{
|
||||
name: "`if:` gated per combination", needStatus: actions_model.StatusSuccess,
|
||||
jobIf: "${{ matrix.value != 'a' }}", outputs: map[string]string{"values": `["a","b"]`},
|
||||
wantBuilds: []string{"build (a)", "build (b)"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", tt.jobIf, tt.outputs)
|
||||
_, err := db.Exec(ctx, "UPDATE `action_run_job` SET status = ? WHERE run_id = ? AND job_id = ?", int(tt.needStatus), job.RunID, "generate")
|
||||
require.NoError(t, err)
|
||||
|
||||
jobs := runJobs(t, job.RunID, job.RunAttemptID)
|
||||
require.NoError(t, jobs.LoadRuns(ctx, false))
|
||||
|
||||
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, actions_model.StatusSkipped, updates[job.ID])
|
||||
|
||||
var names []string
|
||||
for _, runJob := range runJobs(t, job.RunID, job.RunAttemptID) {
|
||||
if runJob.JobID == "build" {
|
||||
names = append(names, runJob.Name)
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, tt.wantBuilds, names)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,9 @@ func Test_jobStatusResolver_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
|
||||
for _, job := range jobs {
|
||||
job.Run = run
|
||||
}
|
||||
assert.Empty(t, newJobStatusResolver(jobs, nil).Resolve(t.Context()), "the starved job must stay blocked")
|
||||
updates, err := newJobStatusResolver(jobs, nil).Resolve(t.Context())
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, updates, "the starved job must stay blocked")
|
||||
|
||||
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
|
||||
assert.Equal(t, actions_model.StatusRunning, holder.Status)
|
||||
|
||||
@@ -136,6 +136,12 @@ type rerunPlan struct {
|
||||
// skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs.
|
||||
// These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh.
|
||||
skipCloneTemplateJobIDs container.Set[int64]
|
||||
|
||||
// matrixPlaceholderTemplateIDs holds, per dynamic-matrix job whose matrix must be re-derived in the new attempt,
|
||||
// the template DB row ID to clone as a restored unexpanded placeholder.
|
||||
// The group's remaining combination rows are in matrixSiblingSkipTemplateIDs and are not cloned: they'll be re-expanded.
|
||||
matrixPlaceholderTemplateIDs container.Set[int64]
|
||||
matrixSiblingSkipTemplateIDs container.Set[int64]
|
||||
}
|
||||
|
||||
// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database.
|
||||
@@ -174,6 +180,7 @@ func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUs
|
||||
return nil, err
|
||||
}
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
plan.collectMatrixCollapse()
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
@@ -247,9 +254,19 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
if plan.skipCloneTemplateJobIDs.Contains(templateJob.ID) {
|
||||
continue
|
||||
}
|
||||
// siblings of a collapsed dynamic-matrix job are not cloned either: re-expansion re-inserts them
|
||||
if plan.matrixSiblingSkipTemplateIDs.Contains(templateJob.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
newJob := cloneRunJobForAttempt(templateJob, newAttempt)
|
||||
|
||||
if plan.matrixPlaceholderTemplateIDs.Contains(templateJob.ID) {
|
||||
if err := restoreDeferredMatrixPlaceholder(newJob); err != nil {
|
||||
return fmt.Errorf("restore matrix placeholder from job %d: %w", templateJob.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remap ParentJobID from template attempts's DB ID -> new attempt's DB ID.
|
||||
if templateJob.ParentJobID != 0 {
|
||||
newParentID, ok := templateIDToNewID[templateJob.ParentJobID]
|
||||
@@ -261,7 +278,9 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
}
|
||||
|
||||
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
|
||||
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob)
|
||||
// A deferred-matrix placeholder must go through the emitter, which is the only place
|
||||
// that expands it: dispatching it directly would hand the runner the raw payload.
|
||||
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) || newJob.IsMatrixDeferred
|
||||
|
||||
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
|
||||
newJob.TaskID = 0
|
||||
@@ -360,8 +379,9 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun)
|
||||
|
||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
||||
if hasWaitingCallerJobs {
|
||||
// Post-commit kick for expanded callers and restored matrix placeholders: let job_emitter
|
||||
// resolve child jobs, and re-expand a placeholder whose needs may all be pass-through and done.
|
||||
if hasWaitingCallerJobs || len(plan.matrixPlaceholderTemplateIDs) > 0 {
|
||||
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
|
||||
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
|
||||
}
|
||||
@@ -519,6 +539,8 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
|
||||
Needs: slices.Clone(templateJob.Needs),
|
||||
RunsOn: slices.Clone(templateJob.RunsOn),
|
||||
ContinueOnError: templateJob.ContinueOnError,
|
||||
IsMatrixDeferred: templateJob.IsMatrixDeferred,
|
||||
DeferredMatrixPayload: slices.Clone(templateJob.DeferredMatrixPayload),
|
||||
Status: templateJob.Status,
|
||||
RawConcurrency: templateJob.RawConcurrency,
|
||||
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
|
||||
@@ -600,3 +622,52 @@ func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.A
|
||||
return actions_model.UpdateRun(ctx, run, "latest_attempt_id")
|
||||
})
|
||||
}
|
||||
|
||||
// collectMatrixCollapse decides, per dynamic-matrix job in the rerun set, whether the new
|
||||
// attempt must re-derive the matrix instead of reusing the previous attempt's combinations,
|
||||
// and fills matrixPlaceholderTemplateIDs / matrixSiblingSkipTemplateIDs accordingly.
|
||||
func (p *rerunPlan) collectMatrixCollapse() {
|
||||
p.matrixPlaceholderTemplateIDs = make(container.Set[int64])
|
||||
p.matrixSiblingSkipTemplateIDs = make(container.Set[int64])
|
||||
|
||||
// Group every template row by the key the rows of one matrix job share.
|
||||
type groupKey struct {
|
||||
parentJobID int64
|
||||
jobID string
|
||||
}
|
||||
groups := make(map[groupKey][]*actions_model.ActionRunJob)
|
||||
for _, tj := range p.templateJobs {
|
||||
key := groupKey{tj.ParentJobID, tj.JobID}
|
||||
groups[key] = append(groups[key], tj)
|
||||
}
|
||||
|
||||
for _, rows := range groups {
|
||||
anchorIdx := slices.IndexFunc(rows, func(j *actions_model.ActionRunJob) bool {
|
||||
return len(j.DeferredMatrixPayload) > 0
|
||||
})
|
||||
if anchorIdx < 0 {
|
||||
continue // not a dynamic-matrix job: a plain job, or a matrix expanded at plan time
|
||||
}
|
||||
anchor := rows[anchorIdx]
|
||||
// Descendants of a reset caller are not cloned at all; the caller's expansion re-inserts the job.
|
||||
if p.skipCloneTemplateJobIDs.Contains(anchor.ID) {
|
||||
continue
|
||||
}
|
||||
inRerunSet := slices.ContainsFunc(rows, func(j *actions_model.ActionRunJob) bool {
|
||||
return p.rerunAttemptJobIDs.Contains(j.AttemptJobID)
|
||||
})
|
||||
if !inRerunSet {
|
||||
continue // pass-through group, cloned as-is
|
||||
}
|
||||
unexpanded := len(rows) == 1 && anchor.IsMatrixDeferred
|
||||
if !unexpanded && !p.hasRerunDependency(anchor) {
|
||||
continue // needs keep their outputs, reuse the combinations
|
||||
}
|
||||
p.matrixPlaceholderTemplateIDs.Add(anchor.ID)
|
||||
for _, row := range rows {
|
||||
if row.ID != anchor.ID {
|
||||
p.matrixSiblingSkipTemplateIDs.Add(row.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
continue
|
||||
}
|
||||
needs := parsedChild.Needs()
|
||||
isMatrixDeferred := jobparser.HasDeferredMatrix(parsedChild)
|
||||
if err := sw.SetJob(jobID, parsedChild.EraseNeeds()); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -328,11 +329,10 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
|
||||
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
|
||||
|
||||
// AttemptJobID: prefer a prior-attempt match by (JobID, Name) and fall back to a fresh allocator value for newly-appearing logical jobs.
|
||||
// The two-level key disambiguates matrix instances (same JobID, different Names) and distinct jobs that legally share the same Name (different JobIDs).
|
||||
// AttemptJobID: prefer a prior-attempt match and fall back to a fresh allocator value for newly-appearing logical jobs.
|
||||
var attemptJobID int64
|
||||
if priorChild, ok := priorChildren[jobID][parsedChild.Name]; ok {
|
||||
attemptJobID = priorChild.AttemptJobID
|
||||
if priorID, ok := priorAttemptJobID(priorChildren[jobID], parsedChild.Name, isMatrixDeferred); ok {
|
||||
attemptJobID = priorID
|
||||
} else {
|
||||
attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
if err != nil {
|
||||
@@ -359,6 +359,11 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
ParentJobID: caller.ID,
|
||||
WorkflowSourceRepoID: sourceRepoID,
|
||||
WorkflowSourceCommitSHA: sourceCommitSHA,
|
||||
IsMatrixDeferred: isMatrixDeferred,
|
||||
}
|
||||
if isMatrixDeferred {
|
||||
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
|
||||
child.DeferredMatrixPayload = payload
|
||||
}
|
||||
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
|
||||
child.TokenPermissions = perms
|
||||
@@ -411,3 +416,21 @@ func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// priorAttemptJobID returns the AttemptJobID a re-inserted child should reuse,
|
||||
// given the prior attempt's rows of the same JobID indexed by Name.
|
||||
func priorAttemptJobID(priorSameJobID map[string]*actions_model.ActionRunJob, name string, isMatrixDeferred bool) (int64, bool) {
|
||||
if isMatrixDeferred {
|
||||
for _, prior := range priorSameJobID {
|
||||
if len(prior.DeferredMatrixPayload) > 0 {
|
||||
return prior.AttemptJobID, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
prior, ok := priorSameJobID[name]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return prior.AttemptJobID, true
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
|
||||
id, job := workflowJob.Job()
|
||||
needs := job.Needs()
|
||||
isMatrixDeferred := jobparser.HasDeferredMatrix(job)
|
||||
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
@@ -218,8 +219,13 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
WorkflowSourceRepoID: run.WorkflowRepoID,
|
||||
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
|
||||
ContinueOnError: job.GetContinueOnError(),
|
||||
IsMatrixDeferred: isMatrixDeferred,
|
||||
MaxParallel: parseMaxParallel(id, job.Strategy.MaxParallelString),
|
||||
}
|
||||
if isMatrixDeferred {
|
||||
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
|
||||
runJob.DeferredMatrixPayload = payload
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
|
||||
Reference in New Issue
Block a user