fix(actions): evaluate job-level if: before concurrency check (#39437)

Gitea doesn't evaluate a job's `if:` before checking the job's
concurrency group, which causes a job that should have been skipped to
incorrectly cancel other jobs in the same concurrency group.

This PR makes Gitea decide `if:` for every job before it becomes
waiting, including jobs without `needs` at insertion, on approval and on
rerun. A skipped job therefore no longer takes part in job concurrency
or holds a max-parallel slot, and a reusable caller whose `if:` is false
is no longer expanded on approval or rerun. An invalid `if:` skips the
job with an error summary.

After this PR, Gitea decides all jobs' `if:` expressions and sends `if:
always()` to the runner, so the runner no longer needs to evaluate a
job's `if:` again ([gitea/runner
`run_context.go`](https://gitea.com/gitea/runner/src/commit/81add274599355ec1838b6ebe45804890d40bab9/act/runner/run_context.go#L1195)).

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Zettat123
2026-09-26 01:01:21 -06:00
committed by GitHub
parent f0f53a76ee
commit a15f032026
12 changed files with 263 additions and 83 deletions
+24 -37
View File
@@ -178,11 +178,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
return nil
}
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
// caller's dependents.
// insertRunJob returns the inserted job, the jobs its concurrency cancelled, and whether a post-commit emitter pass is needed.
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
id, job := workflowJob.Job()
needs := job.Needs()
@@ -236,6 +232,12 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
runJob.CallUses = job.Uses
}
// a skipped job must neither cancel its group peers nor take a slot
invalidIf, err := decideJobIf(ctx, run, runAttempt, runJob, vars)
if err != nil {
return nil, nil, false, fmt.Errorf("evaluate job if: %w", err)
}
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
// check job concurrency
if job.RawConcurrency != nil {
@@ -245,8 +247,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
}
runJob.RawConcurrency = string(rawConcurrency)
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
if len(needs) == 0 {
// the job emitter evaluates it for jobs with `needs`, a skipped job never takes part
if len(needs) == 0 && runJob.Status != actions_model.StatusSkipped {
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
return nil, nil, false, fmt.Errorf("evaluate job concurrency: %w", err)
}
@@ -270,43 +272,28 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
if err := db.Insert(ctx, runJob); err != nil {
return nil, nil, false, err
}
// expand reusable caller
var needPostCommitEmit bool
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
if invalidIf != nil {
if err := upsertJobErrorSummary(ctx, runJob, "if", invalidIf); err != nil {
return nil, nil, false, err
}
// A processed caller always needs a resolver pass:
// - if the caller is expanded, resolve its children jobs;
// - if the caller is skipped, propagate its state to its dependents
needPostCommitEmit = true
}
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
if err := expandInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
return nil, nil, false, err
}
}
// the emitter resolves an expanded caller's children and a skipped job's dependents
return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status == actions_model.StatusSkipped, nil
}
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
// either inline-expands it into child jobs or marks it skipped.
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
if err != nil {
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
func expandInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
}
if shouldStart {
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
}
// refresh the caller status
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
}
return nil
}
caller.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
}
return nil
}