diff --git a/models/actions/run_job_summary.go b/models/actions/run_job_summary.go new file mode 100644 index 0000000000..a523839ee0 --- /dev/null +++ b/models/actions/run_job_summary.go @@ -0,0 +1,114 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +const ( + // JobSummaryCapability is the runner-declare capability string for job summaries. + JobSummaryCapability = "job-summary" + + // JobSummaryContentTypeMarkdown is the only accepted content type for job summaries. + JobSummaryContentTypeMarkdown = "text/markdown" + + // MaxJobSummarySize is the maximum accepted summary payload size in bytes. + // This is intentionally conservative to avoid DB bloat and UI abuse. + MaxJobSummarySize = 1024 * 1024 // 1 MiB +) + +// ActionRunJobSummary stores the raw job summary markdown uploaded by the runner. +// It is internal state (not a downloadable artifact). +type ActionRunJobSummary struct { + ID int64 `xorm:"pk autoincr"` + + RepoID int64 `xorm:"UNIQUE(summary_key) INDEX"` + RunID int64 `xorm:"UNIQUE(summary_key) INDEX"` + RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0 INDEX"` + JobID int64 `xorm:"UNIQUE(summary_key) INDEX"` + + Content string `xorm:"LONGTEXT"` + ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"` + + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(ActionRunJobSummary)) +} + +func GetActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID int64) (*ActionRunJobSummary, error) { + var s ActionRunJobSummary + has, err := db.GetEngine(ctx). + Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=?", repoID, runID, runAttemptID, jobID). + Get(&s) + if err != nil { + return nil, err + } + if !has { + return nil, util.ErrNotExist + } + return &s, nil +} + +func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID int64, contentType string, content []byte) error { + if runID <= 0 || jobID <= 0 || repoID <= 0 { + return util.ErrInvalidArgument + } + if len(content) == 0 { + // Treat empty summaries as no-op; runner may create SUMMARY.md but never write to it. + return nil + } + if len(content) > MaxJobSummarySize { + return util.ErrInvalidArgument + } + if contentType == "" { + contentType = JobSummaryContentTypeMarkdown + } + if contentType != JobSummaryContentTypeMarkdown { + return util.ErrInvalidArgument + } + + engine := db.GetEngine(ctx) + + existing, err := GetActionRunJobSummary(ctx, repoID, runID, runAttemptID, jobID) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return err + } + + if existing == nil { + _, err := engine.Insert(&ActionRunJobSummary{ + RepoID: repoID, + RunID: runID, + RunAttemptID: runAttemptID, + JobID: jobID, + Content: string(content), + ContentType: contentType, + }) + return err + } + + existing.Content = string(content) + existing.ContentType = contentType + _, err = engine.ID(existing.ID).Cols("content", "content_type").Update(existing) + return err +} + +func ListActionRunJobSummariesByRunAttempt(ctx context.Context, repoID, runID, runAttemptID int64) ([]*ActionRunJobSummary, error) { + var summaries []*ActionRunJobSummary + if err := db.GetEngine(ctx). + Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID). + OrderBy("job_id ASC"). + Find(&summaries); err != nil { + return nil, err + } + return summaries, nil +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..f25a22a3dd 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..f0e9d6b8a0 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + + "code.gitea.io/gitea/models/actions" + + "xorm.io/xorm" +) + +func AddActionRunJobSummaryTable(ctx context.Context, x *xorm.Engine) error { + return x.Sync(new(actions.ActionRunJobSummary)) +} diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 838ddb7f91..263fbcefa2 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -121,6 +121,9 @@ func ArtifactsRoutes(prefix string) *web.Router { m.Get("/{artifact_id}/download", r.downloadArtifact) }) + // Job summary upload endpoint (GITHUB_STEP_SUMMARY). + m.Put(jobSummaryRouteBase, uploadJobSummary) + return m } diff --git a/routers/api/actions/job_summary.go b/routers/api/actions/job_summary.go new file mode 100644 index 0000000000..cff54c787e --- /dev/null +++ b/routers/api/actions/job_summary.go @@ -0,0 +1,96 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "io" + "mime" + "net/http" + "strconv" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/summary" + +func uploadJobSummary(ctx *ArtifactContext) { + task, runID, ok := validateRunID(ctx) + if !ok { + return + } + + jobID := ctx.PathParamInt64("job_id") + if jobID <= 0 { + ctx.HTTPError(http.StatusBadRequest, "invalid job_id") + return + } + + if task == nil || task.Job == nil { + ctx.HTTPError(http.StatusInternalServerError, "task/job not loaded") + return + } + if task.Job.ID != jobID { + ctx.HTTPError(http.StatusBadRequest, "job_id mismatch") + return + } + if task.Job.RunID != runID { + ctx.HTTPError(http.StatusBadRequest, "run_id mismatch") + return + } + + body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, actions_model.MaxJobSummarySize+1)) + if err != nil { + log.Error("Error reading job summary request body: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "read request body") + return + } + if len(body) == 0 { + ctx.JSON(http.StatusOK, map[string]string{"message": "empty"}) + return + } + + contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type")) + if !ok { + ctx.HTTPError(http.StatusBadRequest, "invalid summary content type") + return + } + + if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, contentType, body); err != nil { + if errorsIsInvalidArg(err) { + ctx.HTTPError(http.StatusBadRequest, "invalid summary") + return + } + log.Error("Error upsert job summary: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error upsert job summary") + return + } + + ctx.JSON(http.StatusOK, map[string]string{ + "message": "success", + "sizeBytes": strconv.Itoa(len(body)), + "runAttempt": strconv.FormatInt(task.Job.RunAttemptID, 10), + }) +} + +func errorsIsInvalidArg(err error) bool { + return errors.Is(err, util.ErrInvalidArgument) +} + +func normalizeJobSummaryContentType(contentType string) (string, bool) { + if contentType == "" || contentType == "application/octet-stream" { + return actions_model.JobSummaryContentTypeMarkdown, true + } + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "", false + } + if mediaType != actions_model.JobSummaryContentTypeMarkdown { + return "", false + } + return actions_model.JobSummaryContentTypeMarkdown, true +} diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index eee39760ed..e785f4d0f9 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -118,7 +118,7 @@ func (s *Service) Declare( return nil, status.Errorf(codes.Internal, "update runner: %v", err) } - return connect.NewResponse(&runnerv1.DeclareResponse{ + resp := connect.NewResponse(&runnerv1.DeclareResponse{ Runner: &runnerv1.Runner{ Id: runner.ID, Uuid: runner.UUID, @@ -127,7 +127,11 @@ func (s *Service) Declare( Version: runner.Version, Labels: runner.AgentLabels, }, - }), nil + }) + // Capabilities are communicated via headers to avoid a hard dependency on a proto bump. + // Older runners ignore unknown headers; newer runners can use this for feature negotiation. + resp.Header().Set("X-Gitea-Actions-Capabilities", actions_model.JobSummaryCapability) + return resp, nil } // FetchTask assigns a task to the runner diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 83e9bef9c8..56b7c91cc4 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -15,6 +15,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -90,6 +91,7 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.WorkflowID = "workflow-id" resp.State.Run.WorkflowLink = "./workflow-link" resp.State.Run.TriggerEvent = "push" + renderUtils := templates.NewRenderUtils(ctx) resp.State.Run.Commit = actions.ViewCommit{ ShortSha: "ccccdddd", Link: "./commit-link", @@ -185,6 +187,22 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.CanRerun = runID == 30 && isLatestAttempt resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt + // Mock job summaries so the devtest page can preview the Summary panel rendering. + resp.State.Run.JobSummaries = []*actions.ViewJobSummary{ + { + JobID: runID * 10, + JobName: "job 100 (testsubname)", + ContentType: "text/markdown", + SummaryHTML: renderUtils.MarkdownToHtml("### Devtest job summary\n\n- Markdown rendering\n- Links: [example](https://example.com)\n\n```sh\necho hello\n```\n"), + }, + { + JobID: runID*10 + 2, + JobName: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit", + ContentType: "text/markdown", + SummaryHTML: renderUtils.MarkdownToHtml("### Another summary\n\nThis demonstrates multiple job summaries in one run.\n\n- Item A\n- Item B\n"), + }, + } + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ Name: "artifact-a", Size: 100 * 1024, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 726ab63e56..a7e765f8d9 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -300,6 +300,8 @@ type ViewResponse struct { Duration string `json:"duration"` TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule + + JobSummaries []*ViewJobSummary `json:"jobSummaries,omitempty"` } `json:"run"` CurrentJob struct { Title string `json:"title"` @@ -323,6 +325,13 @@ type ViewJob struct { Needs []string `json:"needs,omitempty"` } +type ViewJobSummary struct { + JobID int64 `json:"jobId"` + JobName string `json:"jobName"` + ContentType string `json:"contentType"` + SummaryHTML template.HTML `json:"summaryHTML"` +} + type ViewRunAttempt struct { Attempt int64 `json:"attempt"` Status string `json:"status"` @@ -497,6 +506,39 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, } resp.State.Run.TriggerEvent = run.TriggerEvent + // Job summaries (GITHUB_STEP_SUMMARY). Only show when present. + { + var runAttemptID int64 + if attempt != nil { + runAttemptID = attempt.ID + } + summaries, err := actions_model.ListActionRunJobSummariesByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID) + if err != nil { + ctx.ServerError("ListActionRunJobSummariesByRunAttempt", err) + return + } + if len(summaries) > 0 { + jobNameByID := make(map[int64]string, len(jobs)) + for _, j := range jobs { + jobNameByID[j.ID] = j.Name + } + resp.State.Run.JobSummaries = make([]*ViewJobSummary, 0, len(summaries)) + renderUtils := templates.NewRenderUtils(ctx) + for _, s := range summaries { + if s.ContentType != actions_model.JobSummaryContentTypeMarkdown { + log.Warn("Skip unsupported job summary content type %q for run %d job %d", s.ContentType, s.RunID, s.JobID) + continue + } + resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, &ViewJobSummary{ + JobID: s.JobID, + JobName: jobNameByID[s.JobID], + ContentType: s.ContentType, + SummaryHTML: renderUtils.MarkdownToHtml(s.Content), + }) + } + } + } + // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0, // so passing 0 here scopes to this run's legacy artifacts only. var runAttemptID int64 diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go index 66a00a6773..a4f5fe7e08 100644 --- a/tests/integration/actions_route_test.go +++ b/tests/integration/actions_route_test.go @@ -63,6 +63,8 @@ jobs: task2 := runner2.fetchTask(t) _, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id) + require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, "text/markdown", []byte("### Hello summary\n\nFrom job summary.\n"))) + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID)) user2Session.MakeRequest(t, req, http.StatusOK) @@ -75,6 +77,9 @@ jobs: viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{}) assert.Len(t, viewResp.State.Run.Jobs, 1) assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID) + require.Len(t, viewResp.State.Run.JobSummaries, 1) + assert.Equal(t, job1.ID, viewResp.State.Run.JobSummaries[0].JobID) + assert.Contains(t, string(viewResp.State.Run.JobSummaries[0].SummaryHTML), "Hello summary") // run2 and job2 do not belong to repo1, failure req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID)) diff --git a/tests/integration/api_actions_artifact_test.go b/tests/integration/api_actions_artifact_test.go index 4670bb0704..c45d38001b 100644 --- a/tests/integration/api_actions_artifact_test.go +++ b/tests/integration/api_actions_artifact_test.go @@ -16,6 +16,7 @@ import ( "strings" "testing" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -44,6 +45,67 @@ func prepareTestEnvActionsArtifacts(t *testing.T) func() { return f } +func getArtifactFixtureTask(t *testing.T) *actions_model.ActionTask { + t.Helper() + + task, err := actions_model.GetRunningTaskByToken(t.Context(), "8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + require.NoError(t, err) + require.NoError(t, task.LoadJob(t.Context())) + return task +} + +func TestActionsJobSummaryUpload(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + task := getArtifactFixtureTask(t) + summaryURL := fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/summary", task.Job.RunID, task.Job.ID) + + t.Run("success", func(t *testing.T) { + body := "### Uploaded summary\n\n- line one\n" + req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader(body)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Type", "text/markdown; charset=utf-8") + MakeRequest(t, req, http.StatusOK) + + summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID) + require.NoError(t, err) + assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType) + assert.Equal(t, body, summary.Content) + }) + + t.Run("invalid-content-type", func(t *testing.T) { + req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader("summary")). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Type", "text/html") + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "invalid summary content type") + }) + + t.Run("size-limit", func(t *testing.T) { + req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader(strings.Repeat("a", actions_model.MaxJobSummarySize+1))). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "invalid summary") + }) + + t.Run("job-mismatch", func(t *testing.T) { + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "job_id mismatch") + }) + + t.Run("run-mismatch", func(t *testing.T) { + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "run-id does not match") + }) +} + func TestActionsArtifactUploadSingleFile(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index afbc0a13bf..2485f2dc6a 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -53,6 +53,7 @@ onBeforeUnmount(() => { {{ locale.status[run.status] }}{{ locale.totalDuration }} {{ run.duration || '–' }} + { border-radius: var(--border-radius) var(--border-radius) 0 0; background: var(--color-box-header); } + diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 91fe8329bd..9a284cefc0 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -110,6 +110,7 @@ export function createEmptyActionsRun(): ActionsRun { triggeredAt: 0, triggerEvent: '', jobs: [] as Array, + jobSummaries: [], commit: { localeCommit: '', localePushedBy: '', diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 725a996028..73d28bab90 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -206,18 +206,36 @@ async function deleteArtifact(name: string) {
- - + +
+ +
@@ -342,25 +360,32 @@ async function deleteArtifact(name: string) { width: 70%; display: flex; flex-direction: column; + gap: 12px; +} + +.action-view-right-panel { border: 1px solid var(--color-console-border); border-radius: var(--border-radius); background: var(--color-console-bg); + display: flex; + flex-direction: column; + min-height: 0; } /* begin fomantic button overrides */ -.action-view-right .ui.button, -.action-view-right .ui.button:focus { +.action-view-right-panel .ui.button, +.action-view-right-panel .ui.button:focus { background: transparent; color: var(--color-console-fg-subtle); } -.action-view-right .ui.button:hover { +.action-view-right-panel .ui.button:hover { background: var(--color-console-hover-bg); color: var(--color-console-fg); } -.action-view-right .ui.button:active { +.action-view-right-panel .ui.button:active { background: var(--color-console-active-bg); color: var(--color-console-fg); } @@ -378,4 +403,39 @@ async function deleteArtifact(name: string) { max-width: none; } } + +.job-summary-section { + overflow: hidden; +} + +.job-summary-section-header { + padding: 12px; + border-bottom: 1px solid var(--color-console-border); + background: var(--color-console-bg); + color: var(--color-console-fg); + font-weight: var(--font-weight-semibold); +} + +.job-summary-list { + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.job-summary-item { + padding: 12px; + border-radius: var(--border-radius); + background: var(--color-console-hover-bg); + border: 1px solid var(--color-console-border); +} + +.job-summary-header { + color: var(--color-console-fg); + margin-bottom: 8px; +} + +.job-summary-body { + color: var(--color-console-fg); +} diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts index d4cb4b0d1e..be70388075 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -24,6 +24,7 @@ export type ActionsRun = { triggeredAt: number, triggerEvent: string, jobs: Array, + jobSummaries?: Array, commit: { localeCommit: string, localePushedBy: string, @@ -41,6 +42,13 @@ export type ActionsRun = { }, }; +export type ActionsJobSummary = { + jobId: number; + jobName: string; + contentType: string; + summaryHTML: string; +}; + export type ActionsRunAttempt = { attempt: number; status: ActionsStatus;