Files
gitea/services/actions/approve.go
T
Zettat123 a15f032026 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>
2026-09-26 09:01:21 +02:00

166 lines
5.0 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
// ApproveRuns returns the approved runs in the same order as runIDs.
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) ([]*actions_model.ActionRun, error) {
updatedJobs := make([]*actions_model.ActionRunJob, 0)
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
runIDsToEmit := make(container.Set[int64])
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
for _, runID := range runIDs {
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
if err != nil {
return err
}
if !run.NeedApproval {
continue
}
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
return err
}
jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
if err != nil {
return err
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
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 skipped job must neither cancel its group peers nor take a slot
if isUnblocking {
shouldStart, err := evaluateJobIf(ctx, run, nil, job, vars, true)
if err != nil {
return fmt.Errorf("evaluate job %d if on approval: %w", job.ID, err)
}
if !shouldStart {
job.Status = actions_model.StatusSkipped
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
if err != nil {
return err
}
if n > 0 {
updatedJobs = append(updatedJobs, job)
runIDsToEmit.Add(run.ID)
}
continue
}
}
// 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
}
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
if err != nil {
return err
}
if n == 0 {
continue
}
updatedJobs = append(updatedJobs, job)
// A top-level reusable caller was just unblocked by approval, expand it
if job.IsReusableCaller && !job.IsExpanded {
attempt, has, err := run.GetLatestAttempt(ctx)
if err != nil {
return fmt.Errorf("get latest attempt of run %d: %w", run.ID, err)
}
if !has {
return errors.New("run has no attempt")
}
if err := expandInlineReusableCaller(ctx, run, attempt, job, vars); err != nil {
return err
}
runIDsToEmit.Add(run.ID)
}
}
}
return nil
})
if err != nil {
return nil, err
}
// Re-emit AFTER the tx commits so callee rows and dependents of skipped jobs get resolved.
for runID := range runIDsToEmit {
if err := EmitJobsIfReadyByRun(runID); err != nil {
log.Error("emit run %d after approval: %v", runID, err)
}
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
// The batches above already notified every run whose jobs changed, which is the only way
// approving alters a run's status, so reload purely to answer the caller.
reloaded, err := actions_model.GetRunsByRepoAndID(ctx, repo.ID, runIDs)
if err != nil {
return nil, fmt.Errorf("GetRunsByRepoAndID: %w", err)
}
runsByID := make(map[int64]*actions_model.ActionRun, len(reloaded))
for _, run := range reloaded {
run.Repo = repo // the caller resolved runIDs against this repo, so spare every consumer a reload
runsByID[run.ID] = run
}
approvedRuns := make([]*actions_model.ActionRun, 0, len(runIDs))
for _, runID := range runIDs {
run := runsByID[runID]
if run == nil {
return nil, util.NewNotExistErrorf("run %d no longer exists after approval", runID)
}
approvedRuns = append(approvedRuns, run)
}
return approvedRuns, nil
}