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 <claude-sonnet-4-6@anthropic.com>
Co-authored-by: OpenCode Agent <opencode@rossgolder.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Ross Golder
2026-08-02 11:22:07 +07:00
committed by GitHub
parent c5b6e044d7
commit a65f422b89
27 changed files with 1230 additions and 177 deletions
+7
View File
@@ -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 ""
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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
}}
}
+9
View File
@@ -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 == "" {
+11 -5
View File
@@ -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 {
+17
View File
@@ -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
+9
View File
@@ -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"`
+54
View File
@@ -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()
}
+20
View File
@@ -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"))
}
+18
View File
@@ -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.
+14
View File
@@ -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
}
+7 -2
View File
@@ -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)
})
})
+12 -7
View File
@@ -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.
+152 -5
View File
@@ -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)
}
}
+11 -5
View File
@@ -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)
+82 -24
View File
@@ -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,
+4 -21
View File
@@ -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)
+27 -4
View File
@@ -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
}
+40 -12
View File
@@ -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)
})
}
+40
View File
@@ -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
}
+4 -2
View File
@@ -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,
+10 -2
View File
@@ -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,
+3
View File
@@ -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"),
+4 -8
View File
@@ -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:]
}
+191
View File
@@ -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",
+177
View File
@@ -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"
+300 -77
View File
@@ -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) {