From bb5919ea876902e1324cd78cd47d0cbbc5c39c5f Mon Sep 17 00:00:00 2001 From: OFHansen Date: Tue, 9 Jun 2026 04:00:56 +0200 Subject: [PATCH] feat(api): add new `/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` API endpoint (#12957) This new API endpoint makes it possible to cancel action runs via the API. Previously this was only natively possible through the UI, the same `CancelRun` func has been reused for this feature. ### Tests for Go changes - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12957): feat(api): add new `/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` API endpoint Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12957 Reviewed-by: limiting-factor Reviewed-by: Andreas Ahlenstorf Reviewed-by: Mathieu Fenniak --- routers/api/v1/api.go | 1 + routers/api/v1/repo/action.go | 61 +++++++++ templates/swagger/v1_json.tmpl | 48 +++++++ tests/integration/api_repo_actions_test.go | 118 ++++++++++++++++++ .../action_run.yml | 9 ++ .../action_run_job.yml | 8 ++ .../action_task.yml | 6 + 7 files changed, 251 insertions(+) create mode 100644 tests/integration/fixtures/TestActionsAPICancelActionRun/action_run.yml create mode 100644 tests/integration/fixtures/TestActionsAPICancelActionRun/action_run_job.yml create mode 100644 tests/integration/fixtures/TestActionsAPICancelActionRun/action_task.yml diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9e13901fac..fa556a3b42 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1256,6 +1256,7 @@ func Routes() *web.Route { m.Get("", repo.ListActionRuns) m.Get("/{run_id}", repo.GetActionRun) m.Delete("/{run_id}", reqToken(), reqAdmin(unit.TypeActions), repo.DeleteActionRun) + m.Post("/{run_id}/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelActionRun) m.Get("/{run_id}/jobs", repo.ListActionRunJobs) m.Get("/{run_id}/logs", repo.GetActionRunLogs) m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 5c2d2af4d6..e759ef2088 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1095,6 +1095,67 @@ func DeleteActionRun(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// CancelActionRun cancels a pending or running workflow run. +func CancelActionRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel repository CancelActionRun + // --- + // summary: Cancel a pending or running workflow run. + // description: > + // Cancel a particular workflow run. Pending or running jobs of the run are cancelled. A run that has + // already finished, whether cancelled, failed, skipped or succeeded, is left unchanged. + // In both cases the endpoint responds with HTTP 204. + // 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 repo + // type: string + // required: true + // - name: run_id + // in: path + // description: ID of the workflow run + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // description: Workflow run has been cancelled + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + run, err := actions_model.GetRunByID(ctx, ctx.ParamsInt64(":run_id")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetRunById", err) + return + } + + ctx.Error(http.StatusInternalServerError, "GetRunByID", err) + return + } + + if ctx.Repo.Repository.ID != run.RepoID { + ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist) + return + } + + err = actions_service.CancelRun(ctx, run) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CancelRun", err) + return + } + + ctx.Status(http.StatusNoContent) +} + // ListActionRunJobs return a filtered list of jobs that belong to a single workflow run func ListActionRunJobs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index db3412613f..ffa3275b61 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6422,6 +6422,54 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run_id}/cancel": { + "post": { + "description": "Cancel a particular workflow run. Pending or running jobs of the run are cancelled. A run that has already finished, whether cancelled, failed, skipped or succeeded, is left unchanged. In both cases the endpoint responds with HTTP 204.\n", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Cancel a pending or running workflow run.", + "operationId": "CancelActionRun", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ID of the workflow run", + "name": "run_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Workflow run has been cancelled" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run_id}/jobs": { "get": { "produces": [ diff --git a/tests/integration/api_repo_actions_test.go b/tests/integration/api_repo_actions_test.go index 1331ba0959..86b77eeb21 100644 --- a/tests/integration/api_repo_actions_test.go +++ b/tests/integration/api_repo_actions_test.go @@ -786,6 +786,124 @@ func TestActionsAPIDeleteActionRun(t *testing.T) { }) } +func TestActionsAPICancelActionRun(t *testing.T) { + t.Run("Run cancelled", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")() + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + session := loginUser(t, user2.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 48011, RunID: run.ID}) + assert.False(t, run.Status.IsDone()) + assert.False(t, job.Status.IsDone()) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID) + request := NewRequest(t, "POST", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNoContent) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusCancelled, run.Status) + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}) + assert.Equal(t, actions_model.StatusCancelled, job.Status) + }) + + t.Run("Already finished run is left unchanged", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")() + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + session := loginUser(t, user2.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35012}) + require.Equal(t, actions_model.StatusSuccess, run.Status) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID) + request := NewRequest(t, "POST", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNoContent) + + // The finished run keeps its status. + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusSuccess, run.Status) + }) + + t.Run("Not found if run does not belong to repository", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")() + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo62 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62, OwnerID: user2.ID}) + session := loginUser(t, user2.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011}) + assert.Equal(t, actions_model.StatusRunning, run.Status) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo62.FullName(), run.ID) + request := NewRequest(t, "POST", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNotFound) + + // Verify that the run was not cancelled. + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusRunning, run.Status) + }) + + t.Run("Not found if run does not exist", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + session := loginUser(t, user2.Name) + writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: 260871}) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/260871/cancel", repo1.FullName()) + request := NewRequest(t, "POST", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNotFound) + }) + + t.Run("Run cancellation requires write token", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPICancelActionRun")() + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + session := loginUser(t, user2.Name) + readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 35011}) + assert.Equal(t, actions_model.StatusRunning, run.Status) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/cancel", repo1.FullName(), run.ID) + request := NewRequest(t, "POST", requestURL) + request.AddTokenAuth(readToken) + response := MakeRequest(t, request, http.StatusForbidden) + + type errorResponse struct { + Message string `json:"message"` + } + + var errorMessage *errorResponse + DecodeJSON(t, response, &errorMessage) + + assert.Equal(t, "token does not have at least one of required scope(s): [write:repository]", errorMessage.Message) + + // Verify that the run was not cancelled. + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusRunning, run.Status) + }) +} + func TestActionsAPIListActionRunJobs(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run.yml b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run.yml new file mode 100644 index 0000000000..8d0036bb2c --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run.yml @@ -0,0 +1,9 @@ +- id: 35011 + repo_id: 1 + owner_id: 2 + status: 6 # StatusRunning + +- id: 35012 + repo_id: 1 + owner_id: 2 + status: 1 # StatusSuccess diff --git a/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run_job.yml b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run_job.yml new file mode 100644 index 0000000000..78e2edb3d7 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_run_job.yml @@ -0,0 +1,8 @@ +- id: 48011 + run_id: 35011 + task_id: 72011 + status: 6 # StatusRunning + +- id: 48012 + run_id: 35012 + status: 1 # StatusSuccess diff --git a/tests/integration/fixtures/TestActionsAPICancelActionRun/action_task.yml b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_task.yml new file mode 100644 index 0000000000..7d7989ecf2 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPICancelActionRun/action_task.yml @@ -0,0 +1,6 @@ +- id: 72011 + job_id: 48011 + log_filename: cancel-test/72/011.log + log_in_storage: false + status: 6 # StatusRunning + runner_id: 1