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 08:38:14 -06: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...)
}
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
}
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)
+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).
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)
})
}