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
This commit is contained in:
Zettat123
2026-08-07 14:38:14 +00:00
committed by GitHub
parent 2657756cac
commit 9fc5d20006
10 changed files with 396 additions and 19 deletions
+1 -1
View File
@@ -404,5 +404,5 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA
jobsToCancel = append(jobsToCancel, jobs...) jobsToCancel = append(jobsToCancel, jobs...)
} }
return CancelJobs(ctx, jobsToCancel) return CancelJobs(ctx, jobsToCancel, false)
} }
+14 -12
View File
@@ -703,7 +703,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
return cancelledJobs, err return cancelledJobs, err
} }
cjs, err := CancelJobs(ctx, jobs) cjs, err := CancelJobs(ctx, jobs, false)
if err != nil { if err != nil {
return cancelledJobs, err return cancelledJobs, err
} }
@@ -749,17 +749,18 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
jobsToCancel = append(jobsToCancel, jobs...) 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 // CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one. // handshake so a running task is marked cancelled without waiting for its runner. It leaves the
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { // 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)) cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
for _, job := range jobs { for _, job := range jobs {
if job.IsReusableCaller { if job.IsReusableCaller {
sub, err := cancelReusableCaller(ctx, job) sub, err := cancelReusableCaller(ctx, job, force)
if err != nil { if err != nil {
return cancelledJobs, err return cancelledJobs, err
} }
@@ -767,7 +768,7 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
continue continue
} }
c, err := cancelOneJob(ctx, job) c, err := cancelOneJob(ctx, job, force)
if err != nil { if err != nil {
return cancelledJobs, err 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 // 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() { if job.Status.IsDone() {
return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error" 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 return job, nil
} }
// Has a task: stop the task and re-read the row. // 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 return nil, err
} }
updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID) 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 // 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) cancelledJobs := make([]*ActionRunJob, 0)
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID) 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) }) slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
for _, c := range descendants { for _, c := range descendants {
cancelled, err := cancelOneJob(ctx, c) cancelled, err := cancelOneJob(ctx, c, force)
if err != nil { if err != nil {
return cancelledJobs, err 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 return cancelledJobs, err
} else if c != nil { } else if c != nil {
cancelledJobs = append(cancelledJobs, c) cancelledJobs = append(cancelledJobs, c)
+76 -2
View File
@@ -187,7 +187,7 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
// Cancel all jobs of the attempt, ordered by id (parent before child). // Cancel all jobs of the attempt, ordered by id (parent before child).
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID) jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
require.NoError(t, err) require.NoError(t, err)
_, err = CancelJobs(ctx, jobs) _, err = CancelJobs(ctx, jobs, false)
require.NoError(t, err) require.NoError(t, err)
for _, j := range []*ActionRunJob{outer, inner} { for _, j := range []*ActionRunJob{outer, inner} {
@@ -272,7 +272,7 @@ func TestSettleRunAfterCancel(t *testing.T) {
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob) run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
// mirrors what the CancelRun service does // mirrors what the CancelRun service does
cancelled, err := CancelJobs(t.Context(), jobs) cancelled, err := CancelJobs(t.Context(), jobs, false)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly") assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
require.NoError(t, SettleRunAfterCancel(t.Context(), run)) require.NoError(t, SettleRunAfterCancel(t.Context(), run))
@@ -327,3 +327,77 @@ jobs:
assert.Equal(t, "build (1)", parsed.Name) 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)
})
}
+1
View File
@@ -1363,6 +1363,7 @@ func Routes() *web.Router {
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun) 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.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun)
m.Group("/jobs", func() { m.Group("/jobs", func() {
m.Get("", repo.ListWorkflowRunJobs) m.Get("", repo.ListWorkflowRunJobs)
+51 -1
View File
@@ -88,6 +88,51 @@ func CancelWorkflowRun(ctx *context.APIContext) {
// "409": // "409":
// "$ref": "#/responses/conflict" // "$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) run, jobs := getCurrentRepoActionRunJobsByID(ctx)
if ctx.Written() { if ctx.Written() {
return return
@@ -99,7 +144,12 @@ func CancelWorkflowRun(ctx *context.APIContext) {
return 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 { if err != nil {
ctx.APIErrorAuto(err) ctx.APIErrorAuto(err)
return return
+14 -2
View File
@@ -12,10 +12,21 @@ import (
) )
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state. // 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) { 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 var updatedJobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) (err error) { 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 { if err != nil {
return fmt.Errorf("CancelJobs: %w", err) return fmt.Errorf("CancelJobs: %w", err)
} }
@@ -27,7 +38,8 @@ func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action
return nil, err 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) EmitJobsIfReadyByJobs(updatedJobs)
NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
+1 -1
View File
@@ -182,7 +182,7 @@ func CancelAbandonedJobs(ctx context.Context) error {
return err return err
} }
updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs) updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs, false)
if err != nil { if err != nil {
log.Warn("cancel abandoned jobs: %v", err) log.Warn("cancel abandoned jobs: %v", err)
} }
+56
View File
@@ -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": { "/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
"get": { "get": {
"operationId": "listWorkflowRunJobs", "operationId": "listWorkflowRunJobs",
+53
View File
@@ -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": { "/repos/{owner}/{repo}/actions/runs/{run}/jobs": {
"get": { "get": {
"produces": [ "produces": [
+129
View File
@@ -17,10 +17,13 @@ import (
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
"gitea.dev/models/db" "gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/actions" "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/tests" "gitea.dev/tests"
@@ -42,6 +45,7 @@ func TestAPIActionsWorkflowRun(t *testing.T) {
t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound) t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound)
// finishes run 793, so it must come after everything that needs it still running // finishes run 793, so it must come after everything that needs it still running
t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun) t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun)
t.Run("ForceCancelWorkflowRun", testAPIActionsForceCancelWorkflowRun)
t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun) t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun)
// deletes run 795, so it must come after everything that reads it // deletes run 795, so it must come after everything that reads it
t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral) 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) { func testAPIActionsApproveWorkflowRun(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) 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 // user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all