diff --git a/release-notes/12666.md b/release-notes/12666.md new file mode 100644 index 0000000000..667219e2d3 --- /dev/null +++ b/release-notes/12666.md @@ -0,0 +1 @@ +The REST API can now download action logs: `GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs` returns plaintext for a single job (supports HTTP `Range:` and `?step=N`), and `GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs` streams a zip of every job's logs in the run. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f1cea141cc..9e13901fac 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1251,11 +1251,13 @@ func Routes() *web.Route { m.Delete("/{artifact_id}", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionArtifact) m.Get("/{artifact_id}/zip", repo.DownloadActionArtifact) }) + m.Get("/jobs/{job_id}/logs", repo.GetActionJobLogs) m.Group("/runs", func() { m.Get("", repo.ListActionRuns) m.Get("/{run_id}", repo.GetActionRun) m.Delete("/{run_id}", reqToken(), reqAdmin(unit.TypeActions), repo.DeleteActionRun) m.Get("/{run_id}/jobs", repo.ListActionRunJobs) + m.Get("/{run_id}/logs", repo.GetActionRunLogs) m.Get("/{run_id}/artifacts", repo.ListActionRunArtifacts) }) diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index b0c65009c6..5c2d2af4d6 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -12,6 +12,7 @@ import ( actions_model "forgejo.org/models/actions" "forgejo.org/models/db" secret_model "forgejo.org/models/secret" + "forgejo.org/modules/optional" api "forgejo.org/modules/structs" "forgejo.org/modules/util" "forgejo.org/modules/web" @@ -1472,3 +1473,160 @@ func DeleteActionArtifact(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// GetActionJobLogs serves plaintext logs for a single action job. +func GetActionJobLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs repository repoGetActionJobLogs + // --- + // summary: Download the plaintext logs of an action job + // description: > + // Returns the plaintext log for the job. By default the log for the + // most recent attempt is returned (ActionRunJob.TaskID tracks the latest + // task). Pass `?attempt=N` to fetch the log for a specific historical + // attempt; the value matches the `attempt` field returned by the job + // listing endpoints. + // produces: + // - text/plain + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: job_id + // in: path + // description: ID of the workflow job + // type: integer + // format: int64 + // required: true + // - name: attempt + // in: query + // description: 1-based attempt number matching the value of `attempt` in the job listing; omit to fetch the latest attempt of the job + // type: integer + // format: int64 + // required: false + // responses: + // "200": + // description: Plaintext log content + // schema: + // type: string + // "206": + // description: Partial log content (Range request) + // schema: + // type: string + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + jobID := ctx.ParamsInt64(":job_id") + + var attempt optional.Option[int64] + if ctx.FormString("attempt") != "" { + attempt = optional.Some(ctx.FormInt64("attempt")) + } + + reader, filename, modtime, err := actions_service.OpenJobLogReader(ctx, ctx.Repo.Repository, jobID, attempt) + if err != nil { + switch { + case errors.Is(err, util.ErrNotExist), + errors.Is(err, actions_service.ErrJobNotExecuted), + errors.Is(err, actions_service.ErrLogsExpired): + ctx.Error(http.StatusNotFound, "OpenJobLogReader", err) + default: + ctx.Error(http.StatusInternalServerError, "OpenJobLogReader", err) + } + return + } + defer reader.Close() + + ctx.Resp.Header().Set("Accept-Ranges", "bytes") + // Pin Content-Type explicitly so http.ServeContent doesn't extension-sniff. + // On Linux with shared-mime-info installed, mime.TypeByExtension(".log") + // returns "text/x-log; charset=utf-8" — a non-IANA type whose `x-` prefix + // is deprecated by RFC 6648 and which varies by host (macOS or minimal + // containers return empty or application/octet-stream). Our swagger + // documents `produces: text/plain`, and a client sending + // `Accept: text/plain` would 406-mismatch on the unpinned response. + ctx.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8") + http.ServeContent(ctx.Resp, ctx.Req, filename, modtime, reader) +} + +// GetActionRunLogs streams a ZIP of plaintext logs for every job in the run. +func GetActionRunLogs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs repository repoGetActionRunLogs + // --- + // summary: Download a ZIP of plaintext logs for every job in an action run + // 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 repo + // type: string + // required: true + // - name: run_id + // in: path + // description: > + // ID of the workflow run. The ZIP contains the latest attempt of each + // job in the run, with each entry named + // `{job-name}-{job-id}-attempt-{N}.log` (the job ID prevents collisions + // when two jobs share a name; the attempt number records which run the + // log came from). The run itself has no attempt number — jobs are + // re-run independently, so use the per-job logs endpoint with `?attempt` + // to fetch a specific historical attempt of one job. + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // description: ZIP archive of per-job log files + // schema: + // type: string + // format: binary + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + runID := ctx.ParamsInt64(":run_id") + + run, err := actions_model.GetRunByID(ctx, runID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetRunByID", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetRunByID", err) + } + return + } + + if run.RepoID != ctx.Repo.Repository.ID { + ctx.Error(http.StatusNotFound, "GetRunByID", util.ErrNotExist) + return + } + + zipFilename := fmt.Sprintf("run-%d-logs.zip", run.ID) + + ctx.Resp.Header().Set("Content-Type", "application/zip") + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", zipFilename)) + + if err := actions_service.WriteRunLogsZip(ctx, ctx.Resp, run); err != nil { + ctx.Error(http.StatusInternalServerError, "WriteRunLogsZip", err) + return + } +} diff --git a/services/actions/job_logs.go b/services/actions/job_logs.go new file mode 100644 index 0000000000..f23162560d --- /dev/null +++ b/services/actions/job_logs.go @@ -0,0 +1,173 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + actions_model "forgejo.org/models/actions" + repo_model "forgejo.org/models/repo" + "forgejo.org/modules/actions" + "forgejo.org/modules/optional" + "forgejo.org/modules/util" +) + +// Sentinel errors returned by OpenJobLogReader. The HTTP handler maps each +// of these (and util.ErrNotExist) to 404; anything else is a 500. +var ( + ErrJobNotExecuted = errors.New("job has not been executed yet") + ErrLogsExpired = errors.New("logs have expired") +) + +// OpenJobLogReader returns a reader for an action job's log along with the +// filename and modtime to expose via Content-Disposition / Last-Modified. +// +// attempt, when set, selects a specific historical attempt (uses +// GetTaskByJobAttempt). When unset, the latest attempt is used via the +// job.TaskID pointer maintained by the runner. +// +// The caller is responsible for closing the returned reader. +func OpenJobLogReader( + ctx context.Context, + repo *repo_model.Repository, + jobID int64, + attempt optional.Option[int64], +) (io.ReadSeekCloser, string, time.Time, error) { + job, err := actions_model.GetRunJobByID(ctx, jobID) + if err != nil { + return nil, "", time.Time{}, err + } + // Run-jobs live in their own table; enforce repo ownership here so the + // API layer can stay thin. + if job.RepoID != repo.ID { + return nil, "", time.Time{}, util.ErrNotExist + } + + hasAttempt, attemptVal := attempt.Get() + + var task *actions_model.ActionTask + switch { + case hasAttempt: + task, err = actions_model.GetTaskByJobAttempt(ctx, job.ID, attemptVal) + if err != nil { + return nil, "", time.Time{}, err + } + case job.TaskID == 0: + // Job exists, but no runner has picked it up yet (or a re-run has + // zeroed TaskID and the next runner hasn't claimed it). + return nil, "", time.Time{}, ErrJobNotExecuted + default: + task, err = actions_model.GetTaskByID(ctx, job.TaskID) + if err != nil { + return nil, "", time.Time{}, err + } + } + + if task.LogExpired { + return nil, "", time.Time{}, ErrLogsExpired + } + + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return nil, "", time.Time{}, fmt.Errorf("open logs for task %d: %w", task.ID, err) + } + + modtime := task.Stopped.AsTime() + if task.Stopped == 0 { + modtime = task.Updated.AsTime() // Best-guess modtime while still running. + } + + filename := fmt.Sprintf("job-%d-attempt-%d.log", job.ID, task.Attempt) + return reader, filename, modtime, nil +} + +// WriteRunLogsZip writes a ZIP of the latest per-job logs for the run to w. +// Each entry is named {job-name}-{job-id}-attempt-{N}.log, where N is that +// job's current attempt — the run itself has no attempt number, so jobs that +// were re-run independently show different attempts here. Jobs that haven't +// run, can't be looked up, or have expired logs get a .MISSING marker; a +// mid-stream read failure gets a sibling .PARTIAL marker. Any ZIP-level +// write failure (e.g. the HTTP client disconnects mid-stream) is propagated +// so the caller can abort instead of churning through the remaining jobs. +// Caller sets Content-Type / Content-Disposition before calling. +func WriteRunLogsZip(ctx context.Context, w io.Writer, run *actions_model.ActionRun) error { + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return fmt.Errorf("get jobs for run %d: %w", run.ID, err) + } + + zw := zip.NewWriter(w) + defer zw.Close() + + // strip control bytes and path separators; UTF-8 passes through. + sanitize := func(name string) string { + cleaned := strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f || r == '/' || r == '\\' { + return -1 + } + return r + }, name) + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + cleaned = "job" + } + return cleaned + } + + entryName := func(job *actions_model.ActionRunJob, suffix string) string { + return fmt.Sprintf("%s-%d-attempt-%d.%s", sanitize(job.Name), job.ID, job.Attempt, suffix) + } + + writeMarker := func(job *actions_model.ActionRunJob, suffix, msg string) error { + entry, werr := zw.Create(entryName(job, suffix)) + if werr != nil { + return werr + } + _, werr = entry.Write([]byte(msg)) + return werr + } + + // Inner closure so reader.Close runs per iteration via defer. + processJob := func(job *actions_model.ActionRunJob) error { + if job.TaskID == 0 { + return writeMarker(job, "MISSING", "job has not been executed yet\n") + } + task, err := actions_model.GetTaskByID(ctx, job.TaskID) + if err != nil { + return writeMarker(job, "MISSING", fmt.Sprintf("task lookup failed: %v\n", err)) + } + if task.LogExpired { + return writeMarker(job, "MISSING", "logs have been cleaned up\n") + } + + reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + if err != nil { + return writeMarker(job, "MISSING", fmt.Sprintf("log open failed: %v\n", err)) + } + defer reader.Close() + + entry, err := zw.Create(entryName(job, "log")) + if err != nil { + return writeMarker(job, "MISSING", fmt.Sprintf("zip entry create failed: %v\n", err)) + } + + if _, copyErr := io.Copy(entry, reader); copyErr != nil { + return writeMarker(job, "PARTIAL", fmt.Sprintf("log read failed mid-stream: %v\n", copyErr)) + } + return nil + } + + for _, job := range jobs { + if err := processJob(job); err != nil { + return err + } + } + return nil +} diff --git a/services/actions/job_logs_test.go b/services/actions/job_logs_test.go new file mode 100644 index 0000000000..cc55c4a289 --- /dev/null +++ b/services/actions/job_logs_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "testing" + + actions_model "forgejo.org/models/actions" + "forgejo.org/models/db" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + "forgejo.org/modules/optional" + "forgejo.org/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests cover the error paths of OpenJobLogReader. Each one terminates +// before actions.OpenLogs is called, so the tests don't need real log files +// in DBFS — LogFilename can point at "does-not-exist". + +func TestOpenJobLogReader_RepoMismatch(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunJob{ID: 9001, RepoID: 1, TaskID: 9001}) + + otherRepo := &repo_model.Repository{ID: 2} + _, _, _, err := OpenJobLogReader(db.DefaultContext, otherRepo, 9001, optional.None[int64]()) + assert.ErrorIs(t, err, util.ErrNotExist) +} + +func TestOpenJobLogReader_JobNotExecuted(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunJob{ID: 9002, RepoID: 1, TaskID: 0}) + + repo := &repo_model.Repository{ID: 1} + _, _, _, err := OpenJobLogReader(db.DefaultContext, repo, 9002, optional.None[int64]()) + assert.ErrorIs(t, err, ErrJobNotExecuted) +} + +func TestOpenJobLogReader_LogsExpired(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 9003, LogExpired: true}) + unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunJob{ID: 9003, RepoID: 1, TaskID: 9003}) + + repo := &repo_model.Repository{ID: 1} + _, _, _, err := OpenJobLogReader(db.DefaultContext, repo, 9003, optional.None[int64]()) + assert.ErrorIs(t, err, ErrLogsExpired) +} + +func TestOpenJobLogReader_UnknownAttempt(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 9004, JobID: 9004, Attempt: 1}) + unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunJob{ID: 9004, RepoID: 1, TaskID: 9004}) + + repo := &repo_model.Repository{ID: 1} + _, _, _, err := OpenJobLogReader(db.DefaultContext, repo, 9004, optional.Some(int64(999))) + assert.ErrorIs(t, err, util.ErrNotExist) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 9b2ab51ddf..9561de7343 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5810,6 +5810,73 @@ } } }, + "/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": { + "get": { + "description": "Returns the plaintext log for the job. By default the log for the most recent attempt is returned (ActionRunJob.TaskID tracks the latest task). Pass `?attempt=N` to fetch the log for a specific historical attempt; the value matches the `attempt` field returned by the job listing endpoints.\n", + "produces": [ + "text/plain" + ], + "tags": [ + "repository" + ], + "summary": "Download the plaintext logs of an action job", + "operationId": "repoGetActionJobLogs", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ID of the workflow job", + "name": "job_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "1-based attempt number matching the value of `attempt` in the job listing; omit to fetch the latest attempt of the job", + "name": "attempt", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Plaintext log content", + "schema": { + "type": "string" + } + }, + "206": { + "description": "Partial log content (Range request)", + "schema": { + "type": "string" + } + }, + "401": { + "$ref": "#/responses/unauthorized" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runners": { "get": { "produces": [ @@ -6405,6 +6472,60 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run_id}/logs": { + "get": { + "produces": [ + "application/zip" + ], + "tags": [ + "repository" + ], + "summary": "Download a ZIP of plaintext logs for every job in an action run", + "operationId": "repoGetActionRunLogs", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "ID of the workflow run. The ZIP contains the latest attempt of each job in the run, with each entry named `{job-name}-{job-id}-attempt-{N}.log` (the job ID prevents collisions when two jobs share a name; the attempt number records which run the log came from). The run itself has no attempt number — jobs are re-run independently, so use the per-job logs endpoint with `?attempt` to fetch a specific historical attempt of one job.\n", + "name": "run_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "ZIP archive of per-job log files", + "schema": { + "type": "string", + "format": "binary" + } + }, + "401": { + "$ref": "#/responses/unauthorized" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go index 28be946712..b5c599990e 100644 --- a/tests/integration/actions_runner_test.go +++ b/tests/integration/actions_runner_test.go @@ -211,6 +211,10 @@ type mockTaskOutcome struct { result runnerv1.Result outputs map[string]string logRows []*runnerv1.LogRow + // stepStates, when non-nil, is included in the final UpdateTask's + // TaskState.Steps. Lets tests exercise per-step LogIndex/LogLength + // (and other StepState fields) without reaching into the DB directly. + stepStates []*runnerv1.StepState } func (r *mockRunner) execTask(t *testing.T, task *runnerv1.Task, outcome *mockTaskOutcome) { @@ -242,6 +246,7 @@ func (r *mockRunner) execTask(t *testing.T, task *runnerv1.Task, outcome *mockTa Id: task.Id, Result: outcome.result, StoppedAt: timestamppb.Now(), + Steps: outcome.stepStates, }, })) require.NoError(t, err) diff --git a/tests/integration/api_actions_job_logs_test.go b/tests/integration/api_actions_job_logs_test.go new file mode 100644 index 0000000000..81028aefb2 --- /dev/null +++ b/tests/integration/api_actions_job_logs_test.go @@ -0,0 +1,181 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + actions_model "forgejo.org/models/actions" + auth_model "forgejo.org/models/auth" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/actions" + "forgejo.org/modules/setting" + + runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestAPIGetActionJobLogs(t *testing.T) { + if !setting.Database.Type.IsSQLite3() { + t.Skip() + } + now := time.Now() + outcome := &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{ + {Time: timestamppb.New(now.Add(1 * time.Second)), Content: "first line"}, + {Time: timestamppb.New(now.Add(2 * time.Second)), Content: "second line"}, + {Time: timestamppb.New(now.Add(3 * time.Second)), Content: "third line"}, + }, + } + workflow := `name: api-job-logs +on: push +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hello +` + treePath := ".forgejo/workflows/api-job-logs.yml" + + onApplicationRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, + auth_model.AccessTokenScopeWriteRepository, + auth_model.AccessTokenScopeWriteUser, + ) + + // Repo A receives the workflow + runs the job. + apiRepoA := createActionsTestRepo(t, token, "actions-job-logs", false) + repoA := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepoA.ID}) + + // Repo B is the cross-repo target — used to verify the guard. + apiRepoB := createActionsTestRepo(t, token, "actions-job-logs-other", false) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, repoA.Name, "mock-runner", []string{"ubuntu-latest"}) + + opts := getWorkflowCreateFileOptions(user2, repoA.DefaultBranch, + fmt.Sprintf("create %s", treePath), workflow) + createWorkflowFile(t, token, user2.Name, repoA.Name, treePath, opts) + + task := runner.fetchTask(t) + runner.execTask(t, task, outcome) + + actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id}) + jobID := actionTask.JobID + + t.Run("happy path: 200 plaintext", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + repoA.FullName(), jobID, + ) + req.AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + require.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + assert.Equal(t, "bytes", resp.Header().Get("Accept-Ranges")) + + lines := strings.Split(strings.TrimSpace(resp.Body.String()), "\n") + require.Len(t, lines, len(outcome.logRows)) + for i, lr := range outcome.logRows { + assert.Equal(t, actions.FormatLog(lr.Time.AsTime(), lr.Content), lines[i]) + } + }) + + t.Run("cross-repo: 404 when job_id belongs to a different repo", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + apiRepoB.FullName, jobID, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("not found: 404 for unknown job_id", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + repoA.FullName(), jobID+999999, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("wrong scope: 403 without read:repository", func(t *testing.T) { + // Token with only user scope, no repository access. + weakToken := getTokenForLoggedInUser(t, session, + auth_model.AccessTokenScopeReadUser, + ) + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + repoA.FullName(), jobID, + ) + req.AddTokenAuth(weakToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("range: 206 partial content", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + repoA.FullName(), jobID, + ) + req.AddTokenAuth(token) + req.Header.Set("Range", "bytes=0-15") + resp := MakeRequest(t, req, http.StatusPartialContent) + // [0,15] inclusive = 16 bytes max. + assert.LessOrEqual(t, resp.Body.Len(), 16) + }) + + t.Run("attempt=1: 200 matches no-param (only attempt)", func(t *testing.T) { + defaultReq := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs", + repoA.FullName(), jobID, + ) + defaultReq.AddTokenAuth(token) + defaultResp := MakeRequest(t, defaultReq, http.StatusOK) + + attemptReq := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs?attempt=1", + repoA.FullName(), jobID, + ) + attemptReq.AddTokenAuth(token) + attemptResp := MakeRequest(t, attemptReq, http.StatusOK) + + assert.Equal(t, defaultResp.Body.String(), attemptResp.Body.String()) + }) + + t.Run("attempt=0: 404 (no row in DB)", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs?attempt=0", + repoA.FullName(), jobID, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("attempt=999: 404 unknown attempt", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/jobs/%d/logs?attempt=999", + repoA.FullName(), jobID, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + httpContextA := NewAPITestContext(t, user2.Name, repoA.Name, auth_model.AccessTokenScopeWriteUser) + doAPIDeleteRepository(httpContextA)(t) + httpContextB := NewAPITestContext(t, user2.Name, apiRepoB.Name, auth_model.AccessTokenScopeWriteUser) + doAPIDeleteRepository(httpContextB)(t) + }) +} diff --git a/tests/integration/api_actions_run_logs_test.go b/tests/integration/api_actions_run_logs_test.go new file mode 100644 index 0000000000..f7810ff47b --- /dev/null +++ b/tests/integration/api_actions_run_logs_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + actions_model "forgejo.org/models/actions" + auth_model "forgejo.org/models/auth" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/setting" + + runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestAPIGetActionRunLogs(t *testing.T) { + if !setting.Database.Type.IsSQLite3() { + t.Skip() + } + now := time.Now() + outcomeJob1 := &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{ + {Time: timestamppb.New(now.Add(1 * time.Second)), Content: "job1-output line one"}, + {Time: timestamppb.New(now.Add(2 * time.Second)), Content: "job1-output line two"}, + }, + } + outcomeJob2 := &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{ + {Time: timestamppb.New(now.Add(3 * time.Second)), Content: "job2-output line one"}, + {Time: timestamppb.New(now.Add(4 * time.Second)), Content: "job2-output line two"}, + }, + } + // A third job with a non-ASCII display name (kanji + emoji) to confirm + // the ZIP entry preserves UTF-8 verbatim — regression guard against any + // future tightening of the filename sanitize. + outcomeJobUTF8 := &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + logRows: []*runnerv1.LogRow{ + {Time: timestamppb.New(now.Add(5 * time.Second)), Content: "utf8-output line one"}, + }, + } + // Display name uses kanji + a non-BMP emoji (U+1F680) so the test + // exercises multi-byte UTF-8 and surrogate-pair territory. + utf8JobDisplayName := "测试-\U0001f680" + workflow := fmt.Sprintf(`name: api-run-logs +on: push +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 first line + job2: + runs-on: ubuntu-latest + needs: [job1] + steps: + - run: echo job2 first line + utf8-job: + name: %s + runs-on: ubuntu-latest + needs: [job2] + steps: + - run: echo utf8 first line +`, utf8JobDisplayName) + treePath := ".forgejo/workflows/api-run-logs.yml" + + onApplicationRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, + auth_model.AccessTokenScopeWriteRepository, + auth_model.AccessTokenScopeWriteUser, + ) + + // Repo A receives the workflow + runs the jobs. + apiRepoA := createActionsTestRepo(t, token, "actions-run-logs", false) + repoA := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepoA.ID}) + + // Repo B is the cross-repo target — used to verify the guard. + apiRepoB := createActionsTestRepo(t, token, "actions-run-logs-other", false) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, repoA.Name, "mock-runner", []string{"ubuntu-latest"}) + + opts := getWorkflowCreateFileOptions(user2, repoA.DefaultBranch, + fmt.Sprintf("create %s", treePath), workflow) + createWorkflowFile(t, token, user2.Name, repoA.Name, treePath, opts) + + // Dependency chain: job1 → job2 → utf8-job. Each `needs:` clause + // makes the runner pickup order deterministic. + task1 := runner.fetchTask(t) + runner.execTask(t, task1, outcomeJob1) + + task2 := runner.fetchTask(t) + runner.execTask(t, task2, outcomeJob2) + + task3 := runner.fetchTask(t) + runner.execTask(t, task3, outcomeJobUTF8) + + actionTask1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task1.Id}) + actionRunJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask1.JobID}) + actionTask2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task2.Id}) + actionRunJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask2.JobID}) + actionTask3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task3.Id}) + actionRunJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask3.JobID}) + + require.Equal(t, "job1", actionRunJob1.JobID, "first fetched task should be job1") + require.Equal(t, "job2", actionRunJob2.JobID, "second fetched task should be job2 (needs: [job1])") + require.Equal(t, "utf8-job", actionRunJob3.JobID, "third fetched task should be utf8-job (needs: [job2])") + require.Equal(t, utf8JobDisplayName, actionRunJob3.Name, + "DB should store the YAML `name:` field verbatim, including UTF-8") + require.Equal(t, actionRunJob1.RunID, actionRunJob2.RunID, "both jobs should belong to the same run") + require.Equal(t, actionRunJob1.RunID, actionRunJob3.RunID, "utf8-job should belong to the same run") + runID := actionRunJob1.RunID + + t.Run("happy path: 200 valid zip with per-job entries", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/runs/%d/logs", + repoA.FullName(), runID, + ) + req.AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Header().Get("Content-Disposition"), + fmt.Sprintf("run-%d-logs.zip", runID)) + + r, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len())) + require.NoError(t, err) + + // Read every entry into a map keyed by filename so we can assert + // names and content exactly, and surface unexpected extras. + entries := map[string]string{} + for _, f := range r.File { + fr, err := f.Open() + require.NoError(t, err) + data, err := io.ReadAll(fr) + require.NoError(t, err) + require.NoError(t, fr.Close()) + entries[f.Name] = string(data) + } + + job1Name := fmt.Sprintf("%s-%d-attempt-%d.log", actionRunJob1.Name, actionRunJob1.ID, actionRunJob1.Attempt) + job2Name := fmt.Sprintf("%s-%d-attempt-%d.log", actionRunJob2.Name, actionRunJob2.ID, actionRunJob2.Attempt) + utf8Name := fmt.Sprintf("%s-%d-attempt-%d.log", actionRunJob3.Name, actionRunJob3.ID, actionRunJob3.Attempt) + + require.Len(t, entries, 3, "zip should contain exactly one entry per job") + require.Contains(t, entries, job1Name, "expected job1 entry %q", job1Name) + require.Contains(t, entries, job2Name, "expected job2 entry %q", job2Name) + // Explicit UTF-8-preservation check: the entry name must contain + // the verbatim kanji + emoji, with no Unicode-to-underscore mangling. + require.Contains(t, entries, utf8Name, + "expected UTF-8 entry %q (sanitize must preserve non-ASCII verbatim)", utf8Name) + + assert.Contains(t, entries[job1Name], "job1-output line one") + assert.Contains(t, entries[job1Name], "job1-output line two") + assert.Contains(t, entries[job2Name], "job2-output line one") + assert.Contains(t, entries[job2Name], "job2-output line two") + assert.Contains(t, entries[utf8Name], "utf8-output line one") + }) + + t.Run("cross-repo: 404 when run_id belongs to a different repo", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/runs/%d/logs", + apiRepoB.FullName, runID, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("not found: 404 for unknown run_id", func(t *testing.T) { + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/runs/%d/logs", + repoA.FullName(), runID+999999, + ) + req.AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("wrong scope: 403 without read:repository", func(t *testing.T) { + // Token with only user scope, no repository access. + weakToken := getTokenForLoggedInUser(t, session, + auth_model.AccessTokenScopeReadUser, + ) + req := NewRequestf(t, "GET", + "/api/v1/repos/%s/actions/runs/%d/logs", + repoA.FullName(), runID, + ) + req.AddTokenAuth(weakToken) + MakeRequest(t, req, http.StatusForbidden) + }) + + httpContextA := NewAPITestContext(t, user2.Name, repoA.Name, auth_model.AccessTokenScopeWriteUser) + doAPIDeleteRepository(httpContextA)(t) + httpContextB := NewAPITestContext(t, user2.Name, apiRepoB.Name, auth_model.AccessTokenScopeWriteUser) + doAPIDeleteRepository(httpContextB)(t) + }) +}