From 81d63c63b42222232c0b5ba6d63b4c9f00021c0e Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 29 May 2026 10:09:10 +0200 Subject: [PATCH] fix(actions): cap dynamic matrix expansion at MaxJobNumPerRun A matrix built from a dependent job's runtime output (fromJson(needs.*.outputs.*)) has no inherent size bound, so a large or buggy upstream output could insert an arbitrary number of jobs in one transaction. The per-attempt AttemptJobIDs also back the job-index URLs, which must stay below MaxJobNumPerRun. Skip the expansion with a clear reason when the resulting jobs would exceed that limit. Also rename a test variable that tripped the misspell linter and clarify the deferred-placeholder commit-status comment. Co-Authored-By: Claude (Opus 4.8) --- modules/actions/jobparser/jobparser_test.go | 6 +++--- services/actions/commit_status.go | 2 +- services/actions/matrix.go | 15 +++++++++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/modules/actions/jobparser/jobparser_test.go b/modules/actions/jobparser/jobparser_test.go index d638a50a85..97b62fba92 100644 --- a/modules/actions/jobparser/jobparser_test.go +++ b/modules/actions/jobparser/jobparser_test.go @@ -573,9 +573,9 @@ jobs: func TestExpandMatrixWithNeeds(t *testing.T) { buildJob := func(t *testing.T, matrixYAML, runsOn string, needs []string) *Job { t.Helper() - var strat Strategy - require.NoError(t, yaml.Unmarshal([]byte(matrixYAML), &strat)) - job := &Job{Name: "build", Strategy: strat} + var strategy Strategy + require.NoError(t, yaml.Unmarshal([]byte(matrixYAML), &strategy)) + job := &Job{Name: "build", Strategy: strategy} require.NoError(t, job.RawRunsOn.Encode(runsOn)) require.NoError(t, job.RawNeeds.Encode(needs)) return job diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 91449a1340..cc91ea69f5 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -47,7 +47,7 @@ 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 now - // would be orphaned; the expanded combos get theirs at expansion time. + // would be orphaned; the job emitter creates the combos' statuses after expansion. if job.RawStrategy != "" && !job.IsMatrixEvaluated { continue } diff --git a/services/actions/matrix.go b/services/actions/matrix.go index 40eea01b87..767b57a26d 100644 --- a/services/actions/matrix.go +++ b/services/actions/matrix.go @@ -152,14 +152,21 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act return nil, markMatrixAsEvaluatedAndSkip(ctx, job, "matrix expanded to no combinations") } + // Cap expansion at MaxJobNumPerRun: a runtime fromJson() value must not create unbounded jobs, + // and the AttemptJobIDs (used in job URLs) must stay below the limit. Siblings take the IDs + // above the current max, so that highest value is what must stay in range. + maxAttemptJobID, err := actions_model.GetMaxAttemptJobID(ctx, job.RunID, job.RunAttemptID) + if err != nil { + return nil, fmt.Errorf("get max attempt job id for job %d: %w", job.ID, err) + } + if maxAttemptJobID+int64(len(combos))-1 >= actions_model.MaxJobNumPerRun { + return nil, markMatrixAsEvaluatedAndSkip(ctx, job, fmt.Sprintf("matrix expansion to %d combinations would exceed the per-run job limit of %d", len(combos), actions_model.MaxJobNumPerRun)) + } + // Reuse the placeholder as the first combination and insert the rest as siblings: no phantom // skipped job is left to poison downstream needs, and siblings inherit attempt + permissions. var children []*actions_model.ActionRunJob if err := db.WithTx(ctx, func(txCtx context.Context) error { - maxAttemptJobID, err := actions_model.GetMaxAttemptJobID(txCtx, job.RunID, job.RunAttemptID) - if err != nil { - return err - } for i := 1; i < len(combos); i++ { children = append(children, &actions_model.ActionRunJob{ RunID: job.RunID,