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/<pull request number>.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.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/12478): <!--number 12478 --><!--line 0 --><!--description bWFrZSBpdCBwb3NzaWJsZSB0byByZW1vdmUgd29ya2Zsb3cgcnVucw==-->make it possible to remove workflow runs<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12478
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
Andreas Ahlenstorf 2026-05-11 16:02:36 +02:00 committed by Mathieu Fenniak
parent 2d5dd62cf3
commit 03312e4f46
60 changed files with 1221 additions and 6 deletions

View file

@ -222,6 +222,15 @@ func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
return err 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 // aggregatedArtifactConds returns the common WHERE clause used by aggregated
// artifact queries: restrict to visible statuses and apply the caller's filters. // 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. // The Status field on opts is ignored — visibility is fixed to UploadConfirmed/Expired.

View file

@ -612,4 +612,11 @@ func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns
return run, columns, nil 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 type ActionRunIndex db.ResourceIndex

View file

@ -365,3 +365,10 @@ func (job *ActionRunJob) AllNeedsExist(allExistingJobIDs container.Set[string])
return unknownJobIDs, len(unknownJobIDs) == 0 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
}

View file

@ -396,6 +396,13 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) {
return res.RowsAffected() 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 { func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error {
log.Info("Doing: DeleteOfflineRunners") log.Info("Doing: DeleteOfflineRunners")

View file

@ -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})
}

View file

@ -192,6 +192,15 @@ func HasTaskForRunner(ctx context.Context, runnerID int64) (bool, error) {
return db.GetEngine(ctx).Where("runner_id = ?", runnerID).Exist(&ActionTask{}) 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) { func GetTaskByJobAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) {
var task ActionTask var task ActionTask
has, err := db.GetEngine(ctx).Where("job_id=?", jobID).Where("attempt=?", attempt).Get(&task) 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 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) { func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, limit int) ([]*ActionTask, error) {
e := db.GetEngine(ctx) e := db.GetEngine(ctx)

View file

@ -672,6 +672,11 @@
"actions.runs.no_results": "No results matched.", "actions.runs.no_results": "No results matched.",
"actions.runs.no_workflows": "There are no workflows yet.", "actions.runs.no_workflows": "There are no workflows yet.",
"actions.runs.all_runs_link": "all runs", "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.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.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.", "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.",

View file

@ -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 // reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin. If one or more
func reqAdmin() func(ctx *context.APIContext) { // 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) { 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() { if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() {
ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository") ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository")
return return
@ -1245,6 +1252,7 @@ func Routes() *web.Route {
m.Group("/runs", func() { m.Group("/runs", func() {
m.Get("", repo.ListActionRuns) m.Get("", repo.ListActionRuns)
m.Get("/{run_id}", repo.GetActionRun) 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}/jobs", repo.ListActionRunJobs)
m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts) m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts)
}) })

View file

@ -1032,6 +1032,68 @@ func GetActionRun(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer)) 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 // ListActionRunJobs return a filtered list of jobs that belong to a single workflow run
func ListActionRunJobs(ctx *context.APIContext) { func ListActionRunJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs repository ListActionRunJobs

View file

@ -168,6 +168,7 @@ type ViewRunInfo struct {
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"` CanRerun bool `json:"canRerun"`
CanDeleteArtifact bool `json:"canDeleteArtifact"` CanDeleteArtifact bool `json:"canDeleteArtifact"`
CanDelete bool `json:"canDelete"`
Done bool `json:"done"` Done bool `json:"done"`
Jobs []*ViewJob `json:"jobs"` Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"` 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.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = run.CanBeRerun() && 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.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.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead of 'null' in json
resp.State.Run.Status = run.Status.String() resp.State.Run.Status = run.Status.String()
resp.State.Run.PreExecutionError = actions_model.TranslatePreExecutionError(ctx.Locale, run) 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{}{}) 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. // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs.
// Any error will be written to the ctx. // 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. // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0.

View file

@ -1535,6 +1535,7 @@ func registerRoutes(m *web.Route) {
}) })
}) })
m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel)
m.Post("/delete", reqRepoAdmin, actions.DeleteRun)
m.Get("/artifacts", actions.ArtifactsView) m.Get("/artifacts", actions.ArtifactsView)
m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView) m.Get("/artifacts/{artifact_name_or_id}", actions.ArtifactsDownloadView)
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)

View file

@ -0,0 +1,5 @@
- id: 34901
status: 2 # StatusFailure
- id: 34902
status: 5 # StatusWaiting

View file

@ -0,0 +1,7 @@
- id: 47301
run_id: 34901
status: 2 # StatusFailure
- id: 47302
run_id: 34902
status: 5 # StatusWaiting

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
- id: 34901
status: 2 # StatusFailure
- id: 34902
status: 5 # StatusWaiting

View file

@ -0,0 +1,7 @@
- id: 47301
run_id: 34901
status: 2 # StatusFailure
- id: 47302
run_id: 34902
status: 5 # StatusWaiting

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
- id: 41601
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
ephemeral: true
- id: 41602
uuid: fae04d20-98ae-4b69-8439-213dac373e19
ephemeral: false

View file

@ -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

View file

@ -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

View file

@ -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

47
services/actions/job.go Normal file
View file

@ -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
})
}

View file

@ -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)
})
}

View file

@ -5,6 +5,7 @@ package actions
import ( import (
"context" "context"
"fmt"
"slices" "slices"
"strings" "strings"
@ -203,3 +204,30 @@ func checkJobRunsOnStaticMatrixError(ctx context.Context, job *actions_model.Act
return true, nil 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)
})
}

View file

@ -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)
})
}

View file

@ -367,3 +367,40 @@ func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp {
} }
return timeutil.TimeStamp(timestamp.AsTime().Unix()) 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
})
}

View file

@ -6,8 +6,12 @@ import (
actions_model "forgejo.org/models/actions" actions_model "forgejo.org/models/actions"
repo_model "forgejo.org/models/repo" repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/models/user" "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" "github.com/stretchr/testify/require"
) )
@ -97,3 +101,90 @@ jobs:
require.NotEmpty(t, taskContext.Fields["gitea_runtime_token"].GetStringValue()) 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)
})
}

View file

@ -17,6 +17,9 @@
data-locale-cancel="{{ctx.Locale.Tr "cancel"}}" data-locale-cancel="{{ctx.Locale.Tr "cancel"}}"
data-locale-rerun="{{ctx.Locale.Tr "rerun"}}" data-locale-rerun="{{ctx.Locale.Tr "rerun"}}"
data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}" 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-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}"
data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}" data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}"
data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}" data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}"

View file

@ -6089,6 +6089,55 @@
"$ref": "#/responses/notFound" "$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": { "/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts": {

View file

@ -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})
})
}

View file

@ -14,6 +14,7 @@ import (
"testing" "testing"
actions_model "forgejo.org/models/actions" actions_model "forgejo.org/models/actions"
repo_model "forgejo.org/models/repo"
unit_model "forgejo.org/models/unit" unit_model "forgejo.org/models/unit"
"forgejo.org/models/unittest" "forgejo.org/models/unittest"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
@ -147,7 +148,7 @@ func TestActionViewsView(t *testing.T) {
runIndex: 187, runIndex: 187,
jobIndex: 0, jobIndex: 0,
attempt: 1, 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 <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"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 <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"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", expectedArtifacts: "{\"artifacts\":[{\"name\":\"multi-file-download\",\"size\":2048,\"status\":\"completed\"}]}\n",
}, },
{ {
@ -156,7 +157,7 @@ func TestActionViewsView(t *testing.T) {
runIndex: 209, runIndex: 209,
jobIndex: 0, jobIndex: 0,
attempt: 1, 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", expectedArtifacts: "{\"artifacts\":[]}\n",
}, },
{ {
@ -165,7 +166,7 @@ func TestActionViewsView(t *testing.T) {
runIndex: 210, runIndex: 210,
jobIndex: 0, jobIndex: 0,
attempt: 1, 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", expectedArtifacts: "{\"artifacts\":[]}\n",
}, },
} }
@ -229,7 +230,7 @@ func TestActionViewsViewAttemptOutOfRange(t *testing.T) {
re = regexp.MustCompile(pattern) re = regexp.MustCompile(pattern)
actualClean = re.ReplaceAllString(actualClean, `"time_since_started_html":"_time_"`) 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 <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"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 <a href=\\\"/user5/repo4/commit/c2d72f548424103f01ee1dc02889c1e2bff816b0\\\">c2d72f5484</a> pushed by <a href=\\\"/user1\\\">user1</a>\",\"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") htmlDoc.AssertAttrEqual(t, selector, "data-initial-artifacts-response", "{\"artifacts\":[]}\n")
} }
@ -258,3 +259,62 @@ func TestActionTabAccessibleFromRepo(t *testing.T) {
return true 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))
})
})
}
}

View file

@ -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) { func TestActionsAPIListActionRunJobs(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,7 @@
- id: 47301
run_id: 34901
status: 2 # StatusFailure
- id: 47302
run_id: 34902
status: 5 # StatusWaiting

View file

@ -0,0 +1,5 @@
- id: 41601
owner_id: 0
repo_id: 1
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
ephemeral: true

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
- id: 393701
repo_id: 1
user_id: 5
mode: 2 # write

View file

@ -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

View file

@ -0,0 +1,7 @@
- id: 47301
run_id: 34901
status: 1 # StatusSuccess
- id: 47302
run_id: 34902
status: 6 # StatusRunning

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
- id: 393701
repo_id: 1
user_id: 5
mode: 2 # write

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,7 @@
- id: 47301
run_id: 34901
status: 2 # StatusFailure
- id: 47302
run_id: 34902
status: 5 # StatusWaiting

View file

@ -0,0 +1,5 @@
- id: 41601
owner_id: 0
repo_id: 1
uuid: 61a71de6-8127-40c9-a30e-63647e93edad
ephemeral: true

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -6,6 +6,8 @@ const testLocale = {
approve: 'Locale Approve', approve: 'Locale Approve',
cancel: 'Locale Cancel', cancel: 'Locale Cancel',
rerun: 'Locale Re-run', rerun: 'Locale Re-run',
delete: 'Locale Delete',
confirmDelete: '',
artifactsTitle: 'artifactTitleHere', artifactsTitle: 'artifactTitleHere',
areYouSure: '', areYouSure: '',
confirmDeleteArtifact: '', confirmDeleteArtifact: '',
@ -168,6 +170,7 @@ function configureForMultipleAttemptTests({viewHistorical}) {
canApprove: true, canApprove: true,
canCancel: true, canCancel: true,
canRerun: true, canRerun: true,
canDelete: false,
status: 'success', status: 'success',
commit: { commit: {
pusher: {}, pusher: {},

View file

@ -4,6 +4,7 @@ import ActionRunStatus from './ActionRunStatus.vue';
import ActionJobStepList from './ActionJobStepList.vue'; import ActionJobStepList from './ActionJobStepList.vue';
import {toggleElem} from '../utils/dom.js'; import {toggleElem} from '../utils/dom.js';
import {GET, POST, DELETE} from '../modules/fetch.js'; import {GET, POST, DELETE} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
export default { export default {
name: 'RepoActionView', name: 'RepoActionView',
@ -86,6 +87,7 @@ export default {
canCancel: false, canCancel: false,
canApprove: false, canApprove: false,
canRerun: false, canRerun: false,
canDelete: false,
done: false, done: false,
preExecutionError: '', preExecutionError: '',
jobs: [ 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 // cancel a run
cancelRun() { cancelRun() {
POST(`${this.run.link}/cancel`); POST(`${this.run.link}/cancel`);
@ -474,6 +491,9 @@ export default {
{{ locale.approve }} {{ locale.approve }}
</button> </button>
<div class="action-info-summary-actions" v-else> <div class="action-info-summary-actions" v-else>
<button id="delete-run" class="ui basic small compact button red" @click="deleteRun()" v-if="run.canDelete">
{{ locale.delete }}
</button>
<button class="ui basic small compact button red" @click="cancelRun()" v-if="canCancel"> <button class="ui basic small compact button red" @click="cancelRun()" v-if="canCancel">
{{ locale.cancel }} {{ locale.cancel }}
</button> </button>

View file

@ -29,6 +29,9 @@ export async function initRepositoryActionView() {
approve: el.getAttribute('data-locale-approve'), approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'), cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'), 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'), artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'), areYouSure: el.getAttribute('data-locale-are-you-sure'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),