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) <noreply@anthropic.com>
This commit is contained in:
silverwind
2026-05-29 10:09:10 +02:00
co-authored by Claude
parent d35b3e65d6
commit 81d63c63b4
3 changed files with 15 additions and 8 deletions
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
}
+11 -4
View File
@@ -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,