From 9fc5d20006f70043d5e82d74dea7c7d32c472502 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Fri, 7 Aug 2026 08:38:14 -0600 Subject: [PATCH] feat(actions): add force-cancel workflow run API (#38756) Add `POST /repos/{owner}/{repo}/actions/runs/{run}/force-cancel`, the counterpart of [GitHub's force-cancel endpoint](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run). It cancels a run like `POST .../cancel`, but bypasses the graceful cancelling handshake with the runner and stops running tasks immediately. Permissions and responses match the `/cancel` endpoint. References: - https://github.blog/changelog/2023-09-21-github-actions-force-cancel-workflows/ - https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2026-03-10#force-cancel-a-workflow-run - https://github.com/orgs/community/discussions/123240 --- models/actions/run.go | 2 +- models/actions/run_job.go | 26 ++-- models/actions/run_job_test.go | 78 ++++++++++- routers/api/v1/api.go | 1 + routers/api/v1/repo/actions_run.go | 52 +++++++- services/actions/cancel.go | 16 ++- services/actions/clear_tasks.go | 2 +- templates/swagger/v1-openapi3.generated.json | 56 ++++++++ templates/swagger/v1-swagger.generated.json | 53 ++++++++ tests/integration/api_actions_run_test.go | 129 +++++++++++++++++++ 10 files changed, 396 insertions(+), 19 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index 0e54158537a..976cd5e0355 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -404,5 +404,5 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA jobsToCancel = append(jobsToCancel, jobs...) } - return CancelJobs(ctx, jobsToCancel) + return CancelJobs(ctx, jobsToCancel, false) } diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 5f7a5367e17..a3f38f6d155 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -703,7 +703,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin return cancelledJobs, err } - cjs, err := CancelJobs(ctx, jobs) + cjs, err := CancelJobs(ctx, jobs, false) if err != nil { return cancelledJobs, err } @@ -749,17 +749,18 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) jobsToCancel = append(jobsToCancel, jobs...) } - return CancelJobs(ctx, jobsToCancel) + return CancelJobs(ctx, jobsToCancel, false) } -// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it -// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one. -func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { +// CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling +// handshake so a running task is marked cancelled without waiting for its runner. It leaves the +// status of a run it cancelled nothing in untouched, SettleRunAfterCancel gives such a run a final one. +func CancelJobs(ctx context.Context, jobs []*ActionRunJob, force bool) ([]*ActionRunJob, error) { cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) for _, job := range jobs { if job.IsReusableCaller { - sub, err := cancelReusableCaller(ctx, job) + sub, err := cancelReusableCaller(ctx, job, force) if err != nil { return cancelledJobs, err } @@ -767,7 +768,7 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err continue } - c, err := cancelOneJob(ctx, job) + c, err := cancelOneJob(ctx, job, force) if err != nil { return cancelledJobs, err } @@ -789,7 +790,7 @@ func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error { } // cancelOneJob cancels a single job and returns the post-cancel row -func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) { +func cancelOneJob(ctx context.Context, job *ActionRunJob, force bool) (*ActionRunJob, error) { if job.Status.IsDone() { return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error" } @@ -808,7 +809,8 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) return job, nil } // Has a task: stop the task and re-read the row. - if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil { + stopStatus := util.Iif(force, StatusCancelled, StatusCancelling) + if err := StopTask(ctx, job.TaskID, stopStatus); err != nil { return nil, err } updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID) @@ -819,7 +821,7 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) } // cancelReusableCaller cancels `caller` and all its child jobs -func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) { +func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool) ([]*ActionRunJob, error) { cancelledJobs := make([]*ActionRunJob, 0) attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID) @@ -834,7 +836,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) }) for _, c := range descendants { - cancelled, err := cancelOneJob(ctx, c) + cancelled, err := cancelOneJob(ctx, c, force) if err != nil { return cancelledJobs, err } @@ -843,7 +845,7 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR } } - if c, err := cancelOneJob(ctx, caller); err != nil { + if c, err := cancelOneJob(ctx, caller, force); err != nil { return cancelledJobs, err } else if c != nil { cancelledJobs = append(cancelledJobs, c) diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 182b19737f5..6570ed60534 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -187,7 +187,7 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) { // Cancel all jobs of the attempt, ordered by id (parent before child). jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID) require.NoError(t, err) - _, err = CancelJobs(ctx, jobs) + _, err = CancelJobs(ctx, jobs, false) require.NoError(t, err) for _, j := range []*ActionRunJob{outer, inner} { @@ -272,7 +272,7 @@ func TestSettleRunAfterCancel(t *testing.T) { run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob) // mirrors what the CancelRun service does - cancelled, err := CancelJobs(t.Context(), jobs) + cancelled, err := CancelJobs(t.Context(), jobs, false) require.NoError(t, err) assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly") require.NoError(t, SettleRunAfterCancel(t.Context(), run)) @@ -327,3 +327,77 @@ jobs: assert.Equal(t, "build (1)", parsed.Name) }) } + +func TestForceCancelJobs(t *testing.T) { + assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) { + t.Helper() + + taskAfter := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}) + assert.Equal(t, StatusCancelled, taskAfter.Status) + assert.NotZero(t, taskAfter.Stopped) + + jobAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}) + assert.Equal(t, StatusCancelled, jobAfter.Status) + assert.NotZero(t, jobAfter.Stopped) + } + + // A running task is force-cancelled directly, without trying the graceful cancel first. + t.Run("running task", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + task, job := newRunningTaskForCancelling(t, "force-cancel-job", true) + + cancelledJobs, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true) + require.NoError(t, err) + require.Len(t, cancelledJobs, 1) + assert.Equal(t, StatusCancelled, cancelledJobs[0].Status) + assertCancelled(t, task, job) + }) + + // A task already in the cancelling handshake whose runner never finishes the cleanup. + t.Run("cancelling task", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + task, job := newRunningTaskForCancelling(t, "force-cancel-cancelling-job", true) + + cancelling, err := CancelJobs(t.Context(), []*ActionRunJob{job}, false) + require.NoError(t, err) + require.Len(t, cancelling, 1) + assert.Equal(t, StatusCancelling, cancelling[0].Status) + + job = unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}) + cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{job}, true) + require.NoError(t, err) + require.Len(t, cancelled, 1) + assertCancelled(t, task, job) + }) + + // A caller is cancelled through its descendants, so the force has to reach their tasks too. + t.Run("reusable caller", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + task, child := newRunningTaskForCancelling(t, "force-cancel-child", true) + + caller := &ActionRunJob{ + RunID: child.RunID, + RepoID: child.RepoID, + OwnerID: child.OwnerID, + CommitSHA: child.CommitSHA, + Name: "force-cancel-caller", + JobID: "force-cancel-caller", + Attempt: 1, + Status: StatusRunning, + IsReusableCaller: true, + IsExpanded: true, + } + require.NoError(t, db.Insert(t.Context(), caller)) + child.ParentJobID = caller.ID + _, err := UpdateRunJob(t.Context(), child, nil, "parent_job_id") + require.NoError(t, err) + + cancelled, err := CancelJobs(t.Context(), []*ActionRunJob{caller}, true) + require.NoError(t, err) + require.Len(t, cancelled, 2) + assertCancelled(t, task, child) + + callerAfter := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: caller.ID}) + assert.Equal(t, StatusCancelled, callerAfter.Status) + }) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 86ed1ba5a6b..31e7da2d5e6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1363,6 +1363,7 @@ func Routes() *web.Router { m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun) + m.Post("/force-cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.ForceCancelWorkflowRun) m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun) m.Group("/jobs", func() { m.Get("", repo.ListWorkflowRunJobs) diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index e7f2b45591b..1fd9e387db1 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -88,6 +88,51 @@ func CancelWorkflowRun(ctx *context.APIContext) { // "409": // "$ref": "#/responses/conflict" + cancelWorkflowRun(ctx, false) +} + +func ForceCancelWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/force-cancel repository forceCancelWorkflowRun + // --- + // summary: Force-cancel a workflow run + // description: | + // Cancels a workflow run without waiting for its runners to acknowledge the cancellation. + // The jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded. + // Only use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: run ID + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + + cancelWorkflowRun(ctx, true) +} + +func cancelWorkflowRun(ctx *context.APIContext, force bool) { run, jobs := getCurrentRepoActionRunJobsByID(ctx) if ctx.Written() { return @@ -99,7 +144,12 @@ func CancelWorkflowRun(ctx *context.APIContext) { return } - run, err := actions_service.CancelRun(ctx, run, jobs) + var err error + if force { + run, err = actions_service.ForceCancelRun(ctx, run, jobs) + } else { + run, err = actions_service.CancelRun(ctx, run, jobs) + } if err != nil { ctx.APIErrorAuto(err) return diff --git a/services/actions/cancel.go b/services/actions/cancel.go index 458621e5a2a..b0b4c52d63e 100644 --- a/services/actions/cancel.go +++ b/services/actions/cancel.go @@ -12,10 +12,21 @@ import ( ) // CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state. +// A runner that supports it gets to run its post-cancel cleanup before the job reaches its final status. func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) { + return cancelRun(ctx, run, jobs, false) +} + +// ForceCancelRun cancels a run like CancelRun, but does not wait for the runners to acknowledge it: +// the jobs are marked cancelled at once and whatever a runner reports for them afterwards is discarded. +func ForceCancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) { + return cancelRun(ctx, run, jobs, true) +} + +func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, force bool) (*actions_model.ActionRun, error) { var updatedJobs []*actions_model.ActionRunJob if err := db.WithTx(ctx, func(ctx context.Context) (err error) { - updatedJobs, err = actions_model.CancelJobs(ctx, jobs) + updatedJobs, err = actions_model.CancelJobs(ctx, jobs, force) if err != nil { return fmt.Errorf("CancelJobs: %w", err) } @@ -27,7 +38,8 @@ func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action return nil, err } - CreateCommitStatusForRunJobs(ctx, run, jobs...) + // updatedJobs, not jobs: cancelOneJob re-reads the cancelled rows, the input ones still carry their pre-cancel status + CreateCommitStatusForRunJobs(ctx, run, updatedJobs...) EmitJobsIfReadyByJobs(updatedJobs) NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index f3576db2e85..982108229e2 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -182,7 +182,7 @@ func CancelAbandonedJobs(ctx context.Context) error { return err } - updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs) + updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs, false) if err != nil { log.Warn("cancel abandoned jobs: %v", err) } diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 3e80ab6942a..4b7d25b6fb7 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -16802,6 +16802,62 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/force-cancel": { + "post": { + "description": "Cancels a workflow run without waiting for its runners to acknowledge the cancellation.\nThe jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded.\nOnly use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`.\n", + "operationId": "forceCancelWorkflowRun", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/WorkflowRun" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "409": { + "$ref": "#/components/responses/conflict" + } + }, + "summary": "Force-cancel a workflow run", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "operationId": "listWorkflowRunJobs", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index 88d66299ba5..bc1acf80ec1 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -5737,6 +5737,59 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/force-cancel": { + "post": { + "description": "Cancels a workflow run without waiting for its runners to acknowledge the cancellation.\nThe jobs are marked cancelled at once and anything a runner reports for them afterwards is discarded.\nOnly use this endpoint when the workflow run does not respond to `POST /repos/{owner}/{repo}/actions/runs/{run}/cancel`.\n", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Force-cancel a workflow run", + "operationId": "forceCancelWorkflowRun", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "run ID", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/WorkflowRun" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "produces": [ diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index 3a72caf7a05..f45b9d2f15b 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -17,10 +17,13 @@ import ( actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" + git_model "gitea.dev/models/git" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" "gitea.dev/modules/actions" + "gitea.dev/modules/commitstatus" + "gitea.dev/modules/json" api "gitea.dev/modules/structs" "gitea.dev/modules/timeutil" "gitea.dev/tests" @@ -42,6 +45,7 @@ func TestAPIActionsWorkflowRun(t *testing.T) { t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound) // finishes run 793, so it must come after everything that needs it still running t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun) + t.Run("ForceCancelWorkflowRun", testAPIActionsForceCancelWorkflowRun) t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun) // deletes run 795, so it must come after everything that reads it t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral) @@ -373,6 +377,131 @@ func testAPIActionsCancelWorkflowRun(t *testing.T) { }) } +func testAPIActionsForceCancelWorkflowRun(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + ownerSession := loginUser(t, owner.Name) + ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository) + + // repo4's master head, so the run's commit statuses are created against a commit that exists + const commitSHA = "c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338" + eventPayload, err := json.Marshal(&api.PushPayload{HeadCommit: &api.PayloadCommit{ID: commitSHA}}) + require.NoError(t, err) + + // A running run whose runner advertises cancelling support and reports on time: + // a normal cancel only starts the graceful cancelling handshake, so only a force-cancel finishes it. + run := &actions_model.ActionRun{ + Title: "force-cancel-test", + RepoID: repo.ID, + OwnerID: repo.OwnerID, + WorkflowID: "force-cancel.yaml", + Index: 9601, + TriggerUserID: owner.ID, + Ref: "refs/heads/master", + CommitSHA: commitSHA, + Event: "push", + TriggerEvent: "push", + EventPayload: string(eventPayload), + Status: actions_model.StatusRunning, + Started: timeutil.TimeStampNow(), + } + require.NoError(t, db.Insert(t.Context(), run)) + + attempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: owner.ID, + Status: actions_model.StatusRunning, + Started: timeutil.TimeStampNow(), + } + require.NoError(t, db.Insert(t.Context(), attempt)) + run.LatestAttemptID = attempt.ID + require.NoError(t, actions_model.UpdateRun(t.Context(), run, "latest_attempt_id")) + + job := &actions_model.ActionRunJob{ + RunID: run.ID, + RunAttemptID: attempt.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: "job1", + Attempt: 1, + JobID: "job1", + Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(t.Context(), job)) + + runner := &actions_model.ActionRunner{ + UUID: "force-cancel-runner", + Name: "force-cancel-runner", + RepoID: repo.ID, + HasCancellingSupport: true, + } + runner.GenerateAndFillToken() + require.NoError(t, db.Insert(t.Context(), runner)) + + task := &actions_model.ActionTask{ + JobID: job.ID, + Attempt: 1, + RunnerID: runner.ID, + Status: actions_model.StatusRunning, + Started: timeutil.TimeStampNow(), + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + } + require.NoError(t, db.Insert(t.Context(), task)) + + job.TaskID = task.ID + _, err = actions_model.UpdateRunJob(t.Context(), job, nil, "task_id") + require.NoError(t, err) + + cancelURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo.FullName(), run.ID) + forceCancelURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/force-cancel", repo.FullName(), run.ID) + + // a normal cancel only starts the graceful handshake + MakeRequest(t, NewRequest(t, "POST", cancelURL).AddTokenAuth(ownerToken), http.StatusOK) + cancellingTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID}) + assert.Equal(t, actions_model.StatusCancelling, cancellingTask.Status) + + // the commit status describes the cancellation, not the job's pre-cancel state + statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, commitSHA, db.ListOptionsAll) + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, "Canceling", statuses[0].Description) + + // force-cancel bypasses the handshake and finishes the run immediately + resp := MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(ownerToken), http.StatusOK) + cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.Equal(t, "completed", cancelledRun.Status) + assert.Equal(t, "cancelled", cancelledRun.Conclusion) + + cancelledTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID}) + assert.Equal(t, actions_model.StatusCancelled, cancelledTask.Status) + gotAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: attempt.ID}) + assert.Equal(t, actions_model.StatusCancelled, gotAttempt.Status) + gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusCancelled, gotRun.Status) + + // the run is done, so its commit status must be final instead of pending + statuses, err = git_model.GetLatestCommitStatus(t.Context(), repo.ID, commitSHA, db.ListOptionsAll) + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, commitstatus.CommitStatusFailure, statuses[0].State) + + // both endpoints refuse the completed run + MakeRequest(t, NewRequest(t, "POST", cancelURL).AddTokenAuth(ownerToken), http.StatusConflict) + MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(ownerToken), http.StatusConflict) + + // the route is guarded like /cancel: user2 has no access to repo4, owned by user5 + user2Token := getTokenForLoggedInUser(t, loginUser(t, "user2"), auth_model.AccessTokenScopeWriteRepository) + MakeRequest(t, NewRequest(t, "POST", forceCancelURL).AddTokenAuth(user2Token), http.StatusForbidden) + + missingRunURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/force-cancel", repo.FullName()) + MakeRequest(t, NewRequest(t, "POST", missingRunURL).AddTokenAuth(ownerToken), http.StatusNotFound) +} + func testAPIActionsApproveWorkflowRun(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all