Files
gitea/services/actions/approve.go
T
bircni f44e64be81 fix(actions): harden fork pull request run approval (#39399)
Fixes several gaps in the approval of fork pull request runs:

1. Approving a run that was cancelled while awaiting approval revived
its cancelled jobs. Such a run is no longer treated as awaiting approval
by the merge box, run page, approve actions and API, and rerunning it
approves it.
2. Approval no longer revives jobs cancelled while the run was pending,
no longer lets two jobs sharing a concurrency group cancel each other,
and re-emits the run so jobs needing a cancelled job get resolved.
3. An unapproved run applies its workflow-level concurrency only once
approved.
4. For workflows from the pull request, both the event actor and the
pull request author must be trusted to skip approval. Workflows from the
default branch, like `issue_comment`, still only check the actor.

---------

Co-authored-by: silverwind <me@silverwind.io>
2026-09-26 22:52:19 +00:00

173 lines
5.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"slices"
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"
"xorm.io/builder"
)
// 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)
approvedRunIDs := 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.IsAwaitingApproval() {
continue
}
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
return err
}
approvedRunIDs.Add(run.ID)
jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
if err != nil {
return err
}
attempt, hasAttempt, err := run.GetLatestAttempt(ctx)
if err != nil {
return fmt.Errorf("get latest attempt of run %d: %w", run.ID, err)
}
if hasAttempt && attempt.ConcurrencyGroup != "" {
status, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, attempt)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
if status == actions_model.StatusBlocked {
continue
}
}
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 {
// Only approval-blocked jobs are released here, job_emitter starts those with `needs`
if job.Status != actions_model.StatusBlocked || len(job.Needs) > 0 {
continue
}
if slices.ContainsFunc(cancelledConcurrencyJobs, func(cancelled *actions_model.ActionRunJob) bool { return cancelled.ID == job.ID }) {
continue // cancelled by a sibling's concurrency in this loop
}
// a skipped job must neither cancel its group peers nor take a slot
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)
}
continue
}
// A slot-starved job cannot start, skip the following checks.
if !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...)
applyMaxParallel(job, slots)
if job.Status != actions_model.StatusWaiting {
continue
}
n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "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 {
if !hasAttempt {
return errors.New("run has no attempt")
}
if err := expandInlineReusableCaller(ctx, run, attempt, job, vars); err != nil {
return err
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
// The emitter skipped these runs while they awaited approval
for runID := range approvedRunIDs {
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
}