Files
Gitea/services/actions/max_parallel_test.go
T
bircni 11d0ed699b fix(actions): dynamic matrix expansion correctness fixes (#38690)
Follow-up to https://github.com/go-gitea/gitea/pull/36564 (dynamic
matrix) and https://github.com/go-gitea/gitea/pull/36357 (max-parallel),
fixing issues found reviewing the two features together.

- **A placeholder could stall its run forever.** Its payload keeps the
raw matrix but loses its `needs`, so `ParseJob` re-expanded it instead
of reading it back — fatal for `include: ${{ fromJson(needs.*.outputs.*)
}}`.
- **An `if:` reading `matrix.*` skipped the whole job**, with or without
the `${{ }}`. It now reduces to the needs gate, except under
`always()`/`failure()`/`cancelled()`, and each combination is decided on
its own values once the matrix expands.
- **Dependents could be skipped before the combinations ran**, since
inserted siblings are absent from the resolver's job set. The pass now
stops after an insert and defers to the re-emit it schedules.
- **Expansion failures stranded the placeholder.** A retryable one is
returned so the queue retries it; a malformed payload fails the job
instead of requeueing forever.
- **Rerun could rewind a pass-through row** into a raw placeholder
keeping its old terminal status, which nothing expands. Now gated on the
anchor itself.

Plus: `max-parallel` distinguishes an unevaluated `${{ }}` (debug) from
a non-numeric literal (warn — it silently drops the cap).

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-30 09:13:47 +00:00

254 lines
8.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseMaxParallel(t *testing.T) {
tests := []struct {
input string
want int
}{
{"", 0},
{"3", 3},
{"0", 0},
{"-1", 0},
{"1.5", 1}, // GitHub casts the YAML number to int
{"-1.5", 0}, // truncates to -1, which means unlimited
{"1e3", 256}, // clamped to MaxJobNumPerRun
{"nan", 0}, // must not reach the int cast
{"${{ vars.n }}", 0}, // expressions are not evaluated yet, logged as such
{"abc", 0}, // a plain workflow error, warned about rather than hidden
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseMaxParallel("job", tt.input), "input %q", tt.input)
}
}
// a 5-job matrix capped at 2, so 2 jobs start and 3 stay blocked
const maxParallelWorkflow = `name: max-parallel
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 2
matrix:
version: [1, 2, 3, 4, 5]
steps:
- run: echo hi
`
func insertMaxParallelRun(t *testing.T, workflow string, needApproval bool) *actions_model.ActionRun {
t.Helper()
run := &actions_model.ActionRun{
Title: "max-parallel",
RepoID: 4,
OwnerID: 1,
WorkflowID: "max-parallel.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
NeedApproval: needApproval,
WorkflowRepoID: 4,
WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
}
require.NoError(t, PrepareRunAndInsert(t.Context(), []byte(workflow), run, nil))
return run
}
func runJobs(t *testing.T, runID, attemptID int64) actions_model.ActionJobList {
t.Helper()
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), runID, attemptID)
require.NoError(t, err)
return jobs
}
func statusCounts(jobs actions_model.ActionJobList) map[actions_model.Status]int {
counts := map[actions_model.Status]int{}
for _, job := range jobs {
counts[job.Status]++
}
return counts
}
func TestPrepareRunAndInsert_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
run := insertMaxParallelRun(t, maxParallelWorkflow, false)
jobs := runJobs(t, run.ID, run.LatestAttemptID)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 2,
actions_model.StatusBlocked: 3,
}, statusCounts(jobs))
for _, job := range jobs {
assert.Equal(t, 2, job.MaxParallel, "every matrix sibling carries the limit")
}
}
// A reusable workflow declares its own strategy, so the limit must reach the child jobs.
func TestInsertCallerChildren_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, Index: 9701, TriggerUserID: 1, TriggerEvent: "push", EventPayload: "{}",
}
require.NoError(t, db.Insert(ctx, run))
require.NoError(t, run.LoadAttributes(ctx))
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1}
require.NoError(t, db.Insert(ctx, attempt))
caller := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
JobID: "caller", AttemptJobID: 1, IsReusableCaller: true,
}
require.NoError(t, db.Insert(ctx, caller))
called := []byte(`name: called
on: workflow_call
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 2
matrix:
version: [1, 2, 3]
steps:
- run: echo hi
`)
require.NoError(t, insertCallerChildren(ctx, run, attempt, caller, called, run.RepoID, run.CommitSHA, nil, nil))
children := 0
for _, job := range runJobs(t, run.ID, attempt.ID) {
if job.ParentJobID == caller.ID {
children++
assert.Equal(t, 2, job.MaxParallel)
}
}
assert.Equal(t, 3, children)
}
// An approval-gated run inserts every job as Blocked, so the cap has to be applied on approval.
func TestApproveRuns_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
run := insertMaxParallelRun(t, maxParallelWorkflow, true)
assert.Equal(t, map[actions_model.Status]int{actions_model.StatusBlocked: 5}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 2,
actions_model.StatusBlocked: 3,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
}
func TestPrepareRunAndInsert_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9702, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, false)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 1,
actions_model.StatusBlocked: 1,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status, "the starved job must not cancel the group holder")
}
func Test_jobStatusResolver_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9703, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, false)
jobs := runJobs(t, run.ID, run.LatestAttemptID)
for _, job := range jobs {
job.Run = run
}
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)
}
func TestApproveRuns_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9704, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, true)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 1,
actions_model.StatusBlocked: 1,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status)
}
// a 2-job matrix capped at 1 where each matrix job has its own concurrency group that cancels in progress
const maxParallelConcurrencyWorkflow = `name: max-parallel-concurrency
on: push
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
cluster: [a, b]
concurrency:
group: cluster-${{ matrix.cluster }}
cancel-in-progress: true
steps:
- run: echo hi
`
// insertConcurrencyHolder inserts a running job occupying the given concurrency group
func insertConcurrencyHolder(t *testing.T, index int64, group string) *actions_model.ActionRunJob {
t.Helper()
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, Index: index, TriggerUserID: 1, TriggerEvent: "push", EventPayload: "{}",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
holder := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
JobID: "holder", AttemptJobID: 1, Status: actions_model.StatusRunning,
ConcurrencyGroup: group, IsConcurrencyEvaluated: true,
}
require.NoError(t, db.Insert(ctx, holder))
return holder
}