feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)

- Add GitHub-style Actions **job summaries** support
(`GITHUB_STEP_SUMMARY` / `workflow/SUMMARY.md`) and render them on the
run Summary view.
- Store uploaded summaries internally in the DB (not as downloadable
artifacts).
- Add runtime-token endpoint for runners to upload summaries:
- `PUT
/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/summary`
- Advertise support to runners via `RunnerService.Declare` response
header:
  - `X-Gitea-Actions-Capabilities: job-summary`
- Devtest: extend `/devtest/repo-action-view/...` to include mock
`jobSummaries` for previewing UI rendering.

## Compatibility
- New Gitea + old runner: no summary upload → UI shows nothing (no
behavior change)
- New runner + old Gitea: capability not advertised → runner skips
upload (no behavior change)

## Screenshot:

<img width="2017" height="729"
src="https://github.com/user-attachments/assets/31f8b945-50c4-40e1-9f40-382901a53013"
/>


Fixes #23721
PR on gitea-runner https://gitea.com/gitea/runner/pulls/917

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
bircni
2026-06-08 21:11:00 +02:00
committed by GitHub
parent b1c088e9cf
commit 3b1e75764e
18 changed files with 683 additions and 23 deletions
+3
View File
@@ -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
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"errors"
"io"
"mime"
"net/http"
"slices"
"strconv"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/steps/{step_index}/summary"
func uploadJobSummary(ctx *ArtifactContext) {
task, _, ok := validateRunID(ctx)
if !ok {
return
}
jobID := ctx.PathParamInt64("job_id")
if jobID <= 0 || task.Job.ID != jobID {
ctx.HTTPError(http.StatusBadRequest, "job_id mismatch")
return
}
stepIndex, err := strconv.ParseInt(ctx.PathParam("step_index"), 10, 64)
if err != nil || stepIndex < 0 {
ctx.HTTPError(http.StatusBadRequest, "invalid step_index")
return
}
steps, err := actions_model.GetTaskStepsByTaskID(ctx, task.ID)
if err != nil {
log.Error("Error getting task steps: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error getting task steps")
return
}
if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.Index == stepIndex }) {
ctx.HTTPError(http.StatusBadRequest, "step_index mismatch")
return
}
contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type"))
if !ok {
ctx.HTTPError(http.StatusBadRequest, "invalid summary content type")
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
}
message := "success"
if len(body) == 0 {
// PUT with an empty body clears any previously-stored summary for this step.
if err := actions_model.DeleteActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex); err != nil {
log.Error("Error deleting job summary: %v", err)
ctx.HTTPError(http.StatusInternalServerError, "Error deleting job summary")
return
}
message = "cleared"
} else if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex, contentType, body); err != nil {
if errors.Is(err, actions_model.ErrJobSummaryAggregateExceeded) {
ctx.HTTPError(http.StatusBadRequest, "job summary aggregate size exceeded")
return
}
if errors.Is(err, util.ErrInvalidArgument) {
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]any{
"message": message,
"sizeBytes": len(body),
"runAttempt": task.Job.RunAttemptID,
})
}
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
}
+6 -2
View File
@@ -161,7 +161,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,
@@ -170,7 +170,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.RunnerCapabilities())
return resp, nil
}
// FetchTask assigns a task to the runner
+16
View File
@@ -15,6 +15,7 @@ import (
actions_model "gitea.dev/models/actions"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
@@ -89,6 +90,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id.yml"
resp.State.Run.TriggerEvent = "push"
renderUtils := templates.NewRenderUtils(ctx)
user2, _ := user_model.GetUserByID(ctx, 2)
if user2 == nil {
user2 = &user_model.User{Name: "user2"}
@@ -196,6 +198,20 @@ 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)",
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",
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,
+40 -2
View File
@@ -315,6 +315,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"`
@@ -344,6 +346,12 @@ type ViewJob struct {
CallUses string `json:"callUses,omitempty"`
}
type ViewJobSummary struct {
JobID int64 `json:"jobId"`
JobName string `json:"jobName"`
SummaryHTML template.HTML `json:"summaryHTML"`
}
type ViewRunAttempt struct {
Attempt int64 `json:"attempt"`
Status string `json:"status"`
@@ -649,12 +657,42 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
resp.State.Run.PullRequest = refInfo.PullRequest
resp.State.Run.TriggerEvent = run.TriggerEvent
// 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.
// Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all
// share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only.
var runAttemptID int64
if attempt != nil {
runAttemptID = attempt.ID
}
// Each step's markdown is rendered independently so an unclosed construct
// in one step can't bleed into the next.
// On a single-job view only that job's summaries are needed; the run view shows all.
// Scoping server-side avoids rendering every job's markdown on each 1s poll.
summaries, err := actions_model.ListActionRunJobSummaries(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID, ctx.PathParamInt64("job"))
if err != nil {
ctx.ServerError("ListActionRunJobSummaries", err)
return
}
if len(summaries) > 0 {
jobNameByID := make(map[int64]string, len(jobs))
for _, j := range jobs {
jobNameByID[j.ID] = j.Name
}
renderUtils := templates.NewRenderUtils(ctx)
var current *ViewJobSummary
for _, s := range summaries {
if s.ContentType != actions_model.JobSummaryContentTypeMarkdown {
log.Warn("Skip unsupported job summary content type %q for run %d job %d step %d", s.ContentType, s.RunID, s.JobID, s.StepIndex)
continue
}
if current == nil || current.JobID != s.JobID {
current = &ViewJobSummary{JobID: s.JobID, JobName: jobNameByID[s.JobID]}
resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, current)
}
current.SummaryHTML += renderUtils.MarkdownToHtml(s.Content)
}
}
arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
if err != nil {
ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err)