From 03312e4f46c1a5f75d471af4e60ea785bed0639f Mon Sep 17 00:00:00 2001 From: Andreas Ahlenstorf Date: Mon, 11 May 2026 16:02:36 +0200 Subject: [PATCH] feat: make it possible to remove workflow runs (#12478) Add the ability to remove workflow runs, either using the UI or the HTTP API. Workflow runs can only be removed once a workflow run has completed. For security reasons, only a repository administrator or a token with `write:repository` permissions can remove runs. Resolves https://codeberg.org/forgejo/forgejo/issues/2184. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests for Go changes (can be removed for JavaScript changes) - I added test coverage for Go changes... - [x] 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 ### Tests for JavaScript changes (can be removed for Go changes) - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. *The decision if the pull request will be shown in the release notes is up to the mergers / release team.* The content of the `release-notes/.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12478): make it possible to remove workflow runs Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12478 Reviewed-by: Mathieu Fenniak --- models/actions/artifact.go | 9 ++ models/actions/run.go | 7 + models/actions/run_job.go | 7 + models/actions/runner.go | 7 + models/actions/runner_test.go | 59 +++++++++ models/actions/task.go | 30 +++++ options/locale_next/locale_en-US.json | 5 + routers/api/v1/api.go | 12 +- routers/api/v1/repo/action.go | 62 +++++++++ routers/web/repo/actions/view.go | 28 ++++ routers/web/web.go | 1 + .../TestDeleteJobsOfRun/action_run.yml | 5 + .../TestDeleteJobsOfRun/action_run_job.yml | 7 + .../TestDeleteJobsOfRun/action_task.yml | 11 ++ .../action_task_output.yml | 14 ++ .../TestDeleteJobsOfRun/action_task_step.yml | 9 ++ .../actions/TestDeleteRun/action_artifact.yml | 9 ++ services/actions/TestDeleteRun/action_run.yml | 5 + .../actions/TestDeleteRun/action_run_job.yml | 7 + .../actions/TestDeleteRun/action_task.yml | 11 ++ .../TestDeleteRun/action_task_output.yml | 14 ++ .../TestDeleteRun/action_task_step.yml | 9 ++ .../actions/TestDeleteTask/action_runner.yml | 6 + .../actions/TestDeleteTask/action_task.yml | 16 +++ .../TestDeleteTask/action_task_output.yml | 14 ++ .../TestDeleteTask/action_task_step.yml | 14 ++ services/actions/job.go | 47 +++++++ services/actions/job_test.go | 45 +++++++ services/actions/run.go | 28 ++++ services/actions/run_test.go | 39 ++++++ services/actions/task.go | 37 ++++++ services/actions/task_test.go | 91 +++++++++++++ templates/repo/actions/view.tmpl | 3 + templates/swagger/v1_json.tmpl | 49 +++++++ tests/integration/actions_run_test.go | 123 ++++++++++++++++++ tests/integration/actions_view_test.go | 68 +++++++++- tests/integration/api_repo_actions_test.go | 115 ++++++++++++++++ .../TestActionRunDeletion/action_artifact.yml | 9 ++ .../TestActionRunDeletion/action_run.yml | 11 ++ .../TestActionRunDeletion/action_run_job.yml | 7 + .../TestActionRunDeletion/action_runner.yml | 5 + .../TestActionRunDeletion/action_task.yml | 12 ++ .../action_task_output.yml | 14 ++ .../action_task_step.yml | 9 ++ .../TestActionRunDeletion/collaboration.yml | 4 + .../TestActionViewRunDeletion/action_run.yml | 11 ++ .../action_run_job.yml | 7 + .../TestActionViewRunDeletion/action_task.yml | 11 ++ .../action_task_step.yml | 9 ++ .../collaboration.yml | 4 + .../action_artifact.yml | 9 ++ .../action_run.yml | 9 ++ .../action_run_job.yml | 7 + .../action_runner.yml | 5 + .../action_task.yml | 12 ++ .../action_task_output.yml | 14 ++ .../action_task_step.yml | 9 ++ web_src/js/components/RepoActionView.test.js | 3 + web_src/js/components/RepoActionView.vue | 20 +++ web_src/js/features/repo-action-view.ts | 3 + 60 files changed, 1221 insertions(+), 6 deletions(-) create mode 100644 services/actions/TestDeleteJobsOfRun/action_run.yml create mode 100644 services/actions/TestDeleteJobsOfRun/action_run_job.yml create mode 100644 services/actions/TestDeleteJobsOfRun/action_task.yml create mode 100644 services/actions/TestDeleteJobsOfRun/action_task_output.yml create mode 100644 services/actions/TestDeleteJobsOfRun/action_task_step.yml create mode 100644 services/actions/TestDeleteRun/action_artifact.yml create mode 100644 services/actions/TestDeleteRun/action_run.yml create mode 100644 services/actions/TestDeleteRun/action_run_job.yml create mode 100644 services/actions/TestDeleteRun/action_task.yml create mode 100644 services/actions/TestDeleteRun/action_task_output.yml create mode 100644 services/actions/TestDeleteRun/action_task_step.yml create mode 100644 services/actions/TestDeleteTask/action_runner.yml create mode 100644 services/actions/TestDeleteTask/action_task.yml create mode 100644 services/actions/TestDeleteTask/action_task_output.yml create mode 100644 services/actions/TestDeleteTask/action_task_step.yml create mode 100644 services/actions/job.go create mode 100644 services/actions/job_test.go create mode 100644 tests/integration/actions_run_test.go create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_artifact.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_run.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_run_job.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_runner.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_task.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_task_output.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/action_task_step.yml create mode 100644 tests/integration/fixtures/TestActionRunDeletion/collaboration.yml create mode 100644 tests/integration/fixtures/TestActionViewRunDeletion/action_run.yml create mode 100644 tests/integration/fixtures/TestActionViewRunDeletion/action_run_job.yml create mode 100644 tests/integration/fixtures/TestActionViewRunDeletion/action_task.yml create mode 100644 tests/integration/fixtures/TestActionViewRunDeletion/action_task_step.yml create mode 100644 tests/integration/fixtures/TestActionViewRunDeletion/collaboration.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_artifact.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run_job.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_runner.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_output.yml create mode 100644 tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_step.yml diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 25bf58705a..20b96f829c 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -222,6 +222,15 @@ func SetArtifactDeleted(ctx context.Context, artifactID int64) error { return err } +// SetArtifactsOfRunDeleted marks all artifacts of the given run as deleted. +func SetArtifactsOfRunDeleted(ctx context.Context, runID int64) error { + _, err := db.GetEngine(ctx). + Where("run_id=?", runID). + Cols("status"). + Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)}) + return err +} + // aggregatedArtifactConds returns the common WHERE clause used by aggregated // artifact queries: restrict to visible statuses and apply the caller's filters. // The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired. diff --git a/models/actions/run.go b/models/actions/run.go index 692dab841b..09eddc3269 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -612,4 +612,11 @@ func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns return run, columns, nil } +// DeleteRun removes the given run. It is the caller's responsibility to handle the run's dependencies like artifacts or +// jobs. Nothing happens if the run does not exist. +func DeleteRun(ctx context.Context, runID int64) error { + _, err := db.GetEngine(ctx).Delete(&ActionRun{ID: runID}) + return err +} + type ActionRunIndex db.ResourceIndex diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 0967ab87c3..e64b62805c 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -365,3 +365,10 @@ func (job *ActionRunJob) AllNeedsExist(allExistingJobIDs container.Set[string]) return unknownJobIDs, len(unknownJobIDs) == 0 } + +// DeleteJob removes the given job. Removing all associated tasks is up to the caller. If the given job does not exist, +// nothing happens. +func DeleteJob(ctx context.Context, jobID int64) error { + _, err := db.GetEngine(ctx).Delete(&ActionRunJob{ID: jobID}) + return err +} diff --git a/models/actions/runner.go b/models/actions/runner.go index 1a7fbb0b45..f324adec52 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -396,6 +396,13 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) { return res.RowsAffected() } +// DeleteEphemeralRunner removes the ephemeral runner with the given ID. If the runner with the given ID is not an +// ephemeral runner, nothing happens. +func DeleteEphemeralRunner(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).Where(builder.Eq{"id": id, "ephemeral": true}).Delete(&ActionRunner{}) + return err +} + func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error { log.Info("Doing: DeleteOfflineRunners") diff --git a/models/actions/runner_test.go b/models/actions/runner_test.go index acca9b1761..31e9706851 100644 --- a/models/actions/runner_test.go +++ b/models/actions/runner_test.go @@ -479,3 +479,62 @@ func TestRunner_FindRunnerOptionsToConds(t *testing.T) { }) } } + +func TestDeleteEphemeralRunner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + persistentRunnerOne := &ActionRunner{ + ID: 606526, + UUID: "d53a1222-ae7a-4430-97f8-8fcb6efd04c9", + Name: "persistent-runner-one", + OwnerID: 2, + RepoID: 0, + Ephemeral: false, + TokenHash: "J9YDsQL", + } + persistentRunnerTwo := &ActionRunner{ + ID: 606527, + UUID: "3dc23067-b2fd-4daf-b428-dddad80d7f37", + Name: "persistent-runner-two", + OwnerID: 2, + RepoID: 0, + Ephemeral: false, + TokenHash: "jvIylZtHsS", + } + ephemeralRunnerOne := &ActionRunner{ + ID: 606528, + UUID: "2d9bc0a1-7019-4ed3-ba67-6415415ac2a9", + Name: "ephemeral-runner-one", + OwnerID: 2, + RepoID: 0, + Ephemeral: true, + TokenHash: "t9C8L0kM3W", + } + ephemeralRunnerTwo := &ActionRunner{ + ID: 606529, + UUID: "da7a03f8-ab39-4c54-9ec9-2bd312fe3be1", + Name: "ephemeral-runner-two", + OwnerID: 2, + RepoID: 0, + Ephemeral: true, + TokenHash: "g9oTOFM", + } + + require.NoError(t, CreateRunner(t.Context(), persistentRunnerOne)) + require.NoError(t, CreateRunner(t.Context(), persistentRunnerTwo)) + require.NoError(t, CreateRunner(t.Context(), ephemeralRunnerOne)) + require.NoError(t, CreateRunner(t.Context(), ephemeralRunnerTwo)) + + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerOne.ID}) + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerTwo.ID}) + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerOne.ID}) + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerTwo.ID}) + + require.NoError(t, DeleteEphemeralRunner(t.Context(), persistentRunnerOne.ID)) + require.NoError(t, DeleteEphemeralRunner(t.Context(), ephemeralRunnerOne.ID)) + + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerOne.ID}) + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: persistentRunnerTwo.ID}) + unittest.AssertNotExistsBean(t, &ActionRunner{ID: ephemeralRunnerOne.ID}) + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: ephemeralRunnerTwo.ID}) +} diff --git a/models/actions/task.go b/models/actions/task.go index ed2cab60e8..d056c786c4 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -192,6 +192,15 @@ func HasTaskForRunner(ctx context.Context, runnerID int64) (bool, error) { return db.GetEngine(ctx).Where("runner_id = ?", runnerID).Exist(&ActionTask{}) } +func GetTasksOfJob(ctx context.Context, jobID int64) ([]*ActionTask, error) { + var tasks []*ActionTask + err := db.GetEngine(ctx).Where("job_id=?", jobID).Find(&tasks) + if err != nil { + return nil, fmt.Errorf("cannot fetch tasks of job %d: %w", jobID, err) + } + return tasks, nil +} + func GetTaskByJobAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) { var task ActionTask has, err := db.GetEngine(ctx).Where("job_id=?", jobID).Where("attempt=?", attempt).Get(&task) @@ -497,6 +506,27 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error { return err } +// DeleteTask removes the given task including all its steps and outputs. Removing logs and ephemeral runners is the +// caller's responsibility. +func DeleteTask(ctx context.Context, taskID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + var err error + _, err = db.GetEngine(ctx).Delete(&ActionTaskStep{TaskID: taskID}) + if err != nil { + return fmt.Errorf("unable to delete steps of task %d: %w", taskID, err) + } + _, err = db.GetEngine(ctx).Delete(&ActionTaskOutput{TaskID: taskID}) + if err != nil { + return fmt.Errorf("unable to delete outputs of task %d: %w", taskID, err) + } + _, err = db.GetEngine(ctx).Delete(&ActionTask{ID: taskID}) + if err != nil { + return fmt.Errorf("unable to delete task %d: %w", taskID, err) + } + return nil + }) +} + func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, limit int) ([]*ActionTask, error) { e := db.GetEngine(ctx) diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 9631c43ad4..614f3c1ce9 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -672,6 +672,11 @@ "actions.runs.no_results": "No results matched.", "actions.runs.no_workflows": "There are no workflows yet.", "actions.runs.all_runs_link": "all runs", + "actions.runs.delete.error_could_not_load_run": "Could not load run to delete.", + "actions.runs.delete.error_could_not_delete_run": "Could not delete run.", + "actions.runs.delete.button": "Delete run", + "actions.runs.delete.error": "Could not delete the workflow run.", + "actions.runs.delete.confirm_action": "Do you really want to delete this workflow run?", "actions.workflow.job_parsing_error": "Unable to parse jobs in workflow: %v", "actions.workflow.event_detection_error": "Unable to parse supported events in workflow: %v", "actions.workflow.persistent_incomplete_matrix": "Unable to evaluate `strategy.matrix` of job %[1]s due to a `needs` expression that was invalid. It may reference a job that is not in it's 'needs' list (%[2]s), or an output that doesn't exist on one of those jobs.", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 55ea6762e3..d1488d2fc2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -450,9 +450,16 @@ func reqSelfOrAdmin() func(ctx *context.APIContext) { } } -// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin -func reqAdmin() func(ctx *context.APIContext) { +// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin. If one or more +// unitTypes are given, it also requires that at least one the respective unitTypes is enabled. +func reqAdmin(unitTypes ...unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { + if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool { + return ctx.Repo.Repository.UnitEnabled(ctx, unitType) + }) { + ctx.NotFound() + return + } if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository") return @@ -1245,6 +1252,7 @@ func Routes() *web.Route { m.Group("/runs", func() { m.Get("", repo.ListActionRuns) m.Get("/{run_id}", repo.GetActionRun) + m.Delete("/{run_id}", reqToken(), reqAdmin(unit.TypeActions), repo.DeleteActionRun) m.Get("/{run_id}/jobs", repo.ListActionRunJobs) m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts) }) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 63d9e830b7..b0c65009c6 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1032,6 +1032,68 @@ func GetActionRun(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer)) } +// DeleteActionRun removes a completed workflow run. +func DeleteActionRun(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/runs/{run_id} repository DeleteActionRun + // --- + // summary: Delete a completed workflow run. + // description: > + // Remove a particular workflow run. The workflow run must have completed (succeeded, failed, cancelled) for the + // operation to succeed. Otherwise, an error is returned. + // 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 action run + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // description: Workflow run has been removed + // "400": + // "$ref": "#/responses/error" + // "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.DeleteRun(ctx, run.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteRun", 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/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9769f0f11a..4bd7f12637 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -168,6 +168,7 @@ type ViewRunInfo struct { CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve CanRerun bool `json:"canRerun"` CanDeleteArtifact bool `json:"canDeleteArtifact"` + CanDelete bool `json:"canDelete"` Done bool `json:"done"` Jobs []*ViewJob `json:"jobs"` Commit ViewCommit `json:"commit"` @@ -288,6 +289,7 @@ func getViewResponse(ctx *app_context.Context, req *ViewRequest, runIndex, jobIn resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanRerun = run.CanBeRerun() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.CanDelete = run.Status.IsDone() && ctx.IsUserRepoAdmin() resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json resp.State.Run.Status = run.Status.String() resp.State.Run.PreExecutionError = actions_model.TranslatePreExecutionError(ctx.Locale, run) @@ -600,6 +602,32 @@ func Cancel(ctx *app_context.Context) { ctx.JSON(http.StatusOK, struct{}{}) } +func DeleteRun(ctx *app_context.Context) { + runIndex := ctx.ParamsInt64("run") + + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + log.Debug("Run at index %d in repository %d does not exist", runIndex, ctx.Repo.Repository.ID) + ctx.JSONOK() + return + } + + log.Debug("Could not load run at index %d in repository %d: %s", runIndex, ctx.Repo.Repository.ID, err) + errorMessage := ctx.Locale.Tr("actions.runs.delete.error_could_not_load_run") + ctx.JSON(http.StatusInternalServerError, map[string]any{"message": errorMessage}) + return + } + if err = actions_service.DeleteRun(ctx, run.ID); err != nil { + log.Debug("Could not delete run %d: %s", run.ID, err) + errorMessage := ctx.Locale.Tr("actions.runs.delete.error_could_not_delete_run") + ctx.JSON(http.StatusInternalServerError, map[string]any{"message": errorMessage}) + return + } + + ctx.JSONOK() +} + // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. // Any error will be written to the ctx. // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. diff --git a/routers/web/web.go b/routers/web/web.go index c77c8629b9..c19ecfd411 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1535,6 +1535,7 @@ func registerRoutes(m *web.Route) { }) }) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) + m.Post("/delete", reqRepoAdmin, actions.DeleteRun) m.Get("/artifacts", actions.ArtifactsView) m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView) m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) diff --git a/services/actions/TestDeleteJobsOfRun/action_run.yml b/services/actions/TestDeleteJobsOfRun/action_run.yml new file mode 100644 index 0000000000..e1f2dcc9cf --- /dev/null +++ b/services/actions/TestDeleteJobsOfRun/action_run.yml @@ -0,0 +1,5 @@ +- id: 34901 + status: 2 # StatusFailure + +- id: 34902 + status: 5 # StatusWaiting \ No newline at end of file diff --git a/services/actions/TestDeleteJobsOfRun/action_run_job.yml b/services/actions/TestDeleteJobsOfRun/action_run_job.yml new file mode 100644 index 0000000000..3cc196d9fe --- /dev/null +++ b/services/actions/TestDeleteJobsOfRun/action_run_job.yml @@ -0,0 +1,7 @@ +- id: 47301 + run_id: 34901 + status: 2 # StatusFailure + +- id: 47302 + run_id: 34902 + status: 5 # StatusWaiting diff --git a/services/actions/TestDeleteJobsOfRun/action_task.yml b/services/actions/TestDeleteJobsOfRun/action_task.yml new file mode 100644 index 0000000000..57d0f680b7 --- /dev/null +++ b/services/actions/TestDeleteJobsOfRun/action_task.yml @@ -0,0 +1,11 @@ +- id: 87601 + job_id: 47301 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 2 # StatusFailure + +- id: 87602 + job_id: 47302 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 5 # StatusWaiting diff --git a/services/actions/TestDeleteJobsOfRun/action_task_output.yml b/services/actions/TestDeleteJobsOfRun/action_task_output.yml new file mode 100644 index 0000000000..6244bc9405 --- /dev/null +++ b/services/actions/TestDeleteJobsOfRun/action_task_output.yml @@ -0,0 +1,14 @@ +- id: 90041 + task_id: 87601 + output_key: one + output_value: a + +- id: 90042 + task_id: 87601 + output_key: two + output_value: b + +- id: 90043 + task_id: 87602 + output_key: three + output_value: c diff --git a/services/actions/TestDeleteJobsOfRun/action_task_step.yml b/services/actions/TestDeleteJobsOfRun/action_task_step.yml new file mode 100644 index 0000000000..b438f959ba --- /dev/null +++ b/services/actions/TestDeleteJobsOfRun/action_task_step.yml @@ -0,0 +1,9 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 1 # StatusSuccess + +- id: 98802 + name: echo OK + task_id: 87602 + status: 6 # StatusRunning diff --git a/services/actions/TestDeleteRun/action_artifact.yml b/services/actions/TestDeleteRun/action_artifact.yml new file mode 100644 index 0000000000..73697d9d69 --- /dev/null +++ b/services/actions/TestDeleteRun/action_artifact.yml @@ -0,0 +1,9 @@ +- id: 19401 + run_id: 34901 + status: 2 # ArtifactStatusUploadConfirmed +- id: 19402 + run_id: 34901 + status: 1 # ArtifactStatusUploadPending +- id: 19403 + run_id: 34902 + status: 2 # ArtifactStatusUploadConfirmed diff --git a/services/actions/TestDeleteRun/action_run.yml b/services/actions/TestDeleteRun/action_run.yml new file mode 100644 index 0000000000..e1f2dcc9cf --- /dev/null +++ b/services/actions/TestDeleteRun/action_run.yml @@ -0,0 +1,5 @@ +- id: 34901 + status: 2 # StatusFailure + +- id: 34902 + status: 5 # StatusWaiting \ No newline at end of file diff --git a/services/actions/TestDeleteRun/action_run_job.yml b/services/actions/TestDeleteRun/action_run_job.yml new file mode 100644 index 0000000000..3cc196d9fe --- /dev/null +++ b/services/actions/TestDeleteRun/action_run_job.yml @@ -0,0 +1,7 @@ +- id: 47301 + run_id: 34901 + status: 2 # StatusFailure + +- id: 47302 + run_id: 34902 + status: 5 # StatusWaiting diff --git a/services/actions/TestDeleteRun/action_task.yml b/services/actions/TestDeleteRun/action_task.yml new file mode 100644 index 0000000000..57d0f680b7 --- /dev/null +++ b/services/actions/TestDeleteRun/action_task.yml @@ -0,0 +1,11 @@ +- id: 87601 + job_id: 47301 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 2 # StatusFailure + +- id: 87602 + job_id: 47302 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 5 # StatusWaiting diff --git a/services/actions/TestDeleteRun/action_task_output.yml b/services/actions/TestDeleteRun/action_task_output.yml new file mode 100644 index 0000000000..6244bc9405 --- /dev/null +++ b/services/actions/TestDeleteRun/action_task_output.yml @@ -0,0 +1,14 @@ +- id: 90041 + task_id: 87601 + output_key: one + output_value: a + +- id: 90042 + task_id: 87601 + output_key: two + output_value: b + +- id: 90043 + task_id: 87602 + output_key: three + output_value: c diff --git a/services/actions/TestDeleteRun/action_task_step.yml b/services/actions/TestDeleteRun/action_task_step.yml new file mode 100644 index 0000000000..b438f959ba --- /dev/null +++ b/services/actions/TestDeleteRun/action_task_step.yml @@ -0,0 +1,9 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 1 # StatusSuccess + +- id: 98802 + name: echo OK + task_id: 87602 + status: 6 # StatusRunning diff --git a/services/actions/TestDeleteTask/action_runner.yml b/services/actions/TestDeleteTask/action_runner.yml new file mode 100644 index 0000000000..862b715ab6 --- /dev/null +++ b/services/actions/TestDeleteTask/action_runner.yml @@ -0,0 +1,6 @@ +- id: 41601 + uuid: 61a71de6-8127-40c9-a30e-63647e93edad + ephemeral: true +- id: 41602 + uuid: fae04d20-98ae-4b69-8439-213dac373e19 + ephemeral: false \ No newline at end of file diff --git a/services/actions/TestDeleteTask/action_task.yml b/services/actions/TestDeleteTask/action_task.yml new file mode 100644 index 0000000000..f9b979f437 --- /dev/null +++ b/services/actions/TestDeleteTask/action_task.yml @@ -0,0 +1,16 @@ +- id: 87601 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 1 # StatusSuccess + runner_id: 41601 + +- id: 87602 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 6 # StatusRunning + +- id: 87603 + log_filename: somebody/somerepo/15631/87603.log.zst + log_in_storage: false + status: 2 # StatusFailure + runner_id: 41602 diff --git a/services/actions/TestDeleteTask/action_task_output.yml b/services/actions/TestDeleteTask/action_task_output.yml new file mode 100644 index 0000000000..6244bc9405 --- /dev/null +++ b/services/actions/TestDeleteTask/action_task_output.yml @@ -0,0 +1,14 @@ +- id: 90041 + task_id: 87601 + output_key: one + output_value: a + +- id: 90042 + task_id: 87601 + output_key: two + output_value: b + +- id: 90043 + task_id: 87602 + output_key: three + output_value: c diff --git a/services/actions/TestDeleteTask/action_task_step.yml b/services/actions/TestDeleteTask/action_task_step.yml new file mode 100644 index 0000000000..8050e4002d --- /dev/null +++ b/services/actions/TestDeleteTask/action_task_step.yml @@ -0,0 +1,14 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 1 # StatusSuccess + +- id: 98802 + name: echo OK + task_id: 87602 + status: 6 # StatusRunning + +- id: 98803 + name: echo OK + task_id: 87603 + status: 2 # StatusFailure diff --git a/services/actions/job.go b/services/actions/job.go new file mode 100644 index 0000000000..6ee8260ac7 --- /dev/null +++ b/services/actions/job.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Forgejo Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "context" + "fmt" + + "forgejo.org/models/actions" + "forgejo.org/models/db" +) + +// deleteJobsOfRun removes all jobs that belong to the given run, including its associated tasks. Each job has to be +// completed for the operation to succeed. +func deleteJobsOfRun(ctx context.Context, runID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions.GetRunJobsByRunID(ctx, runID) + if err != nil { + return fmt.Errorf("unable to load jobs of run %d: %w", runID, err) + } + + for _, job := range jobs { + if !job.Status.IsDone() { + return fmt.Errorf("unable to delete job %d because it has not completed yet", job.ID) + } + + tasks, err := actions.GetTasksOfJob(ctx, job.ID) + if err != nil { + return err + } + for _, task := range tasks { + err = deleteTask(ctx, task.ID) + if err != nil { + return err + } + } + + err = actions.DeleteJob(ctx, job.ID) + if err != nil { + return fmt.Errorf("unable to delete job %d of run %d: %w", job.ID, job.RunID, err) + } + } + + return nil + }) +} diff --git a/services/actions/job_test.go b/services/actions/job_test.go new file mode 100644 index 0000000000..b669bce301 --- /dev/null +++ b/services/actions/job_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Forgejo Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "testing" + + actions_model "forgejo.org/models/actions" + "forgejo.org/models/unittest" + + "github.com/stretchr/testify/require" +) + +func TestDeleteJobsOfRun(t *testing.T) { + t.Run("Deletes completed job", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteJobsOfRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47301, RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1) + + require.NoError(t, deleteJobsOfRun(t.Context(), run.ID)) + + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47302}) + unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 0) + }) + + t.Run("Error if job has not completed", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteJobsOfRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 47302, RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1) + + err := deleteJobsOfRun(t.Context(), run.ID) + require.ErrorContains(t, err, "unable to delete job 47302 because it has not completed yet") + + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertCount(t, &actions_model.ActionTask{JobID: job.ID}, 1) + }) +} diff --git a/services/actions/run.go b/services/actions/run.go index 891d9d8f03..d0e874ff54 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -5,6 +5,7 @@ package actions import ( "context" + "fmt" "slices" "strings" @@ -203,3 +204,30 @@ func checkJobRunsOnStaticMatrixError(ctx context.Context, job *actions_model.Act return true, nil } + +// DeleteRun removes a particular run including all associated artifacts, jobs, tasks, and logs. The run has to be +// completed for the operation to succeed. +func DeleteRun(ctx context.Context, runID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + run, err := actions_model.GetRunByID(ctx, runID) + if err != nil { + return fmt.Errorf("unable to load run %d: %w", runID, err) + } + + if !run.Status.IsDone() { + return fmt.Errorf("cannot delete run %d because it has not completed yet", run.ID) + } + + err = actions_model.SetArtifactsOfRunDeleted(ctx, runID) + if err != nil { + return fmt.Errorf("unable to delete artifacts of run %d: %w", run.ID, err) + } + + err = deleteJobsOfRun(ctx, run.ID) + if err != nil { + return fmt.Errorf("unable to delete jobs of run %d: %w", run.ID, err) + } + + return actions_model.DeleteRun(ctx, run.ID) + }) +} diff --git a/services/actions/run_test.go b/services/actions/run_test.go index 61e580aed1..19cad919db 100644 --- a/services/actions/run_test.go +++ b/services/actions/run_test.go @@ -160,3 +160,42 @@ func TestActions_consistencyCheckRun(t *testing.T) { }) } } + +func TestDeleteRun(t *testing.T) { + t.Run("Removes run and its dependencies", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2) + + require.NoError(t, DeleteRun(t.Context(), run.ID)) + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID}) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{ + RunID: run.ID, + Status: int64(actions_model.ArtifactStatusPendingDeletion), + }, 2) + }) + + t.Run("Error if run not done", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 1) + + err := DeleteRun(t.Context(), run.ID) + require.ErrorContains(t, err, "cannot delete run 34902 because it has not completed yet") + + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{ + RunID: run.ID, + Status: int64(actions_model.ArtifactStatusUploadConfirmed), + }, 1) + }) +} diff --git a/services/actions/task.go b/services/actions/task.go index f2525e1b3a..a08403ad08 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -367,3 +367,40 @@ func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp { } return timeutil.TimeStamp(timestamp.AsTime().Unix()) } + +// deleteTask removes the given task with all associated steps, outputs, logs, and ephemeral runners, if any. For +// deleteTask to succeed, it must have completed. If it has not, an error is returned. If the given task does not exist, +// nothing happens. +func deleteTask(ctx context.Context, taskID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil + } + return fmt.Errorf("unable to load task %d: %w", taskID, err) + } + + if !task.Status.IsDone() { + return fmt.Errorf("unable to remove task %d because it has not completed yet", taskID) + } + + err = actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return fmt.Errorf("unable to remove logs of task %d: %w", taskID, err) + } + + // Whether an ephemeral runner has been used is determined based on whether it is assigned to a task. + // Consequently, ephemeral runners have to be cleaned up before any task can be removed. + err = actions_model.DeleteEphemeralRunner(ctx, task.RunnerID) + if err != nil { + return fmt.Errorf("unable to cleanup ephemeral runners before removing task %d: %w", taskID, err) + } + err = actions_model.DeleteTask(ctx, task.ID) + if err != nil { + return fmt.Errorf("unable to remove task %d: %w", task.ID, err) + } + + return nil + }) +} diff --git a/services/actions/task_test.go b/services/actions/task_test.go index 0c2630f3a6..375359b5e8 100644 --- a/services/actions/task_test.go +++ b/services/actions/task_test.go @@ -6,8 +6,12 @@ import ( actions_model "forgejo.org/models/actions" repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" "forgejo.org/models/user" + "forgejo.org/modules/actions" + runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -97,3 +101,90 @@ jobs: require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue()) }) } + +func TestDeleteTask(t *testing.T) { + t.Run("Task removed with logs and ephemeral runner", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteTask")() + require.NoError(t, unittest.PrepareTestDatabase()) + + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87601}) + runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 2) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1) + + _, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, []*runnerv1.LogRow{{Content: "OK"}}) + require.NoError(t, err) + + logExists, err := actions.ExistsLogs(t.Context(), task.LogFilename) + require.NoError(t, err) + assert.True(t, logExists) + + require.NoError(t, deleteTask(t.Context(), task.ID)) + + logExists, err = actions.ExistsLogs(t.Context(), task.LogFilename) + require.NoError(t, err) + assert.False(t, logExists) + + unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 0) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID}) + + // Verify that other tasks have been left alone. + otherTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87602}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: otherTask.ID}, 1) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: otherTask.ID}, 1) + }) + + t.Run("Task removed and persistent runner kept", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteTask")() + require.NoError(t, unittest.PrepareTestDatabase()) + + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87603}) + runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41602}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1) + + _, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, []*runnerv1.LogRow{{Content: "OK"}}) + require.NoError(t, err) + + logExists, err := actions.ExistsLogs(t.Context(), task.LogFilename) + require.NoError(t, err) + assert.True(t, logExists) + + require.NoError(t, deleteTask(t.Context(), task.ID)) + + logExists, err = actions.ExistsLogs(t.Context(), task.LogFilename) + require.NoError(t, err) + assert.False(t, logExists) + + unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 0) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 0) + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: runner.ID}) + }) + + t.Run("No error if task does not exist", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: 87601}) + require.NoError(t, deleteTask(t.Context(), 87601)) + }) + + t.Run("Error if task is not done", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestDeleteTask")() + require.NoError(t, unittest.PrepareTestDatabase()) + + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 87602}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 1) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1) + + err := deleteTask(t.Context(), task.ID) + require.ErrorContains(t, err, "unable to remove task 87602 because it has not completed yet") + + // Verify nothing has been deleted. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID}) + unittest.AssertCount(t, &actions_model.ActionTaskOutput{TaskID: task.ID}, 1) + unittest.AssertCount(t, &actions_model.ActionTaskStep{TaskID: task.ID}, 1) + }) +} diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index 04aced83d1..e005ee412d 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -17,6 +17,9 @@ data-locale-cancel="{{ctx.Locale.Tr "cancel"}}" data-locale-rerun="{{ctx.Locale.Tr "rerun"}}" data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}" + data-locale-delete="{{ctx.Locale.Tr "actions.runs.delete.button"}}" + data-locale-delete-error="{{ctx.Locale.Tr "actions.runs.delete.error"}}" + data-locale-confirm-delete="{{ctx.Locale.Tr "actions.runs.delete.confirm_action"}}" data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}" data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}" data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}" diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e0afb22d54..a0e6cc8e4a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6089,6 +6089,55 @@ "$ref": "#/responses/notFound" } } + }, + "delete": { + "description": "Remove a particular workflow run. The workflow run must have completed (succeeded, failed, cancelled) for the operation to succeed. Otherwise, an error is returned.\n", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a completed workflow run.", + "operationId": "DeleteActionRun", + "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 action run", + "name": "run_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Workflow run has been removed" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } } }, "/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts": { diff --git a/tests/integration/actions_run_test.go b/tests/integration/actions_run_test.go new file mode 100644 index 0000000000..64210f1a71 --- /dev/null +++ b/tests/integration/actions_run_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "fmt" + "net/http" + "testing" + + actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionRunDeletion(t *testing.T) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + repo62 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 62, OwnerID: user2.ID}) + + sessUser2 := loginUser(t, user2.Name) + sessUser5 := loginUser(t, user5.Name) + + t.Run("Run removed", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")() + defer tests.PrepareTestEnv(t)() + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2) + runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601}) + + requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index) + request := NewRequest(t, "POST", requestURL) + response := sessUser2.MakeRequest(t, request, http.StatusOK) + assert.JSONEq(t, `{"ok":true}`, response.Body.String()) + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID}) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{ + RunID: run.ID, + Status: int64(actions_model.ArtifactStatusPendingDeletion), + }, 2) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID}) + }) + + t.Run("Error if run has not completed", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")() + defer tests.PrepareTestEnv(t)() + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34902}) + + requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index) + request := NewRequest(t, "POST", requestURL) + response := sessUser2.MakeRequest(t, request, http.StatusInternalServerError) + assert.JSONEq(t, `{"message":"Could not delete run."}`, response.Body.String()) + + // Verify that the run still exists. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + }) + + t.Run("Nothing happens if run does not belong to repository", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")() + defer tests.PrepareTestEnv(t)() + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901}) + + requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo62.FullName(), run.Index) + request := NewRequest(t, "POST", requestURL) + response := sessUser2.MakeRequest(t, request, http.StatusOK) + assert.JSONEq(t, `{"ok":true}`, response.Body.String()) + + // Verify that the run still exists. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + }) + + t.Run("Nothing happens if run does not exist", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{Index: 260871}) + + requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), 260871) + request := NewRequest(t, "POST", requestURL) + response := sessUser2.MakeRequest(t, request, http.StatusOK) + assert.JSONEq(t, `{"ok":true}`, response.Body.String()) + }) + + t.Run("Removal requires ownership", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionRunDeletion")() + defer tests.PrepareTestEnv(t)() + + isCollaborator, err := repo_model.IsCollaborator(t.Context(), repo1.ID, user5.ID) + require.NoError(t, err) + require.True(t, isCollaborator) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 34901}) + + requestURL := fmt.Sprintf("/%s/actions/runs/%d/delete", repo1.FullName(), run.Index) + request := NewRequest(t, "POST", requestURL) + MakeRequest(t, request, http.StatusNotFound) + + // Verify that run still exists + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + + request = NewRequest(t, "POST", requestURL) + sessUser5.MakeRequest(t, request, http.StatusNotFound) + + // Verify that run still exists + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + + request = NewRequest(t, "POST", requestURL) + sessUser2.MakeRequest(t, request, http.StatusOK) + + // Verify that run no longer exists + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID}) + }) +} diff --git a/tests/integration/actions_view_test.go b/tests/integration/actions_view_test.go index 5275198eff..16a79581dc 100644 --- a/tests/integration/actions_view_test.go +++ b/tests/integration/actions_view_test.go @@ -14,6 +14,7 @@ import ( "testing" actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" unit_model "forgejo.org/models/unit" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" @@ -147,7 +148,7 @@ func TestActionViewsView(t *testing.T) { runIndex: 187, jobIndex: 0, attempt: 1, - expectedJSON: "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/187\",\"title\":\"update actions\",\"titleHTML\":\"update actions\",\"status\":\"success\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit c2d72f5484 pushed by user1\",\"done\":true,\"jobs\":[{\"id\":192,\"name\":\"job_2\",\"status\":\"success\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Success\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":3,\"time_since_started_html\":\"_time_\",\"status\":\"running\",\"status_diagnostics\":[\"Running\"]},{\"number\":2,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]},{\"number\":1,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", + expectedJSON: "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/187\",\"title\":\"update actions\",\"titleHTML\":\"update actions\",\"status\":\"success\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit c2d72f5484 pushed by user1\",\"done\":true,\"jobs\":[{\"id\":192,\"name\":\"job_2\",\"status\":\"success\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Success\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":3,\"time_since_started_html\":\"_time_\",\"status\":\"running\",\"status_diagnostics\":[\"Running\"]},{\"number\":2,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]},{\"number\":1,\"time_since_started_html\":\"_time_\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", expectedArtifacts: "{\"artifacts\":[{\"name\":\"multi-file-download\",\"size\":2048,\"status\":\"completed\"}]}\n", }, { @@ -156,7 +157,7 @@ func TestActionViewsView(t *testing.T) { runIndex: 209, jobIndex: 0, attempt: 1, - expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/209\",\"title\":\"A scheduled workflow\",\"titleHTML\":\"A scheduled workflow\",\"status\":\"waiting\",\"description\":\"Scheduled run of commit \\u003ca href=\\\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\\\"\\u003e64357baca8\\u003c/a\\u003e\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2153,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"64357baca8\",\"link\":\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\",\"pusher\":{\"displayName\":\"forgejo-actions\",\"link\":\"/forgejo-actions\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following labels: debian, gpu\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", + expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/209\",\"title\":\"A scheduled workflow\",\"titleHTML\":\"A scheduled workflow\",\"status\":\"waiting\",\"description\":\"Scheduled run of commit \\u003ca href=\\\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\\\"\\u003e64357baca8\\u003c/a\\u003e\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2153,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"64357baca8\",\"link\":\"/user5/repo4/commit/64357baca84bfff631e7dfae5a3433b26d005646\",\"pusher\":{\"displayName\":\"forgejo-actions\",\"link\":\"/forgejo-actions\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following labels: debian, gpu\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"success\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"success\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"success\",\"status_diagnostics\":[\"Success\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", expectedArtifacts: "{\"artifacts\":[]}\n", }, { @@ -165,7 +166,7 @@ func TestActionViewsView(t *testing.T) { runIndex: 210, jobIndex: 0, attempt: 1, - expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/210\",\"title\":\"A triggered run\",\"titleHTML\":\"A triggered run\",\"status\":\"waiting\",\"description\":\"Run of commit \\u003ca href=\\\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\\\"\\u003ef4100ac141\\u003c/a\\u003e triggered by \\u003ca href=\\\"/user29\\\"\\u003euser29\\u003c/a\\u003e\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2154,\"name\":\"mirror\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"f4100ac141\",\"link\":\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\",\"pusher\":{\"displayName\":\"user29\",\"link\":\"/user29\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"mirror\",\"details\":[\"Waiting for a runner with the following label: windows\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"running\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"waiting\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"waiting\",\"status_diagnostics\":[\"Waiting for a runner with the following label: windows\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", + expectedJSON: "{\"state\":{\"run\":{\"link\":\"/user5/repo4/actions/runs/210\",\"title\":\"A triggered run\",\"titleHTML\":\"A triggered run\",\"status\":\"waiting\",\"description\":\"Run of commit \\u003ca href=\\\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\\\"\\u003ef4100ac141\\u003c/a\\u003e triggered by \\u003ca href=\\\"/user29\\\"\\u003euser29\\u003c/a\\u003e\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"done\":false,\"jobs\":[{\"id\":2154,\"name\":\"mirror\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"f4100ac141\",\"link\":\"/user5/repo4/commit/f4100ac14112a3740490afb22b07b69b0b5d4e8b\",\"pusher\":{\"displayName\":\"user29\",\"link\":\"/user29\"},\"branch\":{\"name\":\"master\",\"link\":\"/user5/repo4/src/branch/master\",\"isDeleted\":false}},\"preExecutionError\":\"\"},\"currentJob\":{\"title\":\"mirror\",\"details\":[\"Waiting for a runner with the following label: windows\"],\"steps\":[{\"summary\":\"Set up job\",\"duration\":\"_duration_\",\"status\":\"running\"},{\"summary\":\"Complete job\",\"duration\":\"_duration_\",\"status\":\"waiting\"}],\"allAttempts\":[{\"number\":1,\"time_since_started_html\":\"-\",\"status\":\"waiting\",\"status_diagnostics\":[\"Waiting for a runner with the following label: windows\"]}]}},\"logs\":{\"stepsLog\":[]}}\n", expectedArtifacts: "{\"artifacts\":[]}\n", }, } @@ -229,7 +230,7 @@ func TestActionViewsViewAttemptOutOfRange(t *testing.T) { re = regexp.MustCompile(pattern) actualClean = re.ReplaceAllString(actualClean, `"time_since_started_html":"_time_"`) - return assert.JSONEq(t, "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/190\",\"title\":\"job output\",\"titleHTML\":\"job output\",\"status\":\"success\",\"canCancel\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit c2d72f5484 pushed by user1\",\"done\":false,\"jobs\":[{\"id\":396,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"test\",\"link\":\"/user5/repo4/src/branch/test\",\"isDeleted\":true}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following label: fedora\"],\"steps\":[],\"allAttempts\":null}},\"logs\":{\"stepsLog\":[]}}\n", actualClean) + return assert.JSONEq(t, "{\"state\":{\"run\":{\"preExecutionError\":\"\",\"link\":\"/user5/repo4/actions/runs/190\",\"title\":\"job output\",\"titleHTML\":\"job output\",\"status\":\"success\",\"canCancel\":false,\"canDelete\":false,\"canApprove\":false,\"canRerun\":false,\"canDeleteArtifact\":false,\"description\":\"Commit c2d72f5484 pushed by user1\",\"done\":false,\"jobs\":[{\"id\":396,\"name\":\"job_2\",\"status\":\"waiting\",\"canRerun\":false,\"duration\":\"_duration_\"}],\"commit\":{\"localeWorkflow\":\"Workflow\",\"localeAllRuns\":\"all runs\",\"shortSHA\":\"c2d72f5484\",\"link\":\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\",\"pusher\":{\"displayName\":\"user1\",\"link\":\"/user1\"},\"branch\":{\"name\":\"test\",\"link\":\"/user5/repo4/src/branch/test\",\"isDeleted\":true}}},\"currentJob\":{\"title\":\"job_2\",\"details\":[\"Waiting for a runner with the following label: fedora\"],\"steps\":[],\"allAttempts\":null}},\"logs\":{\"stepsLog\":[]}}\n", actualClean) }) htmlDoc.AssertAttrEqual(t, selector, "data-initial-artifacts-response", "{\"artifacts\":[]}\n") } @@ -258,3 +259,62 @@ func TestActionTabAccessibleFromRepo(t *testing.T) { return true }) } + +func TestActionViewRunDeletion(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionViewRunDeletion")() + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user2.ID}) + + sessionUser2 := loginUser(t, user2.Name) + sessionUser5 := loginUser(t, user5.Name) + + isCollaborator, err := repo_model.IsCollaborator(t.Context(), repo1.ID, user5.ID) + require.NoError(t, err) + require.True(t, isCollaborator) + + testCases := []struct { + name string + sess *TestSession + requestURL string + canDelete bool + }{ + { + name: "Repo owner can delete completed run", + sess: sessionUser2, + requestURL: fmt.Sprintf("/%s/actions/runs/3161/jobs/0/attempt/1", repo1.FullName()), + canDelete: true, + }, + { + name: "Collaborator cannot delete completed run", + sess: sessionUser5, + requestURL: fmt.Sprintf("/%s/actions/runs/3161/jobs/0/attempt/1", repo1.FullName()), + canDelete: false, + }, + { + name: "Repo owner cannot delete running run", + sess: sessionUser2, + requestURL: fmt.Sprintf("/%s/actions/runs/3162/jobs/0/attempt/1", repo1.FullName()), + canDelete: false, + }, + { + name: "Collaborator cannot delete running run", + sess: sessionUser5, + requestURL: fmt.Sprintf("/%s/actions/runs/3162/jobs/0/attempt/1", repo1.FullName()), + canDelete: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + req := NewRequest(t, "GET", testCase.requestURL) + resp := testCase.sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertAttrPredicate(t, "#repo-action-view", "data-initial-post-response", func(actual string) bool { + return assert.Contains(t, actual, fmt.Sprintf(`"canDelete":%t`, testCase.canDelete)) + }) + }) + } +} diff --git a/tests/integration/api_repo_actions_test.go b/tests/integration/api_repo_actions_test.go index 605d55c469..1331ba0959 100644 --- a/tests/integration/api_repo_actions_test.go +++ b/tests/integration/api_repo_actions_test.go @@ -671,6 +671,121 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) { }) } +func TestActionsAPIDeleteActionRun(t *testing.T) { + t.Run("Run removed", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")() + 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: 34901}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{RunID: run.ID}, 2) + runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 41601}) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID) + request := NewRequest(t, "DELETE", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: run.ID}) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: job.ID}) + unittest.AssertCount(t, &actions_model.ActionArtifact{ + RunID: run.ID, + Status: int64(actions_model.ArtifactStatusPendingDeletion), + }, 2) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runner.ID}) + }) + + t.Run("Error if run has not completed", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")() + 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: 34902}) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID) + request := NewRequest(t, "DELETE", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusInternalServerError) + + // Verify that the run still exists. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + }) + + t.Run("Not found if run does not belong to repository", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")() + 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: 34901}) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo62.FullName(), run.ID) + request := NewRequest(t, "DELETE", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNotFound) + + // Verify that the run still exists. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + }) + + t.Run("No 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", repo1.FullName()) + request := NewRequest(t, "DELETE", requestURL) + request.AddTokenAuth(writeToken) + MakeRequest(t, request, http.StatusNotFound) + }) + + t.Run("Run removal requires write token", func(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestActionsAPIDeleteActionRun")() + 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: 34901}) + + requestURL := fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d", repo1.FullName(), run.ID) + request := NewRequest(t, "DELETE", 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 still exists. + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + }) +} + func TestActionsAPIListActionRunJobs(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_artifact.yml b/tests/integration/fixtures/TestActionRunDeletion/action_artifact.yml new file mode 100644 index 0000000000..73697d9d69 --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_artifact.yml @@ -0,0 +1,9 @@ +- id: 19401 + run_id: 34901 + status: 2 # ArtifactStatusUploadConfirmed +- id: 19402 + run_id: 34901 + status: 1 # ArtifactStatusUploadPending +- id: 19403 + run_id: 34902 + status: 2 # ArtifactStatusUploadConfirmed diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_run.yml b/tests/integration/fixtures/TestActionRunDeletion/action_run.yml new file mode 100644 index 0000000000..b097261695 --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_run.yml @@ -0,0 +1,11 @@ +- id: 34901 + repo_id: 1 + owner_id: 2 + index: 3161 + status: 2 # StatusFailure + +- id: 34902 + repo_id: 1 + owner_id: 2 + index: 3162 + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_run_job.yml b/tests/integration/fixtures/TestActionRunDeletion/action_run_job.yml new file mode 100644 index 0000000000..3cc196d9fe --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_run_job.yml @@ -0,0 +1,7 @@ +- id: 47301 + run_id: 34901 + status: 2 # StatusFailure + +- id: 47302 + run_id: 34902 + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_runner.yml b/tests/integration/fixtures/TestActionRunDeletion/action_runner.yml new file mode 100644 index 0000000000..5462b8e0ec --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_runner.yml @@ -0,0 +1,5 @@ +- id: 41601 + owner_id: 0 + repo_id: 1 + uuid: 61a71de6-8127-40c9-a30e-63647e93edad + ephemeral: true \ No newline at end of file diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_task.yml b/tests/integration/fixtures/TestActionRunDeletion/action_task.yml new file mode 100644 index 0000000000..746486e834 --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_task.yml @@ -0,0 +1,12 @@ +- id: 87601 + job_id: 47301 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 2 # StatusFailure + runner_id: 41601 + +- id: 87602 + job_id: 47302 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_task_output.yml b/tests/integration/fixtures/TestActionRunDeletion/action_task_output.yml new file mode 100644 index 0000000000..6244bc9405 --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_task_output.yml @@ -0,0 +1,14 @@ +- id: 90041 + task_id: 87601 + output_key: one + output_value: a + +- id: 90042 + task_id: 87601 + output_key: two + output_value: b + +- id: 90043 + task_id: 87602 + output_key: three + output_value: c diff --git a/tests/integration/fixtures/TestActionRunDeletion/action_task_step.yml b/tests/integration/fixtures/TestActionRunDeletion/action_task_step.yml new file mode 100644 index 0000000000..e001b9f3a9 --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/action_task_step.yml @@ -0,0 +1,9 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 2 # StatusFailure + +- id: 98802 + name: echo OK + task_id: 87602 + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionRunDeletion/collaboration.yml b/tests/integration/fixtures/TestActionRunDeletion/collaboration.yml new file mode 100644 index 0000000000..117d26490b --- /dev/null +++ b/tests/integration/fixtures/TestActionRunDeletion/collaboration.yml @@ -0,0 +1,4 @@ +- id: 393701 + repo_id: 1 + user_id: 5 + mode: 2 # write diff --git a/tests/integration/fixtures/TestActionViewRunDeletion/action_run.yml b/tests/integration/fixtures/TestActionViewRunDeletion/action_run.yml new file mode 100644 index 0000000000..325aeb38c2 --- /dev/null +++ b/tests/integration/fixtures/TestActionViewRunDeletion/action_run.yml @@ -0,0 +1,11 @@ +- id: 34901 + repo_id: 1 + owner_id: 2 + index: 3161 + status: 1 # StatusSuccess + +- id: 34902 + repo_id: 1 + owner_id: 2 + index: 3162 + status: 6 # StatusRunning diff --git a/tests/integration/fixtures/TestActionViewRunDeletion/action_run_job.yml b/tests/integration/fixtures/TestActionViewRunDeletion/action_run_job.yml new file mode 100644 index 0000000000..79e5cd1502 --- /dev/null +++ b/tests/integration/fixtures/TestActionViewRunDeletion/action_run_job.yml @@ -0,0 +1,7 @@ +- id: 47301 + run_id: 34901 + status: 1 # StatusSuccess + +- id: 47302 + run_id: 34902 + status: 6 # StatusRunning diff --git a/tests/integration/fixtures/TestActionViewRunDeletion/action_task.yml b/tests/integration/fixtures/TestActionViewRunDeletion/action_task.yml new file mode 100644 index 0000000000..1a86ffd28e --- /dev/null +++ b/tests/integration/fixtures/TestActionViewRunDeletion/action_task.yml @@ -0,0 +1,11 @@ +- id: 87601 + job_id: 47301 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 1 # StatusSuccess + +- id: 87602 + job_id: 47302 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 6 # StatusRunning diff --git a/tests/integration/fixtures/TestActionViewRunDeletion/action_task_step.yml b/tests/integration/fixtures/TestActionViewRunDeletion/action_task_step.yml new file mode 100644 index 0000000000..b438f959ba --- /dev/null +++ b/tests/integration/fixtures/TestActionViewRunDeletion/action_task_step.yml @@ -0,0 +1,9 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 1 # StatusSuccess + +- id: 98802 + name: echo OK + task_id: 87602 + status: 6 # StatusRunning diff --git a/tests/integration/fixtures/TestActionViewRunDeletion/collaboration.yml b/tests/integration/fixtures/TestActionViewRunDeletion/collaboration.yml new file mode 100644 index 0000000000..117d26490b --- /dev/null +++ b/tests/integration/fixtures/TestActionViewRunDeletion/collaboration.yml @@ -0,0 +1,4 @@ +- id: 393701 + repo_id: 1 + user_id: 5 + mode: 2 # write diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_artifact.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_artifact.yml new file mode 100644 index 0000000000..73697d9d69 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_artifact.yml @@ -0,0 +1,9 @@ +- id: 19401 + run_id: 34901 + status: 2 # ArtifactStatusUploadConfirmed +- id: 19402 + run_id: 34901 + status: 1 # ArtifactStatusUploadPending +- id: 19403 + run_id: 34902 + status: 2 # ArtifactStatusUploadConfirmed diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run.yml new file mode 100644 index 0000000000..c388610d33 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run.yml @@ -0,0 +1,9 @@ +- id: 34901 + repo_id: 1 + owner_id: 2 + status: 2 # StatusFailure + +- id: 34902 + repo_id: 1 + owner_id: 2 + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run_job.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run_job.yml new file mode 100644 index 0000000000..3cc196d9fe --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_run_job.yml @@ -0,0 +1,7 @@ +- id: 47301 + run_id: 34901 + status: 2 # StatusFailure + +- id: 47302 + run_id: 34902 + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_runner.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_runner.yml new file mode 100644 index 0000000000..5462b8e0ec --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_runner.yml @@ -0,0 +1,5 @@ +- id: 41601 + owner_id: 0 + repo_id: 1 + uuid: 61a71de6-8127-40c9-a30e-63647e93edad + ephemeral: true \ No newline at end of file diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task.yml new file mode 100644 index 0000000000..746486e834 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task.yml @@ -0,0 +1,12 @@ +- id: 87601 + job_id: 47301 + log_filename: somebody/somerepo/15631/87601.log.zst + log_in_storage: false + status: 2 # StatusFailure + runner_id: 41601 + +- id: 87602 + job_id: 47302 + log_filename: somebody/somerepo/15632/87602.log.zst + log_in_storage: false + status: 5 # StatusWaiting diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_output.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_output.yml new file mode 100644 index 0000000000..6244bc9405 --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_output.yml @@ -0,0 +1,14 @@ +- id: 90041 + task_id: 87601 + output_key: one + output_value: a + +- id: 90042 + task_id: 87601 + output_key: two + output_value: b + +- id: 90043 + task_id: 87602 + output_key: three + output_value: c diff --git a/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_step.yml b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_step.yml new file mode 100644 index 0000000000..b438f959ba --- /dev/null +++ b/tests/integration/fixtures/TestActionsAPIDeleteActionRun/action_task_step.yml @@ -0,0 +1,9 @@ +- id: 98801 + name: echo OK + task_id: 87601 + status: 1 # StatusSuccess + +- id: 98802 + name: echo OK + task_id: 87602 + status: 6 # StatusRunning diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js index fb6af25dd0..574be2992b 100644 --- a/web_src/js/components/RepoActionView.test.js +++ b/web_src/js/components/RepoActionView.test.js @@ -6,6 +6,8 @@ const testLocale = { approve: 'Locale Approve', cancel: 'Locale Cancel', rerun: 'Locale Re-run', + delete: 'Locale Delete', + confirmDelete: '', artifactsTitle: 'artifactTitleHere', areYouSure: '', confirmDeleteArtifact: '', @@ -168,6 +170,7 @@ function configureForMultipleAttemptTests({viewHistorical}) { canApprove: true, canCancel: true, canRerun: true, + canDelete: false, status: 'success', commit: { pusher: {}, diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index bd7404ddaf..5550d84c85 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -4,6 +4,7 @@ import ActionRunStatus from './ActionRunStatus.vue'; import ActionJobStepList from './ActionJobStepList.vue'; import {toggleElem} from '../utils/dom.js'; import {GET, POST, DELETE} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; export default { name: 'RepoActionView', @@ -86,6 +87,7 @@ export default { canCancel: false, canApprove: false, canRerun: false, + canDelete: false, done: false, preExecutionError: '', jobs: [ @@ -237,6 +239,21 @@ export default { } }, + async deleteRun() { + if (!window.confirm(this.locale.confirmDelete)) { + return; + } + + const response = await POST(`${this.run.link}/delete`); + + if (response.ok) { + window.location.href = this.workflowURL; + return; + } + + showErrorToast(this.locale.deleteError, {duration: 5000}); + }, + // cancel a run cancelRun() { POST(`${this.run.link}/cancel`); @@ -474,6 +491,9 @@ export default { {{ locale.approve }}
+ diff --git a/web_src/js/features/repo-action-view.ts b/web_src/js/features/repo-action-view.ts index 946927bf06..34cce9edc4 100644 --- a/web_src/js/features/repo-action-view.ts +++ b/web_src/js/features/repo-action-view.ts @@ -29,6 +29,9 @@ export async function initRepositoryActionView() { approve: el.getAttribute('data-locale-approve'), cancel: el.getAttribute('data-locale-cancel'), rerun: el.getAttribute('data-locale-rerun'), + delete: el.getAttribute('data-locale-delete'), + confirmDelete: el.getAttribute('data-locale-confirm-delete'), + deleteError: el.getAttribute('data-locale-delete-error'), artifactsTitle: el.getAttribute('data-locale-artifacts-title'), areYouSure: el.getAttribute('data-locale-are-you-sure'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),