Files
Gitea/services/actions/max_parallel_test.go
T
Pascal Zimmermann 5672b1c4cf 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>
2026-07-28 15:59:00 +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
{"abc", 0},
}
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
}