diff --git a/models/actions/run_attempt.go b/models/actions/run_attempt.go index 778b153cb4d..f686e448c4b 100644 --- a/models/actions/run_attempt.go +++ b/models/actions/run_attempt.go @@ -62,14 +62,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error) attempt.Run = run } - if attempt.TriggerUser == nil { - attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID) - if err != nil { - return err - } - } + return attempt.LoadTriggerUser(ctx) +} - return nil +// LoadTriggerUser loads the attempt's trigger user if not already loaded. +func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) { + if attempt.TriggerUser != nil { + return nil + } + attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID) + return err } func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) { diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 9ef9865bdfa..5b3db940a45 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string { } var _ db.FindOptionsOrder = FindRunJobOptions{} + +// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt. +// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs. +func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) { + return db.Count[ActionRunJob](ctx, FindRunJobOptions{ + RunID: runID, + RunAttemptID: optional.Some(runAttemptID), + }) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index cab8162a620..b98b05df240 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3806,6 +3806,7 @@ "actions.runs.cancel": "Cancel workflow run", "actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.", "actions.runs.not_done": "This workflow run is not done.", + "actions.runs.no_failed_jobs": "This workflow run has no failed jobs to re-run.", "actions.runs.view_workflow_file": "View workflow file", "actions.runs.summary": "Summary", "actions.runs.all_jobs": "All jobs", diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index b1c500d4d51..232253e12c5 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1503,7 +1503,14 @@ func RerunFailedWorkflowRun(ctx *context.APIContext) { return } - if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { + failedJobs := actions_service.GetFailedJobsForRerun(jobs) + // Empty failedJobs means no failed jobs to re-run + if len(failedJobs) == 0 { + ctx.APIError(http.StatusBadRequest, "this workflow run has no failed jobs to re-run") + return + } + + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil { handleWorkflowRerunError(ctx, err) return } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 7237c08b867..fa6d3649815 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -566,9 +566,6 @@ func ViewPost(ctx *context_module.Context) { } func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) { - // Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest. - isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID) - resp.State.Run.RepoID = ctx.Repo.Repository.ID resp.State.Run.Index = run.Index // the title for the "run" is from the commit message @@ -577,8 +574,12 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, resp.State.Run.Link = run.Link() resp.State.Run.ViewLink = getRunViewLink(run, attempt) resp.State.Run.Attempts = make([]*ViewRunAttempt, 0) + // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all + // share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only. + var runAttemptID int64 var effectiveStatus actions_model.Status if attempt != nil { + runAttemptID = attempt.ID effectiveStatus = attempt.Status resp.State.Run.RunAttempt = attempt.Attempt resp.State.Run.Duration = attempt.Duration().String() @@ -588,6 +589,9 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, resp.State.Run.Duration = run.Duration().String() resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() } + // Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest. + isLatestAttempt := run.LatestAttemptID == 0 || runAttemptID == run.LatestAttemptID + resp.State.Run.Status = effectiveStatus.String() resp.State.Run.Done = effectiveStatus.IsDone() @@ -647,7 +651,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, Status: runAttempt.Status.String(), Done: runAttempt.Status.IsDone(), Link: getRunViewLink(run, runAttempt), - Current: runAttempt.ID == attempt.ID, + Current: runAttempt.ID == runAttemptID, Latest: runAttempt.ID == run.LatestAttemptID, TriggeredAt: runAttempt.Created.AsTime().Unix(), TriggerUserName: runAttempt.TriggerUser.GetDisplayName(), @@ -672,13 +676,6 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, resp.State.Run.PullRequest = refInfo.PullRequest resp.State.Run.TriggerEvent = run.TriggerEvent - // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all - // share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only. - var runAttemptID int64 - if attempt != nil { - runAttemptID = attempt.ID - } - // Each step's markdown is rendered independently so an unclosed construct // in one step can't bleed into the next. // On a single-job view only that job's summaries are needed; the run view shows all. @@ -1006,7 +1003,15 @@ func RerunFailed(ctx *context_module.Context) { return } - if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { + // An empty job list means "re-run the whole run" to RerunWorkflowRunJobs, which is right for the plain + // rerun button but wrong here, so a direct POST on a fully successful run cannot re-run everything. + failedJobs := actions_service.GetFailedJobsForRerun(jobs) + if len(failedJobs) == 0 { + ctx.JSONError(ctx.Locale.Tr("actions.runs.no_failed_jobs")) + return + } + + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, failedJobs); err != nil { handleWorkflowRerunError(ctx, err) return } diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index 6abce568ac2..f3576db2e85 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -26,11 +26,13 @@ func StopZombieTasks(ctx context.Context) error { }, actions_model.StatusRunning, actions_model.StatusCancelling) } -// StopEndlessTasks stops tasks in running/cancelling status with continuous updates that don't end for a long time +// StopEndlessTasks stops running tasks with continuous updates that don't end for a long time. +// StatusRunning only: the threshold is the task's *start* time, so including StatusCancelling would kill a +// task mid post-cancel cleanup. StopZombieTasks covers a stalled one, keying off the last update instead. func StopEndlessTasks(ctx context.Context) error { return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{ StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()), - }, actions_model.StatusRunning, actions_model.StatusCancelling) + }, actions_model.StatusRunning) } func stopTasksByStatuses(ctx context.Context, opts actions_model.FindTaskOptions, statuses ...actions_model.Status) error { diff --git a/services/actions/clear_tasks_test.go b/services/actions/clear_tasks_test.go index 899b73fa446..be7ff26602a 100644 --- a/services/actions/clear_tasks_test.go +++ b/services/actions/clear_tasks_test.go @@ -4,11 +4,16 @@ package actions import ( + "fmt" "testing" + "time" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" "gitea.dev/models/unittest" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + "gitea.dev/modules/timeutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,3 +93,51 @@ func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) { require.NoError(t, err) assert.True(t, shouldBlock) } + +// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not +// force-stopped by the endless-task sweep just because the job started long ago. +func TestStopEndlessTasksSkipsCancelling(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // StopEndlessTasks emits ready jobs onto the emitter queue, mock it + defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { return nil })() + + // well past the endless-task threshold, keyed on the task's start time + longAgo := timeutil.TimeStamp(time.Now().Add(-2 * setting.Actions.EndlessTaskTimeout).Unix()) + + var seq int64 + newTaskWithJob := func(status actions_model.Status) *actions_model.ActionTask { + seq++ + run := &actions_model.ActionRun{ + RepoID: 1, OwnerID: 2, TriggerUserID: 2, WorkflowID: "test.yml", + Index: 99500 + seq, Ref: "refs/heads/main", Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), run)) + attempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), attempt)) + job := &actions_model.ActionRunJob{ + RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: run.RepoID, OwnerID: run.OwnerID, + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Name: "j", JobID: "j", Status: status, + } + require.NoError(t, db.Insert(t.Context(), job)) + task := &actions_model.ActionTask{ + JobID: job.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, + CommitSHA: job.CommitSHA, Status: status, Started: longAgo, + TokenHash: fmt.Sprintf("endless-test-token-%d", seq), TokenSalt: "salt", + } + require.NoError(t, db.Insert(t.Context(), task)) + return task + } + + running := newTaskWithJob(actions_model.StatusRunning) + cancelling := newTaskWithJob(actions_model.StatusCancelling) + + require.NoError(t, StopEndlessTasks(t.Context())) + + runningAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: running.ID}) + cancellingAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: cancelling.ID}) + assert.Equal(t, actions_model.StatusFailure, runningAfter.Status, "long-running task should be force-stopped") + assert.Equal(t, actions_model.StatusCancelling, cancellingAfter.Status, "cancelling task should keep running its cleanup") +} diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index dc999f323d6..39c7ff77aaf 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -265,7 +265,10 @@ func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository return fmt.Errorf("GetLatestCommitStatus: %w", err) } for _, v := range statuses { - if v.ContextHash == legacyHash && v.Context == ctxName { + // Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the + // #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API + // would collapse two same-named workflows back into a single check. + if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID { ctxHash = legacyHash break } diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go index f54536f999b..753229f8e33 100644 --- a/services/actions/commit_status_test.go +++ b/services/actions/commit_status_test.go @@ -217,7 +217,8 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) { legacyHash := git_model.HashCommitStatusContext(ctxName) sha, err := git.NewIDFromString(branch.CommitID) require.NoError(t, err) - creator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + // Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash. + creator := user_model.NewActionsUser() require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{ Repo: repo, Creator: creator, @@ -258,6 +259,65 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) { assert.Equal(t, 1, matches) } +// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a +// workflow's Context must not pull the workflow into the legacy Context-only hash group. +func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch}) + + workflowID := "external.yaml" + ctxName := "external.yaml / my-job (push)" + legacyHash := git_model.HashCommitStatusContext(ctxName) + distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID) + sha, err := git.NewIDFromString(branch.CommitID) + require.NoError(t, err) + + // An external status (posted by a real user, not the Actions user) sharing the same Context. + externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{ + Repo: repo, + Creator: externalCreator, + SHA: sha, + CommitStatus: &git_model.CommitStatus{ + State: commitstatus.CommitStatusSuccess, + Context: ctxName, + ContextHash: legacyHash, + TargetURL: "https://example.invalid/external", + Description: "external check", + }, + })) + + run := &actions_model.ActionRun{ + ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID, + WorkflowID: workflowID, CommitSHA: branch.CommitID, + } + require.NoError(t, db.Insert(t.Context(), run)) + job := &actions_model.ActionRunJob{ + ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, + Name: "my-job", Status: actions_model.StatusSuccess, + } + require.NoError(t, db.Insert(t.Context(), job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job)) + + latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll) + require.NoError(t, err) + // The external status and the workflow status must coexist under distinct hashes. + var external, workflow *git_model.CommitStatus + for _, s := range latest { + switch s.ContextHash { + case legacyHash: + external = s + case distinctHash: + workflow = s + } + } + require.NotNil(t, external, "external status must be preserved under the legacy hash") + require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash") + assert.Equal(t, "https://example.invalid/external", external.TargetURL) +} + // TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no // non-blank `name:` uses the file name in the Context, not an empty // "/ job (event)" — covers both an omitted and a whitespace-only name. diff --git a/services/actions/context.go b/services/actions/context.go index b92e179f949..1cb6dd09fcc 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -134,7 +134,12 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att if attempt != nil { gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10) - if err := attempt.LoadAttributes(ctx); err == nil { + // Only the trigger user is needed for triggering_actor. attempt.LoadAttributes would also re-load + // the run this function already holds as a parameter, on the hot task-dispatch path. + if err := attempt.LoadTriggerUser(ctx); err != nil { + log.Error("GenerateGiteaContext: load trigger user of attempt %d: %v", attempt.ID, err) + } + if attempt.TriggerUser != nil { gitContext["triggering_actor"] = attempt.TriggerUser.Name } } diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 8100f1e2380..31f73bed86d 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -9,7 +9,9 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" "gitea.dev/modules/json" api "gitea.dev/modules/structs" @@ -318,3 +320,39 @@ func TestFindTaskNeeds(t *testing.T) { assert.Equal(t, "abc", ret["job1"].Outputs["output_a"]) assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"]) } + +// TestGenerateGiteaContext_NilAttempt verifies that, with no explicit attempt, +// use GetLatestAttempt to load the latest attempt and resolve attempt-related context variables. +func TestGenerateGiteaContext_NilAttempt(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + require.NoError(t, repo.LoadOwner(t.Context())) + actor := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // initiated the run + triggerer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // initiated the latest attempt + + run := &actions_model.ActionRun{ + RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, + TriggerUserID: actor.ID, TriggerUser: actor, + WorkflowID: "test.yml", Index: 99600, Ref: "refs/heads/main", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", TriggerEvent: "push", + Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), run)) + attempt := &actions_model.ActionRunAttempt{ + RepoID: repo.ID, RunID: run.ID, Attempt: 3, TriggerUserID: triggerer.ID, Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), attempt)) + run.LatestAttemptID = attempt.ID + job := &actions_model.ActionRunJob{ + RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: repo.ID, OwnerID: repo.OwnerID, + Name: "j", JobID: "j", Attempt: attempt.Attempt, Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), job)) + + // attempt == nil forces the fallback lookup via run.GetLatestAttempt. + gitCtx := GenerateGiteaContext(t.Context(), run, nil, job) + assert.Equal(t, actor.Name, gitCtx["actor"]) + assert.Equal(t, triggerer.Name, gitCtx["triggering_actor"]) + assert.Equal(t, "3", gitCtx["run_attempt"]) +} diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 4003a1ad1a1..e1b56c36876 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -27,7 +27,7 @@ type jobUpdate struct { RunID int64 } -func EmitJobsIfReadyByRun(runID int64) error { +var EmitJobsIfReadyByRun = func(runID int64) error { err := jobEmitterQueue.Push(&jobUpdate{ RunID: runID, }) diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index 345f6b039d5..05056fcb4b2 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -32,6 +32,20 @@ import ( // a top-level caller may have at most MaxReusableCallLevels nested callers below it. const MaxReusableCallLevels = 9 +// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun. +// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a +// cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database. +func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error { + existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID) + if err != nil { + return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err) + } + if existing+int64(adding) > actions_model.MaxJobNumPerRun { + return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun) + } + return nil +} + // loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes, // along with the (repo_id, commit_sha) the file was loaded from. func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) { @@ -289,6 +303,10 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses) } + if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil { + return err + } + priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID) if err != nil { return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err) diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go index 8ed228a4873..49ee7016530 100644 --- a/services/actions/reusable_workflow_test.go +++ b/services/actions/reusable_workflow_test.go @@ -207,6 +207,47 @@ func TestResolveUses(t *testing.T) { }) } +func TestCheckRunJobLimit(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + const ( + runID = 900100 + attemptA = 910001 + attemptB = 910002 + ) + + seed := func(attemptID int64, n int) { + for i := range n { + name := fmt.Sprintf("job-%d-%d", attemptID, i) + require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{ + RunID: runID, + RunAttemptID: attemptID, + RepoID: 1, + OwnerID: 1, + CommitSHA: "abcdef", + Name: name, + JobID: name, + AttemptJobID: attemptID*1000 + int64(i), + Status: actions_model.StatusBlocked, + })) + } + } + + seed(attemptA, 5) + seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A + + limit := actions_model.MaxJobNumPerRun + + // attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected. + require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5)) + require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum") + require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum") + + // the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in. + require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3)) + require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum") +} + func TestUndoExpansion(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() diff --git a/tests/integration/actions_rerun_test.go b/tests/integration/actions_rerun_test.go index b088a2379a0..ece09276429 100644 --- a/tests/integration/actions_rerun_test.go +++ b/tests/integration/actions_rerun_test.go @@ -143,6 +143,28 @@ jobs: job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID) assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID) + t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) { + // The run is fully successful, so an empty failed-job list must be rejected rather than fall + // through to re-running the whole run. + before := getRunLatestAttemptNum(t, run.ID) + req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID)) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "no failed jobs") + assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID)) + runner.fetchNoTask(t) // no new tasks were scheduled + }) + + t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) { + // Same rejection on the API route: an empty failed-job list must not fall through to a full re-run. + before := getRunLatestAttemptNum(t, run.ID) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "no failed jobs") + assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID)) + runner.fetchNoTask(t) + }) + t.Run("AttemptAPI", func(t *testing.T) { req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)). AddTokenAuth(token)