mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-08 11:17:37 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+56
@@ -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",
|
||||
|
||||
+53
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user