feat: Add max-parallel Support for Gitea Actions (#36357)

Add support for `strategy.max-parallel` on Gitea Actions matrix jobs.

**How it works**
Jobs over the limit are inserted as `Blocked` instead of `Waiting`, so
runners never see them. When a job finishes, the job-status resolver
promotes one `Blocked` job per freed slot, in job order. Slots are
counted per `JobID` and scoped by reusable-workflow caller. A
`Cancelling` job still owns its runner, so it keeps its slot.

The cap is applied wherever a job can become `Waiting`: initial insert,
rerun, approval, and resolver promotion.

Best effort, not a hard invariant: two concurrent emitter passes can
each promote into the last slot, overshooting by one. It does not
compound, since every later pass recounts.

**Parsing**
Any YAML number, cast to an int as GitHub does (`1.5` → 1). `0` or
negative means unlimited. Expressions (`${{ ... }}`) are not evaluated
yet and fall back to unlimited.

**Migration**
Adds the `max_parallel` column on `action_run_job`. No index or
constraint changes.

**Compatibility**
Existing rows default to `0`, so behaviour is unchanged. No runner
changes needed: the runner protocol is untouched, and since the server
splits the matrix each runner still receives a single job.

Closes https://github.com/go-gitea/gitea/issues/35561
Signed-off-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Signed-off-by: ZPascal <pascal.zimmermann@theiotstudio.com>
Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
Pascal Zimmermann
2026-07-27 16:18:43 +00:00
committed by GitHub
parent e67dd4c818
commit 10c678a0a0
14 changed files with 637 additions and 17 deletions
+16
View File
@@ -41,18 +41,34 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
return err
}
// approval unblocks every job at once, so max-parallel has to cap them here too
slots := maxParallelSlots{}
for _, job := range jobs {
slots.hold(job, job.Status)
}
for _, job := range jobs {
// Skip jobs with `needs`: they stay blocked until their dependencies finish,
// at which point job_emitter will evaluate and start them.
if len(job.Needs) > 0 {
continue
}
// Only a job this approval unblocks competes for a slot, one that is already
// active was counted by the seeding loop above and must not take a second.
isUnblocking := job.Status == actions_model.StatusBlocked
// A slot-starved job cannot start, skip the following checks.
if isUnblocking && !slots.available(job) {
continue
}
var jobsToCancel []*actions_model.ActionRunJob
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
if isUnblocking {
applyMaxParallel(job, slots)
}
if job.Status != actions_model.StatusWaiting {
continue
}