From a65f422b89ca38fcbb85922b897aea4ece444415 Mon Sep 17 00:00:00 2001 From: Ross Golder Date: Sun, 2 Aug 2026 11:22:07 +0700 Subject: [PATCH] feat(actions): Add Actions API endpoints for workflow run management and logs (#35382) Implements the missing REST API endpoints for Actions workflow run management: 1. `POST /actions/runs/{run}/cancel` cancels a run and its jobs, `409` when it already finished 1. `POST /actions/runs/{run}/approve` approves a run awaiting approval, idempotent, `409` when it never awaited one 1. `GET /actions/runs/{run}/logs` downloads the latest attempt's job logs as a zip archive `ActionWorkflowRun` gains `created_at`, `updated_at` and the `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url` and `rerun_url` fields, and now always emits `conclusion` and `head_branch`. Cancellation is shared with the web handler in `services/actions`. Fixes https://github.com/go-gitea/gitea/issues/35176 Fixes https://github.com/go-gitea/gitea/issues/36554 --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: OpenCode Agent Co-authored-by: Nicolas Co-authored-by: silverwind Co-authored-by: wxiaoguang --- models/actions/run.go | 7 + models/actions/run_job.go | 8 +- models/actions/run_list.go | 2 +- models/actions/task.go | 9 + modules/actions/log.go | 16 +- modules/storage/minio.go | 17 + modules/structs/repo_actions.go | 9 + modules/util/filename.go | 54 +++ modules/util/filename_test.go | 20 + modules/util/path.go | 18 + modules/util/truncate.go | 14 + routers/api/v1/api.go | 9 +- routers/api/v1/repo/action.go | 19 +- routers/api/v1/repo/actions_run.go | 157 +++++++- routers/api/v1/shared/action.go | 16 +- routers/common/actions.go | 106 ++++-- routers/web/repo/actions/view.go | 25 +- services/actions/approve.go | 31 +- services/actions/approve_test.go | 52 ++- services/actions/cancel.go | 40 ++ services/actions/max_parallel_test.go | 6 +- services/convert/convert.go | 12 +- services/doctor/dbconsistency.go | 3 + services/repository/generate.go | 12 +- templates/swagger/v1-openapi3.generated.json | 191 ++++++++++ templates/swagger/v1-swagger.generated.json | 177 +++++++++ tests/integration/api_actions_run_test.go | 377 +++++++++++++++---- 27 files changed, 1230 insertions(+), 177 deletions(-) create mode 100644 modules/util/filename.go create mode 100644 modules/util/filename_test.go create mode 100644 services/actions/cancel.go diff --git a/models/actions/run.go b/models/actions/run.go index 0d6c2bbf15..0e54158537 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -92,6 +92,13 @@ func (run *ActionRun) Link() string { return fmt.Sprintf("%s/actions/runs/%d", run.Repo.Link(), run.ID) } +func (run *ActionRun) APIURL(ctx context.Context) string { + if run.Repo == nil { + return "" + } + return fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID) +} + func (run *ActionRun) WorkflowLink() string { if run.Repo == nil { return "" diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 8090431059..e2f6e7c992 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -261,12 +261,16 @@ func GetLatestAttemptJobsByRepoAndRunID(ctx context.Context, repoID, runID int64 if err != nil { return nil, err } + return GetLatestAttemptJobsByRun(ctx, run) +} + +func GetLatestAttemptJobsByRun(ctx context.Context, run *ActionRun) (ActionJobList, error) { if run.LatestAttemptID > 0 { - return GetRunJobsByRunAndAttemptID(ctx, runID, run.LatestAttemptID) + return GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID) } var jobs []*ActionRunJob - if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", repoID, runID).OrderBy("id").Find(&jobs); err != nil { + if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", run.RepoID, run.ID).OrderBy("id").Find(&jobs); err != nil { return nil, err } return jobs, nil diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 7bfe3314d6..c71e393db9 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -114,7 +114,7 @@ func (opts FindRunOptions) ToConds() builder.Cond { func (opts FindRunOptions) ToJoins() []db.JoinFunc { if opts.OwnerID > 0 { return []db.JoinFunc{func(sess db.Engine) error { - sess.Join("INNER", "repository", "repository.id = repo_id AND repository.owner_id = ?", opts.OwnerID) + sess.Join("INNER", "repository", "repository.id = action_run.repo_id AND repository.owner_id = ?", opts.OwnerID) return nil }} } diff --git a/models/actions/task.go b/models/actions/task.go index 5a76409022..9f22e560ae 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -171,6 +171,15 @@ func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) { return &task, nil } +// GetTasksMapByIDs returns the found tasks keyed by ID, silently omitting IDs that no longer exist. +func GetTasksMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionTask, error) { + tasks := make(map[int64]*ActionTask, len(ids)) + if len(ids) == 0 { + return tasks, nil + } + return tasks, db.GetEngine(ctx).In("id", ids).Find(&tasks) +} + func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) { errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist) if token == "" { diff --git a/modules/actions/log.go b/modules/actions/log.go index 3fbdf4cdeb..b823fbe7cd 100644 --- a/modules/actions/log.go +++ b/modules/actions/log.go @@ -192,16 +192,22 @@ func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeek return nil, fmt.Errorf("storage open %q: %w", filename, err) } - var reader io.ReadSeekCloser = f if strings.HasSuffix(filename, ".zst") { - r, err := zstd.NewSeekableReader(f) + reader, err := zstd.NewSeekableReader(f) // reads the seek table, so a lazily opened object already fails here if err != nil { - return nil, fmt.Errorf("zstd NewSeekableReader: %w", err) + f.Close() + return nil, fmt.Errorf("zstd NewSeekableReader %q: %w", filename, err) } - reader = r + return reader, nil } - return reader, nil + // object storage opens lazily, force a missing object to surface before the caller commits a response + if _, err := f.Seek(0, io.SeekStart); err != nil { + f.Close() + return nil, fmt.Errorf("storage open %q: %w", filename, err) + } + + return f, nil } func FormatLog(timestamp time.Time, content string) string { diff --git a/modules/storage/minio.go b/modules/storage/minio.go index 310dd6da5e..ca4bb51ca4 100644 --- a/modules/storage/minio.go +++ b/modules/storage/minio.go @@ -39,6 +39,23 @@ func (m *minioObject) Stat() (os.FileInfo, error) { return &minioFileInfo{oi}, nil } +// minio reports a missing key on the first Read, ReadAt or Seek rather than on Open, so all +// of them convert it like Stat does. +func (m *minioObject) Read(p []byte) (int, error) { + n, err := m.Object.Read(p) + return n, convertMinioErr(err) +} + +func (m *minioObject) ReadAt(p []byte, off int64) (int, error) { + n, err := m.Object.ReadAt(p, off) + return n, convertMinioErr(err) +} + +func (m *minioObject) Seek(offset int64, whence int) (int64, error) { + n, err := m.Object.Seek(offset, whence) + return n, convertMinioErr(err) +} + // MinioStorage returns a minio bucket storage type MinioStorage struct { cfg *setting.MinioStorageConfig diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index dbbbd8d795..3e56989675 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -111,6 +111,11 @@ type ActionWorkflowRun struct { // It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null. PreviousAttemptURL *string `json:"previous_attempt_url"` HTMLURL string `json:"html_url"` + JobsURL string `json:"jobs_url"` + LogsURL string `json:"logs_url"` + ArtifactsURL string `json:"artifacts_url"` + CancelURL string `json:"cancel_url"` + RerunURL string `json:"rerun_url"` DisplayTitle string `json:"display_title"` Path string `json:"path"` Event string `json:"event"` @@ -130,6 +135,10 @@ type ActionWorkflowRun struct { Conclusion string `json:"conclusion,omitempty"` PullRequests []*PullRequestMinimal `json:"pull_requests"` // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + UpdatedAt time.Time `json:"updated_at"` + // swagger:strfmt date-time StartedAt time.Time `json:"started_at"` // swagger:strfmt date-time CompletedAt time.Time `json:"completed_at"` diff --git a/modules/util/filename.go b/modules/util/filename.go new file mode 100644 index 0000000000..c5c1be8e37 --- /dev/null +++ b/modules/util/filename.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "strconv" + "strings" +) + +// FileNameJoinFields joins the fields by separator "-" as a safe filename, +// the last field is used as extname (no sep is added before it). +// This function does its best to avoid the filename exceeding the max filename length (255 bytes on Linux), +// Each field needs at least about 3 bytes, if the caller passes too many fields (e.g.: 100 fields), +// then it's unavoidable, just do not write such code in real world. +func FileNameJoinFields(fields ...any) string { + // linux max filename length is 255, we leave some space for the separators and extname + const maxFilenameLen = 210 + return fileNameJoinFields(maxFilenameLen, fields...) +} + +func fileNameJoinFields(stemNameLimit int, fields ...any) string { + // stemNameLimit is just a suggested limit, when adding more fields, the length might still exceed a little + sb := strings.Builder{} + for i, f := range fields { + var field string + switch v := f.(type) { + case string: + field = v + case int64: + field = strconv.FormatInt(v, 10) + default: + field = fmt.Sprint(v) + } + field = PathNameValidator().InvalidChars.ReplaceAllString(field, "_") + if i < len(fields)-1 { + field = strings.ReplaceAll(strings.ReplaceAll(field, ".", "_"), "-", "_") + estimatedRemainingLen := (len(fields) - 1 - i) * 3 + estimatedLimit := stemNameLimit - estimatedRemainingLen + if sb.Len()+len(field) > estimatedLimit { + field = TruncateStringBytes(field, estimatedLimit-sb.Len()-2) + "__" + } + sb.WriteString(field) + if i < len(fields)-2 { + sb.WriteString("-") + } + } else { + // last field is extname, no need to do more processing + sb.WriteString(field) + } + } + return sb.String() +} diff --git a/modules/util/filename_test.go b/modules/util/filename_test.go new file mode 100644 index 0000000000..a75532d19e --- /dev/null +++ b/modules/util/filename_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFileNameJoinFields(t *testing.T) { + assert.Equal(t, "a_b-1.txt", FileNameJoinFields("a-b", 1, ".txt")) + assert.Equal(t, "a-____-b.txt", FileNameJoinFields("a", "/../", "b", ".txt")) + assert.Equal(t, "a-____-b.txt", FileNameJoinFields("a", "\\..\\", "b", ".txt")) + + assert.Equal(t, "🌞-🌛.txt", fileNameJoinFields(14, "🌞", "🌛", ".txt")) + assert.Equal(t, "🌞-🌛__.txt", fileNameJoinFields(14, "🌞", "🌛🌛🌛🌛", ".txt")) + assert.Equal(t, "🌞__-__.txt", fileNameJoinFields(14, "🌞🌞🌞🌞", "🌛🌛🌛🌛", ".txt")) +} diff --git a/modules/util/path.go b/modules/util/path.go index d251d5353e..7d67cc72cb 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -10,9 +10,27 @@ import ( "os" "path" "path/filepath" + "regexp" "strings" + "sync" ) +var PathNameValidator = sync.OnceValue(func() (ret struct { + InvalidChars *regexp.Regexp + InvalidNames *regexp.Regexp +}, +) { + ret.InvalidChars = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]`) + // invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex + // "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid + ret.InvalidNames = regexp.MustCompile(`(?i)^(con|prn|aux|nul|com\d|lpt\d)$`) + return ret +}) + +func PathBaseStem(s string) string { + return strings.TrimSuffix(path.Base(s), path.Ext(s)) +} + // PathJoinRel joins the path elements into a single path, each element is cleaned by path.Clean separately. // It only returns the following values (like path.Join), any redundant part (empty, relative dots, slashes) is removed. // It's caller's duty to make every element not bypass its own directly level, to avoid security issues. diff --git a/modules/util/truncate.go b/modules/util/truncate.go index 52534d3cac..faca759381 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -130,3 +130,17 @@ func TruncateRunes(str string, limit int) string { } return string([]rune(str)[:limit]) } + +// TruncateStringBytes returns a truncated string with given byte limit, +// it returns input string if its byte length doesn't exceed the limit. +func TruncateStringBytes(str string, limit int) string { + l := 0 + for i, r := range str { + rl := utf8.RuneLen(r) + if l+rl > limit { + return str[:i] + } + l += rl + } + return str +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index fa8d29c28b..8a4d69e8f2 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1362,8 +1362,13 @@ func Routes() *web.Router { m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun) m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun) - m.Get("/jobs", repo.ListWorkflowRunJobs) - m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) + m.Post("/cancel", reqToken(), reqRepoWriter(unit.TypeActions), repo.CancelWorkflowRun) + m.Post("/approve", reqToken(), reqRepoWriter(unit.TypeActions), repo.ApproveWorkflowRun) + m.Group("/jobs", func() { + m.Get("", repo.ListWorkflowRunJobs) + m.Post("/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob) + }) + m.Get("/logs", reqToken(), repo.GetWorkflowRunLogs) m.Get("/artifacts", repo.GetArtifactsOfRun) }) }) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 232253e12c..14d2fa9e83 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1291,7 +1291,7 @@ func getCurrentRepoActionRunJobsByID(ctx *context.APIContext) (*actions_model.Ac return nil, nil } - jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run) if err != nil { ctx.APIErrorInternal(err) return nil, nil @@ -1315,6 +1315,16 @@ func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_m return run, attempt } +func respondRepoActionWorkflowRun(ctx *context.APIContext, run *actions_model.ActionRun) { + run.Repo = ctx.Repo.Repository + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedRun) +} + // GetWorkflowRun Gets a specific workflow run. func GetWorkflowRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun @@ -1351,12 +1361,7 @@ func GetWorkflowRun(ctx *context.APIContext) { return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) - if err != nil { - ctx.APIErrorInternal(err) - return - } - ctx.JSON(http.StatusOK, convertedRun) + respondRepoActionWorkflowRun(ctx, run) } // GetWorkflowRunAttempt Gets a specific workflow run attempt. diff --git a/routers/api/v1/repo/actions_run.go b/routers/api/v1/repo/actions_run.go index 1765ed564d..e7f2b45591 100644 --- a/routers/api/v1/repo/actions_run.go +++ b/routers/api/v1/repo/actions_run.go @@ -4,8 +4,11 @@ package repo import ( + "net/http" + actions_model "gitea.dev/models/actions" "gitea.dev/routers/common" + actions_service "gitea.dev/services/actions" "gitea.dev/services/context" ) @@ -45,13 +48,157 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) { ctx.APIErrorAuto(err) return } - if err = curJob.LoadRepo(ctx); err != nil { - ctx.APIErrorInternal(err) - return - } - err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob) if err != nil { ctx.APIErrorAuto(err) } } + +func CancelWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/cancel repository cancelWorkflowRun + // --- + // summary: Cancel a workflow run and its jobs + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: run ID + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + + run, jobs := getCurrentRepoActionRunJobsByID(ctx) + if ctx.Written() { + return + } + + // Cancelling a finished run would change nothing, so report a conflict instead of a false success. + if run.Status.IsDone() { + ctx.APIError(http.StatusConflict, "run is already completed") + return + } + + run, err := actions_service.CancelRun(ctx, run, jobs) + if err != nil { + ctx.APIErrorAuto(err) + return + } + respondRepoActionWorkflowRun(ctx, run) +} + +func ApproveWorkflowRun(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/approve repository approveWorkflowRun + // --- + // summary: Approve a workflow run that requires approval + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: run ID + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + + if !run.NeedApproval { + // Approving twice is idempotent, but a run that never awaited approval gets 409 rather + // than GitHub's 403, which would be indistinguishable from a permission denial. + if run.ApprovedBy == 0 { + ctx.APIError(http.StatusConflict, "run does not require approval") + return + } + respondRepoActionWorkflowRun(ctx, run) + return + } + + approvedRuns, err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}) + if err != nil { + ctx.APIErrorAuto(err) + return + } + respondRepoActionWorkflowRun(ctx, approvedRuns[0]) +} + +func GetWorkflowRunLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/logs repository getWorkflowRunLogs + // --- + // summary: Download workflow run logs as archive + // produces: + // - application/zip + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: run ID + // type: integer + // required: true + // responses: + // "200": + // description: Logs archive + // "404": + // "$ref": "#/responses/notFound" + + run := getCurrentRepoActionRunByID(ctx) + if ctx.Written() { + return + } + + if err := common.DownloadActionsRunAllJobLogs(ctx.Base, run); err != nil { + ctx.APIErrorAuto(err) + } +} diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 58e47c0a98..134d336b2a 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -205,7 +205,6 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) } res := new(api.ActionWorkflowRunsResponse) - res.TotalCount = total runList := actions_model.RunList(runs) if err := runList.LoadTriggerUser(ctx); err != nil { @@ -225,16 +224,23 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) return } - res.Entries = make([]*api.ActionWorkflowRun, len(runs)) - for i := range runs { + res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs)) + for _, run := range runs { + if run.Repo == nil { + // Orphaned row: drop it rather than failing the page, so total stays an upper bound + // until "doctor check --run check-db-consistency" removes it. + total-- + continue + } // TODO: load run attempts in batch - convertedRun, err := convert.ToActionWorkflowRun(ctx, runs[i], nil, excludePullRequests) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, excludePullRequests) if err != nil { ctx.APIErrorInternal(err) return } - res.Entries[i] = convertedRun + res.Entries = append(res.Entries, convertedRun) } + res.TotalCount = total ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, &res) diff --git a/routers/common/actions.go b/routers/common/actions.go index 4c3c212928..220dad32e2 100644 --- a/routers/common/actions.go +++ b/routers/common/actions.go @@ -4,68 +4,126 @@ package common import ( + "archive/zip" + "context" "errors" "fmt" + "io" "io/fs" - "strings" actions_model "gitea.dev/models/actions" repo_model "gitea.dev/models/repo" "gitea.dev/modules/actions" + "gitea.dev/modules/container" "gitea.dev/modules/httplib" + "gitea.dev/modules/log" "gitea.dev/modules/util" - "gitea.dev/services/context" + context_module "gitea.dev/services/context" ) -func DownloadActionsRunJobLogsWithID(ctx *context.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { +func openTaskLogs(ctx context.Context, task *actions_model.ActionTask) (io.ReadSeekCloser, error) { + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if errors.Is(err, fs.ErrNotExist) { + // convert fs err to our own error type so that the API can return a 404 instead of a 500 + return nil, util.NewNotExistErrorf("unable to open task logs: %s", task.LogFilename) + } + return reader, err +} + +func DownloadActionsRunJobLogsWithID(ctx *context_module.Base, ctxRepo *repo_model.Repository, runID, jobID int64) error { job, err := actions_model.GetRunJobByRunAndID(ctx, runID, jobID) if err != nil { return err } - if err := job.LoadRepo(ctx); err != nil { - return fmt.Errorf("LoadRepo: %w", err) - } return DownloadActionsRunJobLogs(ctx, ctxRepo, job) } -func DownloadActionsRunJobLogs(ctx *context.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error { - if curJob.Repo.ID != ctxRepo.ID { - return util.NewNotExistErrorf("job not found") +// DownloadActionsRunAllJobLogs assumes the run was already resolved against the requesting repository. +func DownloadActionsRunAllJobLogs(ctx *context_module.Base, run *actions_model.ActionRun) error { + runJobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run) + if err != nil { + return fmt.Errorf("GetLatestAttemptJobsByRun: %w", err) } - taskID := curJob.EffectiveTaskID() - if taskID == 0 { - return util.NewNotExistErrorf("job not started") + tasks, err := actions_model.GetTasksMapByIDs(ctx, container.FilterSlice(runJobs, func(job *actions_model.ActionRunJob) (int64, bool) { + taskID := job.EffectiveTaskID() + return taskID, taskID != 0 + })) + if err != nil { + return fmt.Errorf("GetTasksMapByIDs: %w", err) + } + + // Delay the headers until the first log opens, afterwards a failure can no longer reach + // the client, so the remaining entries are best-effort. + var zipWriter *zip.Writer + for _, job := range runJobs { + task := tasks[job.EffectiveTaskID()] + if task == nil || task.LogExpired { + continue + } + + reader, err := openTaskLogs(ctx, task) + if err != nil { + log.Error("Failed to open logs of job %d: %v", job.ID, err) + continue + } + + if zipWriter == nil { + ctx.SetServeHeaders(context_module.ServeHeaderOptions{ + Filename: util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), "run", run.ID, "logs", ".zip"), + ContentType: "application/zip", + ContentDisposition: httplib.ContentDispositionAttachment, + }) + zipWriter = zip.NewWriter(ctx.Resp) + } + + zipFile, err := zipWriter.Create(util.FileNameJoinFields(util.PathBaseStem(run.WorkflowID), job.Name, task.ID, ".log")) + if err == nil { + _, err = io.Copy(zipFile, reader) + } + reader.Close() + if err != nil { + log.Error("Failed to add logs of job %d to zip: %v", job.ID, err) + } + } + if zipWriter == nil { + return util.NewNotExistErrorf("logs not found") + } + if err := zipWriter.Close(); err != nil { + log.Error("Failed to finalize logs zip of run %d: %v", run.ID, err) + } + return nil +} + +func DownloadActionsRunJobLogs(ctx *context_module.Base, ctxRepo *repo_model.Repository, curJob *actions_model.ActionRunJob) error { + if curJob.RepoID != ctxRepo.ID { + return util.NewNotExistErrorf("job not found") } if err := curJob.LoadRun(ctx); err != nil { return fmt.Errorf("LoadRun: %w", err) } + taskID := curJob.EffectiveTaskID() + if taskID == 0 { + return util.NewNotExistErrorf("job not started") + } task, err := actions_model.GetTaskByID(ctx, taskID) if err != nil { return fmt.Errorf("GetTaskByID: %w", err) } - if task.LogExpired { return util.NewNotExistErrorf("logs have been cleaned up") } - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + reader, err := openTaskLogs(ctx, task) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return util.NewNotExistErrorf("logs not found") - } - return fmt.Errorf("OpenLogs: %w", err) + return err } defer reader.Close() - workflowName := curJob.Run.WorkflowID - if p := strings.Index(workflowName, "."); p > 0 { - workflowName = workflowName[0:p] - } - ctx.ServeContent(reader, context.ServeHeaderOptions{ - Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, curJob.Name, task.ID), + ctx.ServeContent(reader, context_module.ServeHeaderOptions{ + Filename: util.FileNameJoinFields(util.PathBaseStem(curJob.Run.WorkflowID), curJob.Name, task.ID, ".log"), ContentLength: &task.LogSize, ContentType: "text/plain; charset=utf-8", ContentDisposition: httplib.ContentDispositionAttachment, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 115afc0c4c..b868fbd687 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -1050,27 +1050,10 @@ func Cancel(ctx *context_module.Context) { return } - var updatedJobs []*actions_model.ActionRunJob - - if err := db.WithTx(ctx, func(ctx context.Context) error { - cancelledJobs, err := actions_model.CancelJobs(ctx, jobs) - if err != nil { - return fmt.Errorf("cancel jobs: %w", err) - } - updatedJobs = append(updatedJobs, cancelledJobs...) - return nil - }); err != nil { - ctx.ServerError("StopTask", err) + if _, err := actions_service.CancelRun(ctx, run, jobs); err != nil { + ctx.ServerError("CancelRun", err) return } - - actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) - actions_service.EmitJobsIfReadyByJobs(updatedJobs) - - actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) - if len(updatedJobs) > 0 { - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) - } ctx.JSONOK() } @@ -1079,7 +1062,7 @@ func Approve(ctx *context_module.Context) { if ctx.Written() { return } - if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { + if _, err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) @@ -1345,7 +1328,7 @@ func ApproveAllChecks(ctx *context_module.Context) { return } - if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil { + if _, err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil { ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) diff --git a/services/actions/approve.go b/services/actions/approve.go index c8755434bf..cbee1a4e15 100644 --- a/services/actions/approve.go +++ b/services/actions/approve.go @@ -14,9 +14,11 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/container" "gitea.dev/modules/log" + "gitea.dev/modules/util" ) -func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error { +// ApproveRuns returns the approved runs in the same order as runIDs. +func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) ([]*actions_model.ActionRun, error) { updatedJobs := make([]*actions_model.ActionRunJob, 0) cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0) // Track runs whose reusable callers were just expanded so we can re-emit after the tx commits. @@ -36,7 +38,7 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { return err } - jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run) if err != nil { return err } @@ -107,7 +109,7 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo return nil }) if err != nil { - return err + return nil, err } // Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting. @@ -122,5 +124,26 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) - return nil + // The batches above already notified every run whose jobs changed, which is the only way + // approving alters a run's status, so reload purely to answer the caller. + reloaded, err := actions_model.GetRunsByRepoAndID(ctx, repo.ID, runIDs) + if err != nil { + return nil, fmt.Errorf("GetRunsByRepoAndID: %w", err) + } + runsByID := make(map[int64]*actions_model.ActionRun, len(reloaded)) + for _, run := range reloaded { + run.Repo = repo // the caller resolved runIDs against this repo, so spare every consumer a reload + runsByID[run.ID] = run + } + + approvedRuns := make([]*actions_model.ActionRun, 0, len(runIDs)) + for _, runID := range runIDs { + run := runsByID[runID] + if run == nil { + return nil, util.NewNotExistErrorf("run %d no longer exists after approval", runID) + } + approvedRuns = append(approvedRuns, run) + } + + return approvedRuns, nil } diff --git a/services/actions/approve_test.go b/services/actions/approve_test.go index 8b04a283ff..d9d90ef859 100644 --- a/services/actions/approve_test.go +++ b/services/actions/approve_test.go @@ -32,36 +32,64 @@ func TestApproveRuns(t *testing.T) { require.NoError(t, db.Insert(t.Context(), run)) return run } - insertJob := func(run *actions_model.ActionRun, status actions_model.Status) *actions_model.ActionRunJob { + insertJob := func(run *actions_model.ActionRun, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { job := &actions_model.ActionRunJob{ RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, Name: "job1", Attempt: 1, JobID: "job1", Status: status, - RunsOn: []string{"ubuntu-latest"}, + RunsOn: []string{"ubuntu-latest"}, Needs: needs, } require.NoError(t, db.Insert(t.Context(), job)) return job } - t.Run("approve blocked run", func(t *testing.T) { + t.Run("approve unblocks a job with no dependencies", func(t *testing.T) { run := insertRun(1001, actions_model.StatusBlocked, true, 0) job := insertJob(run, actions_model.StatusBlocked) - require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID})) + approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) + require.NoError(t, err) + require.Len(t, approved, 1) + assert.False(t, approved[0].NeedApproval) + assert.Equal(t, doer.ID, approved[0].ApprovedBy) - run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) - assert.False(t, run.NeedApproval) - assert.Equal(t, doer.ID, run.ApprovedBy) assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status) }) + t.Run("a job with unmet dependencies stays blocked", func(t *testing.T) { + run := insertRun(1002, actions_model.StatusBlocked, true, 0) + job := insertJob(run, actions_model.StatusBlocked, "some-other-job") + + approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) + require.NoError(t, err) + require.Len(t, approved, 1) + assert.False(t, approved[0].NeedApproval) + + assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status) + }) + t.Run("re-approving an approved run is a no-op", func(t *testing.T) { - run := insertRun(1002, actions_model.StatusRunning, false, 4) + run := insertRun(1005, actions_model.StatusRunning, false, 4) job := insertJob(run, actions_model.StatusRunning) - require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID})) - - run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) - assert.EqualValues(t, 4, run.ApprovedBy, "approver must not be overwritten") + approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) + require.NoError(t, err) + require.Len(t, approved, 1) + assert.EqualValues(t, 4, approved[0].ApprovedBy, "approver must not be overwritten") assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting") }) + + t.Run("approving several runs returns them in the requested order", func(t *testing.T) { + run1 := insertRun(1003, actions_model.StatusBlocked, true, 0) + insertJob(run1, actions_model.StatusBlocked) + run2 := insertRun(1004, actions_model.StatusBlocked, true, 0) + insertJob(run2, actions_model.StatusBlocked) + + approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run2.ID, run1.ID}) + require.NoError(t, err) + require.Len(t, approved, 2) + assert.Equal(t, run2.ID, approved[0].ID) + assert.Equal(t, run1.ID, approved[1].ID) + assert.False(t, approved[0].NeedApproval) + assert.False(t, approved[1].NeedApproval) + }) } diff --git a/services/actions/cancel.go b/services/actions/cancel.go new file mode 100644 index 0000000000..8e7ae2a827 --- /dev/null +++ b/services/actions/cancel.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" +) + +// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state. +func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) { + var updatedJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) (err error) { + updatedJobs, err = actions_model.CancelJobs(ctx, jobs) + if err != nil { + return fmt.Errorf("CancelJobs: %w", err) + } + return nil + }); err != nil { + return nil, err + } + + CreateCommitStatusForRunJobs(ctx, run, jobs...) + EmitJobsIfReadyByJobs(updatedJobs) + NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) + if len(updatedJobs) == 0 { + return run, nil + } + + reloaded, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, run.ID) + if err != nil { + return nil, fmt.Errorf("GetRunByRepoAndID: %w", err) + } + NotifyWorkflowRunStatusUpdate(ctx, reloaded) + return reloaded, nil +} diff --git a/services/actions/max_parallel_test.go b/services/actions/max_parallel_test.go index b571891066..936646bcec 100644 --- a/services/actions/max_parallel_test.go +++ b/services/actions/max_parallel_test.go @@ -155,7 +155,8 @@ func TestApproveRuns_MaxParallel(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID})) + _, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) + require.NoError(t, err) assert.Equal(t, map[actions_model.Status]int{ actions_model.StatusWaiting: 2, @@ -203,7 +204,8 @@ func TestApproveRuns_MaxParallelStarvedSkipsConcurrency(t *testing.T) { run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, true) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID})) + _, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID}) + require.NoError(t, err) assert.Equal(t, map[actions_model.Status]int{ actions_model.StatusWaiting: 1, diff --git a/services/convert/convert.go b/services/convert/convert.go index 80a4cdcdeb..fd72f8c906 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -293,7 +293,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte completedAt = attempt.Stopped.AsLocalTime() triggerUser = attempt.TriggerUser if attempt.Attempt > 1 { - url := fmt.Sprintf("%s/actions/runs/%d/attempts/%d", run.Repo.APIURL(ctx), run.ID, attempt.Attempt-1) + url := fmt.Sprintf("%s/attempts/%d", run.APIURL(ctx), attempt.Attempt-1) previousAttemptURL = &url } } @@ -305,13 +305,21 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte } } + runURL := run.APIURL(ctx) return &api.ActionWorkflowRun{ ID: run.ID, - URL: fmt.Sprintf("%s/actions/runs/%d", run.Repo.APIURL(ctx), run.ID), + URL: runURL, PreviousAttemptURL: previousAttemptURL, HTMLURL: run.HTMLURL(ctx), + JobsURL: runURL + "/jobs", + LogsURL: runURL + "/logs", + ArtifactsURL: runURL + "/artifacts", + CancelURL: runURL + "/cancel", + RerunURL: runURL + "/rerun", RunNumber: run.Index, RunAttempt: runAttempt, + CreatedAt: run.Created.AsLocalTime(), + UpdatedAt: run.Updated.AsLocalTime(), StartedAt: startedAt, CompletedAt: completedAt, Event: run.TriggerEvent, diff --git a/services/doctor/dbconsistency.go b/services/doctor/dbconsistency.go index 8c218e2bf6..f2495e411d 100644 --- a/services/doctor/dbconsistency.go +++ b/services/doctor/dbconsistency.go @@ -222,6 +222,9 @@ func prepareDBConsistencyChecks() []consistencyCheck { // find action without repository genericOrphanCheck("Action entries without existing repository", "action", "repository", "action.repo_id=repository.id"), + // find action runs without repository + genericOrphanCheck("Action runs without existing repository", + "action_run", "repository", "action_run.repo_id=repository.id"), // find action without user genericOrphanCheck("Action entries without existing user", "action", "user", "action.act_user_id=`user`.id"), diff --git a/services/repository/generate.go b/services/repository/generate.go index 050afea999..8b98662612 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -12,7 +12,6 @@ import ( "io/fs" "os" "path/filepath" - "regexp" "strconv" "strings" "sync" @@ -42,8 +41,7 @@ type expansion struct { } var globalVars = sync.OnceValue(func() (ret struct { - defaultTransformers []transformer - fileNameSanitizeRegexp *regexp.Regexp + defaultTransformers []transformer }, ) { ret.defaultTransformers = []transformer{ @@ -55,10 +53,6 @@ var globalVars = sync.OnceValue(func() (ret struct { {Name: "UPPER", Transform: strings.ToUpper}, {Name: "TITLE", Transform: util.ToTitleCase}, } - - // invalid filename contents, based on https://github.com/sindresorhus/filename-reserved-regex - // "COM10" needs to be opened with UNC "\\.\COM10" on Windows, so itself is valid - ret.fileNameSanitizeRegexp = regexp.MustCompile(`(?i)[<>:"/\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`) return ret }) @@ -340,7 +334,9 @@ func (gro GenerateRepoOptions) IsValid() bool { func filePathSanitize(s string) string { fields := strings.Split(filepath.ToSlash(s), "/") for i, field := range fields { - field = strings.TrimSpace(strings.TrimSpace(globalVars().fileNameSanitizeRegexp.ReplaceAllString(field, "_"))) + field = util.PathNameValidator().InvalidChars.ReplaceAllString(field, "_") + field = util.PathNameValidator().InvalidNames.ReplaceAllString(field, "_") + field = strings.TrimSpace(field) if strings.HasPrefix(field, "..") { field = "__" + field[2:] } diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 1b586abc0d..eaa1e3a31f 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -2237,6 +2237,16 @@ "actor": { "$ref": "#/components/schemas/User" }, + "artifacts_url": { + "format": "uri", + "type": "string", + "x-go-name": "ArtifactsURL" + }, + "cancel_url": { + "format": "uri", + "type": "string", + "x-go-name": "CancelURL" + }, "completed_at": { "format": "date-time", "type": "string", @@ -2246,6 +2256,11 @@ "type": "string", "x-go-name": "Conclusion" }, + "created_at": { + "format": "date-time", + "type": "string", + "x-go-name": "CreatedAt" + }, "display_title": { "type": "string", "x-go-name": "DisplayTitle" @@ -2275,6 +2290,16 @@ "type": "integer", "x-go-name": "ID" }, + "jobs_url": { + "format": "uri", + "type": "string", + "x-go-name": "JobsURL" + }, + "logs_url": { + "format": "uri", + "type": "string", + "x-go-name": "LogsURL" + }, "path": { "type": "string", "x-go-name": "Path" @@ -2300,6 +2325,11 @@ "type": "integer", "x-go-name": "RepositoryID" }, + "rerun_url": { + "format": "uri", + "type": "string", + "x-go-name": "RerunURL" + }, "run_attempt": { "description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.", "format": "int64", @@ -2323,6 +2353,11 @@ "trigger_actor": { "$ref": "#/components/schemas/User" }, + "updated_at": { + "format": "date-time", + "type": "string", + "x-go-name": "UpdatedAt" + }, "url": { "format": "uri", "type": "string", @@ -16455,6 +16490,61 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/approve": { + "post": { + "operationId": "approveWorkflowRun", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/WorkflowRun" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "409": { + "$ref": "#/components/responses/conflict" + } + }, + "summary": "Approve a workflow run that requires approval", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { "get": { "operationId": "getArtifactsOfRun", @@ -16652,6 +16742,61 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/cancel": { + "post": { + "operationId": "cancelWorkflowRun", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/WorkflowRun" + }, + "400": { + "$ref": "#/components/responses/error" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/notFound" + }, + "409": { + "$ref": "#/components/responses/conflict" + } + }, + "summary": "Cancel a workflow run and its jobs", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "operationId": "listWorkflowRunJobs", @@ -16811,6 +16956,52 @@ ] } }, + "/repos/{owner}/{repo}/actions/runs/{run}/logs": { + "get": { + "operationId": "getWorkflowRunLogs", + "parameters": [ + { + "description": "owner of the repo", + "in": "path", + "name": "owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "name of the repository", + "in": "path", + "name": "repo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "run ID", + "in": "path", + "name": "run", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Logs archive" + }, + "404": { + "$ref": "#/components/responses/notFound" + } + }, + "summary": "Download workflow run logs as archive", + "tags": [ + "repository" + ] + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { "post": { "operationId": "rerunWorkflowRun", diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index fb8409a7c5..3dfea78f76 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -5457,6 +5457,58 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/approve": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Approve a workflow run that requires approval", + "operationId": "approveWorkflowRun", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "run ID", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/WorkflowRun" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { "get": { "produces": [ @@ -5633,6 +5685,58 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/cancel": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Cancel a workflow run and its jobs", + "operationId": "cancelWorkflowRun", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "run ID", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/WorkflowRun" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/jobs": { "get": { "produces": [ @@ -5774,6 +5878,49 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/logs": { + "get": { + "produces": [ + "application/zip" + ], + "tags": [ + "repository" + ], + "summary": "Download workflow run logs as archive", + "operationId": "getWorkflowRunLogs", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "run ID", + "name": "run", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Logs archive" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runs/{run}/rerun": { "post": { "produces": [ @@ -22518,6 +22665,14 @@ "actor": { "$ref": "#/definitions/User" }, + "artifacts_url": { + "type": "string", + "x-go-name": "ArtifactsURL" + }, + "cancel_url": { + "type": "string", + "x-go-name": "CancelURL" + }, "completed_at": { "type": "string", "format": "date-time", @@ -22527,6 +22682,11 @@ "type": "string", "x-go-name": "Conclusion" }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, "display_title": { "type": "string", "x-go-name": "DisplayTitle" @@ -22555,6 +22715,14 @@ "format": "int64", "x-go-name": "ID" }, + "jobs_url": { + "type": "string", + "x-go-name": "JobsURL" + }, + "logs_url": { + "type": "string", + "x-go-name": "LogsURL" + }, "path": { "type": "string", "x-go-name": "Path" @@ -22579,6 +22747,10 @@ "format": "int64", "x-go-name": "RepositoryID" }, + "rerun_url": { + "type": "string", + "x-go-name": "RerunURL" + }, "run_attempt": { "description": "RunAttempt is 1-based for runs created after ActionRunAttempt was introduced.\nA value of 0 is a legacy-only sentinel for runs created before attempts existed\nand indicates no corresponding /attempts/{n} resource is available.", "type": "integer", @@ -22602,6 +22774,11 @@ "trigger_actor": { "$ref": "#/definitions/User" }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + }, "url": { "type": "string", "x-go-name": "URL" diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index f927e08419..3a72caf7a0 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -4,23 +4,30 @@ package integration import ( + "archive/zip" + "bytes" "fmt" + "io" "net/http" "slices" "testing" + "time" + runnerv1 "gitea.dev/actions-proto-go/runner/v1" actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + "gitea.dev/modules/actions" api "gitea.dev/modules/structs" "gitea.dev/modules/timeutil" "gitea.dev/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestAPIActionsWorkflowRun(t *testing.T) { @@ -31,16 +38,13 @@ func TestAPIActionsWorkflowRun(t *testing.T) { t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows) t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission) t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning) + t.Run("GetWorkflowRunLogsNotFound", testAPIActionsGetWorkflowRunLogsNotFound) + t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound) + // finishes run 793, so it must come after everything that needs it still running + t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun) + t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun) + // deletes run 795, so it must come after everything that reads it t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral) - - t.Run("RerunWorkflowRun", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - testAPIActionsRerunWorkflowRun(t) - }) - t.Run("RerunWorkflowJob", func(t *testing.T) { - defer tests.PrepareTestEnv(t)() - testAPIActionsRerunWorkflowJob(t) - }) } func testAPIActionsGetWorkflowRun(t *testing.T) { @@ -74,8 +78,10 @@ func testAPIActionsGetWorkflowRun(t *testing.T) { }) require.NoError(t, err) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).AddTokenAuth(token) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())). + AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) + jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{}) job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 }) @@ -95,11 +101,9 @@ func testAPIActionsGetWorkflowJob(t *testing.T) { req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198198", repo.FullName())). AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) - req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198", repo.FullName())). - AddTokenAuth(token) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198", repo.FullName())).AddTokenAuth(token) MakeRequest(t, req, http.StatusOK) - req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/196", repo.FullName())). - AddTokenAuth(token) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/196", repo.FullName())).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } @@ -126,6 +130,7 @@ func testAPIActionsDeleteRunGeneral(t *testing.T) { testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound) } +// needs run 793 still running, so it must come before CancelWorkflowRun func testAPIActionsDeleteRunRunning(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -144,7 +149,8 @@ func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token st } func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) { - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).AddTokenAuth(token) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())). + AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{}) assert.Len(t, listResp.Entries, artifacts) @@ -154,7 +160,6 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{}) - findTask1 := false findTask2 := false for _, entry := range listResp.Entries { @@ -171,7 +176,12 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, assert.Equal(t, expected, findTask2) } -func testAPIActionsRerunWorkflowRun(t *testing.T) { +// TestAPIActionsRerunWorkflowRun covers everything that mutates run 795, in a fixed order so +// they can share one fixture load: the log download has to see the original tasks, the job +// rerun needs the run still done, and the full rerun re-arms it by cancelling first. +func TestAPIActionsRerunWorkflowRun(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + t.Run("NotDone", func(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) @@ -181,6 +191,10 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) { req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())). AddTokenAuth(writeToken) MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusBadRequest) }) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) @@ -190,16 +204,89 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) { writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - t.Run("Success", func(t *testing.T) { - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).AddTokenAuth(writeToken) - resp := MakeRequest(t, req, http.StatusCreated) - rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + t.Run("RunLogs", func(t *testing.T) { + // run 795 (workflow "test.yaml") has job 198 "job_1" on task 53 and job 199 "job_2" on task 54 + seedTaskLogs(t, 53, "hello from job_1") + seedTaskLogs(t, 54, "hello from job_2") + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(writeToken) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Header().Get("Content-Disposition"), "test-run-795-logs.zip") + assert.Equal(t, "Content-Disposition", resp.Header().Get("Access-Control-Expose-Headers")) + + body := resp.Body.Bytes() + archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + require.NoError(t, err) + + contents := make(map[string]string, len(archive.File)) + for _, file := range archive.File { + r, err := file.Open() + require.NoError(t, err) + content, err := io.ReadAll(r) + require.NoError(t, r.Close()) + require.NoError(t, err) + contents[file.Name] = string(content) + } + + require.Len(t, contents, 2) + assert.Contains(t, contents["test-job_1-53.log"], "hello from job_1") + assert.Contains(t, contents["test-job_2-54.log"], "hello from job_2") + }) + + t.Run("JobSuccess", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())). + AddTokenAuth(writeToken) + resp := MakeRequest(t, req, http.StatusCreated) + + rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{}) + job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199) + assert.Equal(t, job199Rerun.ID, rerunResp.ID) + assert.Equal(t, "queued", rerunResp.Status) + + run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) + require.NoError(t, err) + assert.Equal(t, actions_model.StatusWaiting, run.Status) + latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context()) + require.NoError(t, err) + require.True(t, hasLatestAttempt) + + job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198) + assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status) + assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt) + assert.Equal(t, int64(0), job198Rerun.TaskID) + assert.Equal(t, int64(53), job198Rerun.SourceTaskID) + + job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199) + assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status) + assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt) + assert.Equal(t, int64(0), job199Rerun.TaskID) + assert.Equal(t, int64(0), job199Rerun.SourceTaskID) + }) + + t.Run("Success", func(t *testing.T) { + // JobSuccess above leaves the run waiting, so finish it to make it rerunnable again. + // Run on its own the fixture run is still done and needs no cancelling. + run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) + require.NoError(t, err) + if !run.Status.IsDone() { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/cancel", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusOK) + } + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())). + AddTokenAuth(writeToken) + resp := MakeRequest(t, req, http.StatusCreated) + + rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) assert.Equal(t, int64(795), rerunResp.ID) assert.Equal(t, "queued", rerunResp.Status) assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha) - run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) + run, err = actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) require.NoError(t, err) assert.Equal(t, actions_model.StatusWaiting, run.Status) assert.Equal(t, timeutil.TimeStamp(0), run.Started) @@ -230,67 +317,136 @@ func testAPIActionsRerunWorkflowRun(t *testing.T) { AddTokenAuth(writeToken) MakeRequest(t, req, http.StatusNotFound) }) -} - -func testAPIActionsRerunWorkflowJob(t *testing.T) { - t.Run("NotDone", func(t *testing.T) { - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - session := loginUser(t, user.Name) - writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())). - AddTokenAuth(writeToken) - MakeRequest(t, req, http.StatusBadRequest) - }) - - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - session := loginUser(t, user.Name) - - writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) - - t.Run("Success", func(t *testing.T) { - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).AddTokenAuth(writeToken) - resp := MakeRequest(t, req, http.StatusCreated) - rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{}) - - job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199) - assert.Equal(t, job199Rerun.ID, rerunResp.ID) - assert.Equal(t, "queued", rerunResp.Status) - - run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795) - require.NoError(t, err) - assert.Equal(t, actions_model.StatusWaiting, run.Status) - latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context()) - require.NoError(t, err) - require.True(t, hasLatestAttempt) - - job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198) - assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status) - assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt) - assert.Equal(t, int64(0), job198Rerun.TaskID) - assert.Equal(t, int64(53), job198Rerun.SourceTaskID) - - job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199) - assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status) - assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt) - assert.Equal(t, int64(0), job199Rerun.TaskID) - assert.Equal(t, int64(0), job199Rerun.SourceTaskID) - }) - - t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) { - req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())). - AddTokenAuth(readToken) - MakeRequest(t, req, http.StatusForbidden) - }) t.Run("NotFoundJob", func(t *testing.T) { req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())). AddTokenAuth(writeToken) MakeRequest(t, req, http.StatusNotFound) }) + + t.Run("NoLogsAfterRerun", func(t *testing.T) { + // the full rerun above cleared both TaskID and SourceTaskID on every latest-attempt job + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(writeToken) + MakeRequest(t, req, http.StatusNotFound) + }) +} + +func testAPIActionsCancelWorkflowRun(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + ownerSession := loginUser(t, owner.Name) + ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository) + + t.Run("Success", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(ownerToken) + resp := MakeRequest(t, req, http.StatusOK) + cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.Equal(t, int64(793), cancelledRun.ID) + assert.Equal(t, "completed", cancelledRun.Status) + assert.Equal(t, "cancelled", cancelledRun.Conclusion) + }) + + t.Run("AlreadyCompleted", func(t *testing.T) { + // run 791 already succeeded, so there is nothing left to cancel + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/cancel", repo.FullName())). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("NotFound", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/cancel", repo.FullName())). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("ForbiddenWithoutPermission", func(t *testing.T) { + // user2 is not the owner of repo4 (owned by user5) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusForbidden) + }) +} + +func testAPIActionsApproveWorkflowRun(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + // user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all + ownerToken := getTokenForLoggedInUser(t, loginUser(t, "user5"), auth_model.AccessTokenScopeWriteRepository) + writerToken := getTokenForLoggedInUser(t, loginUser(t, "user4"), auth_model.AccessTokenScopeWriteRepository) + strangerToken := getTokenForLoggedInUser(t, loginUser(t, "user2"), auth_model.AccessTokenScopeWriteRepository) + + // a fork PR from a first-time contributor is what produces these in practice, which + // actions_approve_test.go already covers end to end + insertBlockedRun := func(index int64) *actions_model.ActionRun { + run := &actions_model.ActionRun{ + Title: "needs approval", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index, + TriggerUserID: 4, Ref: "refs/heads/main", CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "pull_request", TriggerEvent: "pull_request", + Status: actions_model.StatusBlocked, NeedApproval: true, + } + require.NoError(t, db.Insert(t.Context(), run)) + require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{ + RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, + Name: "job1", Attempt: 1, JobID: "job1", Status: actions_model.StatusBlocked, RunsOn: []string{"ubuntu-latest"}, + })) + return run + } + + assertApproved := func(t *testing.T, runID, approverID int64) { + t.Helper() + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.False(t, run.NeedApproval) + assert.Equal(t, approverID, run.ApprovedBy) + jobs, err := actions_model.GetLatestAttemptJobsByRun(t.Context(), run) + require.NoError(t, err) + for _, job := range jobs { + assert.Equal(t, actions_model.StatusWaiting, job.Status) + } + } + + run := insertBlockedRun(2001) + + t.Run("ForbiddenWithoutPermission", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)). + AddTokenAuth(strangerToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("AsOwner", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)). + AddTokenAuth(ownerToken) + resp := MakeRequest(t, req, http.StatusOK) + apiRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{}) + assert.Equal(t, run.ID, apiRun.ID) + assertApproved(t, run.ID, 5) + }) + + t.Run("AgainIsIdempotent", func(t *testing.T) { + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusOK) + assertApproved(t, run.ID, 5) + }) + + t.Run("AsWriterNonAdmin", func(t *testing.T) { + writerRun := insertBlockedRun(2002) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), writerRun.ID)). + AddTokenAuth(writerToken) + MakeRequest(t, req, http.StatusOK) + assertApproved(t, writerRun.ID, 4) + }) + + t.Run("NotRequired", func(t *testing.T) { + // run 791 succeeded without ever awaiting approval + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/approve", repo.FullName())). + AddTokenAuth(ownerToken) + MakeRequest(t, req, http.StatusConflict) + }) } func testAPIActionsListUserWorkflows(t *testing.T) { @@ -373,6 +529,73 @@ func testAPIActionsListRepoWorkflows(t *testing.T) { } } +func testAPIActionsGetWorkflowRunLogsNotFound(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + t.Run("NoLogs", func(t *testing.T) { + // Run 795 has jobs but fixture tasks have no log output in storage. + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("RunNotFound", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} + +// seedTaskLogs writes task logs the way the runner does, in DBFS to stay independent of the object storage fixture. +func seedTaskLogs(t *testing.T, taskID int64, lines ...string) { + t.Helper() + + task, err := actions_model.GetTaskByID(t.Context(), taskID) + require.NoError(t, err) + + task.LogInStorage = false + task.LogFilename = fmt.Sprintf("test-logs/%d.log", task.ID) + + rows := make([]*runnerv1.LogRow, 0, len(lines)) + for _, line := range lines { + rows = append(rows, &runnerv1.LogRow{Time: timestamppb.New(time.Unix(1683636528, 0)), Content: line}) + } + ns, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, rows) + require.NoError(t, err) + + task.LogLength = int64(len(rows)) + for _, n := range ns { + task.LogIndexes = append(task.LogIndexes, task.LogSize) + task.LogSize += int64(n) + } + require.NoError(t, actions_model.UpdateTask(t.Context(), task, + "log_filename", "log_in_storage", "log_indexes", "log_length", "log_size")) +} + +// the success path is covered against real runner logs by TestDownloadTaskLogs +func testAPIActionsGetWorkflowJobLogsNotFound(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + t.Run("NoLogFile", func(t *testing.T) { + // job 199 exists but its task has no log file in the test fixture + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/199/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("JobNotFound", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/999999/logs", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} + // TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not // leak runs/jobs from repos the caller cannot access. func TestAPIOrgActionsRunsAccessControl(t *testing.T) {