mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-01 20:59:45 +09:00
f44e64be81
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>
173 lines
5.3 KiB
Go
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
|
|
}
|