Runner capability advertisement — X-Gitea-Actions-Capabilities header
1 Entrypoint
RunnerCapabilities / JobSummaryCapability
Defines the capability constant and the helper that serialises it; this is what gets advertised to runners.
Defines the capability constant and the helper that serialises it; this is what gets advertised to runners.
+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 per-step summary payload size in bytes.+ MaxJobSummarySize = 1024 * 1024 // 1 MiB+ + // MaxJobSummaryAggregateSize is the maximum aggregate size of all step summaries within+ // a single job attempt. Matches GitHub's documented per-job summary cap of 1 MiB.+ MaxJobSummaryAggregateSize = 1024 * 1024 // 1 MiB+)+ ⋯+// RunnerCapabilities returns the value advertised in the X-Gitea-Actions-Capabilities header.+// When more capabilities are added, return them comma-separated so runners can split on ", ".+func RunnerCapabilities() string {+ return JobSummaryCapability+}+
1 Entrypoint
Service.Declare
After building the DeclareResponse, sets X-Gitea-Actions-Capabilities on the response header so runners can discover job-summary support without a proto bump.
After building the DeclareResponse, sets
X-Gitea-Actions-Capabilities on the response header so runners can discover job-summary support without a proto bump.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,⋯ 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
PUT /api/actions_pipeline/…/steps/{step_index}/summary — runner upload endpoint
2 Application
jobSummaryRouteBase / ArtifactsRoutes wiring
Registers the new PUT route alongside the existing artifacts routes, reusing the same ArtifactContexter middleware (token auth, task loading).
Registers the new PUT route alongside the existing artifacts routes, reusing the same ArtifactContexter middleware (token auth, task loading).
m.Get("/{artifact_id}/download", r.downloadArtifact) }) + // Job summary upload endpoint (GITHUB_STEP_SUMMARY).+ m.Put(jobSummaryRouteBase, uploadJobSummary)+ return m } ⋯+const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/steps/{step_index}/summary"+
2 Application
uploadJobSummary / normalizeJobSummaryContentType
Validates run-ID, job-ID, step-index (must exist in DB), and content-type; reads body up to the per-step limit; delegates to the model for upsert or delete (empty body clears). Content-type normalisation accepts blank or application/octet-stream as markdown for runner compatibility.
Validates run-ID, job-ID, step-index (must exist in DB), and content-type; reads body up to the per-step limit; delegates to the model for upsert or delete (empty body clears). Content-type normalisation accepts blank or
application/octet-stream as markdown for runner compatibility.+// 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"+)+ ⋯+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+}
Run Summary view — rendering job summaries in ViewPost
3 Domain
ViewResponse.JobSummaries / ViewJobSummary
Adds jobSummaries to the run state payload and defines the ViewJobSummary struct that carries per-job rendered HTML.
Adds
jobSummaries to the run state payload and defines the ViewJobSummary struct that carries per-job rendered HTML.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"`⋯ 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"`
3 Domain
fillViewRunResponseSummary — summary rendering
Queries summaries scoped by attempt; on the single-job view passes job path param to filter server-side (avoids re-rendering every job on each 1s poll). Concatenates each step's markdown independently to avoid bleed between steps, groups by job, and appends rendered HTML to the response.
Queries summaries scoped by attempt; on the single-job view passes
job path param to filter server-side (avoids re-rendering every job on each 1s poll). Concatenates each step's markdown independently to avoid bleed between steps, groups by job, and appends rendered HTML to the response.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⋯ 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)
Frontend — job summary panel in RepoActionView
4 Adapter
ActionsJobSummary type / ActionsRun.jobSummaries
Defines the ActionsJobSummary TypeScript type and adds the optional jobSummaries array to ActionsRun.
Defines the
ActionsJobSummary TypeScript type and adds the optional jobSummaries array to ActionsRun.link: string, } | null, jobs: Array<ActionsJob>,+ jobSummaries?: Array<ActionsJobSummary>, commit: { localeCommit: string, localePushedBy: string,⋯ }, }; +export type ActionsJobSummary = {+ jobId: number,+ jobName: string,+ summaryHTML: string,+};+ export type ActionsRunAttempt = { attempt: number; status: ActionsStatus;
4 Adapter
createEmptyActionsRun — jobSummaries initialisation
Seeds jobSummaries: [] in the empty-run factory so the computed property never sees undefined before the first poll response.
Seeds
jobSummaries: [] in the empty-run factory so the computed property never sees undefined before the first poll response.triggerEvent: '', pullRequest: null, jobs: [] as Array<ActionsJob>,+ jobSummaries: [], commit: { localeCommit: '', localePushedBy: '',
4 Adapter
visibleJobSummaries / job-summary-section template
A computed property filters summaries by jobId prop (no-op on the run view); the template renders a new panel below the existing job/summary view, iterating job summaries as cards with v-html for the server-rendered markdown.
A computed property filters summaries by
jobId prop (no-op on the run view); the template renders a new panel below the existing job/summary view, iterating job summaries as cards with v-html for the server-rendered markdown.const locale = props.locale; const store = createActionRunViewStore(props.actionsViewUrl); const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);+const visibleJobSummaries = computed(() => {+ const summaries = run.value.jobSummaries || [];+ if (!props.jobId) return summaries;+ return summaries.filter((summary) => summary.jobId === props.jobId);+}); type JobListItem = { job: ActionsJob;⋯ </div> <div class="action-view-right">- <ActionRunSummaryView- v-if="!props.jobId"- :store="store"- :locale="locale"- :artifact-count="artifacts.length"- />- <ActionRunJobView- v-else- :store="store"- :locale="locale"- :actions-view-url="props.actionsViewUrl"- :job-id="props.jobId"- />+ <div class="action-view-right-panel">+ <ActionRunSummaryView+ v-if="!props.jobId"+ :store="store"+ :locale="locale"+ :artifact-count="artifacts.length"+ />+ <ActionRunJobView+ v-else+ :store="store"+ :locale="locale"+ :actions-view-url="props.actionsViewUrl"+ :job-id="props.jobId"+ />+ </div>+ <div v-if="visibleJobSummaries.length" class="action-view-right-panel job-summary-section">+ <div class="job-summary-section-header">+ {{ locale.jobSummaries }}+ </div>+ <div class="job-summary-list">+ <div v-for="s in visibleJobSummaries" :key="s.jobId" class="job-summary-item">+ <div class="job-summary-header">+ <strong class="gt-ellipsis">{{ s.jobName || `Job ${s.jobId}` }}</strong>+ </div>+ <!-- eslint-disable-next-line vue/no-v-html -->+ <div class="markup job-summary-body" v-html="s.summaryHTML"/>+ </div>+ </div>+ </div> </div> </div> </div>
4 Adapter
action-view-right panel styles
Wraps the existing right column in .action-view-right-panel (replaces the old .action-view-right selector on button overrides), introduces .job-summary-section and its sub-elements so both panels stack vertically with a gap.
Wraps the existing right column in
.action-view-right-panel (replaces the old .action-view-right selector on button overrides), introduces .job-summary-section and its sub-elements so both panels stack vertically with a gap.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);⋯ 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 */⋯ /* 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); }⋯ 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); }⋯ 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); }⋯ 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);+} </style>
4 Adapter
locale wiring — job_summaries string
Passes the data-locale-job-summaries attribute from the template into the Vue app's locale prop.
Passes the
data-locale-job-summaries attribute from the template into the Vue app's locale prop.b039 is the Go template data attribute; b065 reads it in the JS initialiser.
data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}" data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}" data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}"+ data-locale-job-summaries="{{ctx.Locale.Tr "actions.runs.job_summaries"}}" data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}" data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}" data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}"⋯ pushedBy: el.getAttribute('data-locale-runs-pushed-by'), summary: el.getAttribute('data-locale-summary'), allJobs: el.getAttribute('data-locale-all-jobs'),+ jobSummaries: el.getAttribute('data-locale-job-summaries'), expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'), collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'), triggeredVia: el.getAttribute('data-locale-triggered-via'),
4 Adapter
locale_en-US.json — actions.runs.job_summaries
Adds the English locale string for the panel header.
Adds the English locale string for the panel header.
"actions.runs.view_workflow_file": "View workflow file", "actions.runs.summary": "Summary", "actions.runs.all_jobs": "All jobs",+ "actions.runs.job_summaries": "Job summaries", "actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller", "actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller", "actions.runs.attempt": "Attempt",
ActionRunJobSummary data model, upsert logic, and migration
5 Cross-cutting
ActionRunJobSummary struct / init
Defines the ORM model. ContentSize is stored explicitly (not computed) because LENGTH() counts characters in most DB engines, which would let multibyte UTF-8 bypass the aggregate byte cap.
Defines the ORM model.
ContentSize is stored explicitly (not computed) because LENGTH() counts characters in most DB engines, which would let multibyte UTF-8 bypass the aggregate byte cap.+// Copyright 2026 The Gitea Authors. All rights reserved.+// SPDX-License-Identifier: MIT+ ⋯+package actions+ ⋯+import (+ "context"+ + "gitea.dev/models/db"+ "gitea.dev/modules/setting"+ "gitea.dev/modules/timeutil"+ "gitea.dev/modules/util"+)+ ⋯+type ActionRunJobSummary struct {+ ID int64 `xorm:"pk autoincr"`+ + RepoID int64 `xorm:"UNIQUE(summary_key)"`+ RunID int64 `xorm:"UNIQUE(summary_key)"`+ RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`+ JobID int64 `xorm:"UNIQUE(summary_key)"`+ StepIndex int64 `xorm:"UNIQUE(summary_key)"`+ + Content string `xorm:"LONGTEXT"`+ ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`+ // ContentSize is the byte length of Content. Stored explicitly because LENGTH()+ // counts characters (not bytes) on PostgreSQL, SQLite and MSSQL, which would let+ // multibyte UTF-8 content bypass the aggregate cap.+ ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`+ + Created timeutil.TimeStamp `xorm:"created"`+ Updated timeutil.TimeStamp `xorm:"updated"`+}+ ⋯+func init() {+ db.RegisterModel(new(ActionRunJobSummary))+}+
5 Cross-cutting
Get/Upsert/Delete/List/sumOtherSizes
Full CRUD surface. The aggregate-size check is explicitly noted as best-effort (no row-level lock). upsertActionRunJobSummary has per-dialect SQL because xorm lacks a portable INSERT … ON CONFLICT. ListActionRunJobSummaries accepts an optional jobID for the single-job scoping described above.
Full CRUD surface. The aggregate-size check is explicitly noted as best-effort (no row-level lock).
upsertActionRunJobSummary has per-dialect SQL because xorm lacks a portable INSERT … ON CONFLICT. ListActionRunJobSummaries accepts an optional jobID for the single-job scoping described above.+func GetActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) (*ActionRunJobSummary, error) {+ var s ActionRunJobSummary+ has, err := db.GetEngine(ctx).+ Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).+ Get(&s)+ if err != nil {+ return nil, err+ }+ if !has {+ return nil, util.ErrNotExist+ }+ return &s, nil+}+ ⋯+// ErrJobSummaryAggregateExceeded is returned when a step summary upload would push the+// aggregate size of summaries for a single job attempt over MaxJobSummaryAggregateSize.+var ErrJobSummaryAggregateExceeded = util.NewInvalidArgumentErrorf("job summary aggregate size exceeded")+ ⋯+func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64, contentType string, content []byte) error {+ if runID <= 0 || jobID <= 0 || repoID <= 0 || stepIndex < 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 != JobSummaryContentTypeMarkdown {+ return util.ErrInvalidArgument+ }+ + // The aggregate check is best-effort: a tx wouldn't actually serialize concurrent+ // step uploads (no row-level lock on the parent job), so wrapping these two+ // statements only adds round-trip cost without changing the race semantics.+ // The current step is excluded because the upsert below replaces its size with len(content).+ otherSize, err := sumOtherJobSummarySizes(ctx, repoID, runID, runAttemptID, jobID, stepIndex)+ if err != nil {+ return err+ }+ if otherSize+int64(len(content)) > MaxJobSummaryAggregateSize {+ return ErrJobSummaryAggregateExceeded+ }+ + now := timeutil.TimeStampNow()+ return upsertActionRunJobSummary(ctx, &ActionRunJobSummary{+ RepoID: repoID,+ RunID: runID,+ RunAttemptID: runAttemptID,+ JobID: jobID,+ StepIndex: stepIndex,+ Content: string(content),+ ContentSize: int64(len(content)),+ ContentType: contentType,+ Created: now,+ Updated: now,+ })+}+ ⋯+// sumOtherJobSummarySizes returns the total stored size of all step summaries for a job+// except excludeStepIndex, computed in the database to avoid loading every row.+func sumOtherJobSummarySizes(ctx context.Context, repoID, runID, runAttemptID, jobID, excludeStepIndex int64) (int64, error) {+ return db.GetEngine(ctx).+ Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index<>?", repoID, runID, runAttemptID, jobID, excludeStepIndex).+ SumInt(new(ActionRunJobSummary), "content_size")+}+ ⋯+// DeleteActionRunJobSummary removes the stored summary for a specific step. Used when+// a runner PUTs an empty body to clear a previously-uploaded step summary.+func DeleteActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID, stepIndex int64) error {+ _, err := db.GetEngine(ctx).+ Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=? AND step_index=?", repoID, runID, runAttemptID, jobID, stepIndex).+ Delete(new(ActionRunJobSummary))+ return err+}+ ⋯+func upsertActionRunJobSummary(ctx context.Context, summary *ActionRunJobSummary) error {+ engine := db.GetEngine(ctx)+ columns := "`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`, `content`, `content_type`, `content_size`, `created`, `updated`"+ values := []any{+ summary.RepoID,+ summary.RunID,+ summary.RunAttemptID,+ summary.JobID,+ summary.StepIndex,+ summary.Content,+ summary.ContentType,+ summary.ContentSize,+ summary.Created,+ summary.Updated,+ }+ + if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() {+ args := append([]any{"INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " ++ "ON CONFLICT (`repo_id`, `run_id`, `run_attempt_id`, `job_id`, `step_index`) DO UPDATE SET " ++ "`content` = excluded.`content`, `content_type` = excluded.`content_type`, `content_size` = excluded.`content_size`, `updated` = excluded.`updated`"}, values...)+ _, err := engine.Exec(args...)+ return err+ }+ + if setting.Database.Type.IsMySQL() {+ args := append([]any{+ "INSERT INTO `action_run_job_summary` (" + columns + ") VALUES (?,?,?,?,?,?,?,?,?,?) " ++ "ON DUPLICATE KEY UPDATE `content` = VALUES(`content`), `content_type` = VALUES(`content_type`), `content_size` = VALUES(`content_size`), `updated` = VALUES(`updated`)",+ }, values...)+ _, err := engine.Exec(args...)+ return err+ }+ + if setting.Database.Type.IsMSSQL() {+ _, err := engine.Exec(`+MERGE INTO action_run_job_summary WITH (HOLDLOCK) AS target+USING (SELECT ? AS repo_id, ? AS run_id, ? AS run_attempt_id, ? AS job_id, ? AS step_index) AS source+ON target.repo_id = source.repo_id+ AND target.run_id = source.run_id+ AND target.run_attempt_id = source.run_attempt_id+ AND target.job_id = source.job_id+ AND target.step_index = source.step_index+WHEN MATCHED THEN+ UPDATE SET content = ?, content_type = ?, content_size = ?, updated = ?+WHEN NOT MATCHED THEN+ INSERT (repo_id, run_id, run_attempt_id, job_id, step_index, content, content_type, content_size, created, updated)+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);+`,+ summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex,+ summary.Content, summary.ContentType, summary.ContentSize, summary.Updated,+ summary.RepoID, summary.RunID, summary.RunAttemptID, summary.JobID, summary.StepIndex, summary.Content, summary.ContentType, summary.ContentSize, summary.Created, summary.Updated)+ return err+ }+ + return util.ErrInvalidArgument+}+ ⋯+// ListActionRunJobSummaries lists the stored summaries for a run attempt, ordered by job+// then step. A positive jobID scopes the lookup to that single job, used by the job view to+// avoid rendering every job's summary on each poll; jobID<=0 returns all jobs in the attempt.+func ListActionRunJobSummaries(ctx context.Context, repoID, runID, runAttemptID, jobID int64) ([]*ActionRunJobSummary, error) {+ sess := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID)+ if jobID > 0 {+ sess = sess.And("job_id=?", jobID)+ }+ var summaries []*ActionRunJobSummary+ if err := sess.OrderBy("job_id ASC, step_index ASC").Find(&summaries); err != nil {+ return nil, err+ }+ return summaries, nil+}
5 Cross-cutting
migration 336 — AddActionRunJobSummaryTable
Creates the new table; the struct is duplicated inline (migration convention) to decouple it from future model changes.
Creates the new table; the struct is duplicated inline (migration convention) to decouple it from future model changes.
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist), newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),+ newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), } return preparedMigrations }⋯+// Copyright 2026 The Gitea Authors. All rights reserved.+// SPDX-License-Identifier: MIT+ ⋯+package v1_27+ ⋯+import (+ "gitea.dev/models/db"+ "gitea.dev/modules/timeutil"+)+ ⋯+func AddActionRunJobSummaryTable(x db.EngineMigration) error {+ type ActionRunJobSummary struct {+ ID int64 `xorm:"pk autoincr"`+ + RepoID int64 `xorm:"UNIQUE(summary_key)"`+ RunID int64 `xorm:"UNIQUE(summary_key)"`+ RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0"`+ JobID int64 `xorm:"UNIQUE(summary_key)"`+ StepIndex int64 `xorm:"UNIQUE(summary_key)"`+ + Content string `xorm:"LONGTEXT"`+ ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`+ ContentSize int64 `xorm:"NOT NULL DEFAULT 0"`+ + Created timeutil.TimeStamp `xorm:"created"`+ Updated timeutil.TimeStamp `xorm:"updated"`+ }+ + return x.Sync(new(ActionRunJobSummary))+}
Lifecycle cleanup — delete summaries with run and repo
5 Cross-cutting
DeleteRun — include ActionRunJobSummary
Adds summary rows to the batch-delete list when a run is deleted.
Adds summary rows to the batch-delete list when a run is deleted.
RepoID: repoID, RunID: run.ID, })+ recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{+ RepoID: repoID,+ RunID: run.ID,+ }) if err := db.WithTx(ctx, func(ctx context.Context) error { // TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
5 Cross-cutting
DeleteRepositoryDirectly — include ActionRunJobSummary
Adds summary rows to the bulk-delete call when an entire repository is deleted.
Adds summary rows to the bulk-delete call when an entire repository is deleted.
&actions_model.ActionScheduleSpec{RepoID: repoID}, &actions_model.ActionSchedule{RepoID: repoID}, &actions_model.ActionArtifact{RepoID: repoID},+ &actions_model.ActionRunJobSummary{RepoID: repoID}, &actions_model.ActionRunnerToken{RepoID: repoID}, &issues_model.IssuePin{RepoID: repoID}, ); err != nil {
Tests — upload endpoint and view scoping
6 Tests/Docs
TestActionsJobSummaryUpload
New integration test covering: happy-path upsert and timestamp refresh, multi-step listing order, content-type rejection, per-step and aggregate size limits, job/run/step-index mismatch errors, and empty-body clear. Introduces getArtifactFixtureTask and ensureArtifactFixtureTaskSteps helpers because the upload handler validates step existence.
New integration test covering: happy-path upsert and timestamp refresh, multi-step listing order, content-type rejection, per-step and aggregate size limits, job/run/step-index mismatch errors, and empty-body clear. Introduces
getArtifactFixtureTask and ensureArtifactFixtureTaskSteps helpers because the upload handler validates step existence."testing" runnerv1 "gitea.dev/actions-proto-go/runner/v1"+ actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth"⋯ 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"⋯ repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user"+ "gitea.dev/modules/util" "gitea.dev/tests" "github.com/stretchr/testify/assert"⋯ 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()))+ ensureArtifactFixtureTaskSteps(t, task)+ return task+}+ ⋯+func ensureArtifactFixtureTaskSteps(t *testing.T, task *actions_model.ActionTask) {+ t.Helper()+ + steps, err := actions_model.GetTaskStepsByTaskID(t.Context(), task.ID)+ require.NoError(t, err)+ + existingIndexes := make(map[int64]bool, len(steps))+ for _, step := range steps {+ existingIndexes[step.Index] = true+ }+ + var stepsToInsert []*actions_model.ActionTaskStep+ for _, idx := range []int64{0, 1} {+ if existingIndexes[idx] {+ continue+ }+ stepsToInsert = append(stepsToInsert, &actions_model.ActionTaskStep{+ TaskID: task.ID,+ Index: idx,+ RepoID: task.RepoID,+ Status: actions_model.StatusWaiting,+ })+ }+ if len(stepsToInsert) == 0 {+ return+ }+ + _, err = db.GetEngine(t.Context()).Insert(stepsToInsert)+ require.NoError(t, err)+}+ ⋯+func TestActionsJobSummaryUpload(t *testing.T) {+ defer prepareTestEnvActionsArtifacts(t)()+ + const runnerToken = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"+ task := getArtifactFixtureTask(t)+ summaryURL := func(stepIndex int64) string {+ return fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/%d/summary", task.Job.RunID, task.Job.ID, stepIndex)+ }+ putSummary := func(stepIndex int64, body, contentType string) *RequestWrapper {+ return NewRequestWithBody(t, "PUT", summaryURL(stepIndex), strings.NewReader(body)).+ AddTokenAuth(runnerToken).+ SetHeader("Content-Type", contentType)+ }+ + t.Run("success", func(t *testing.T) {+ body := "### Uploaded summary\n\n- line one\n"+ MakeRequest(t, putSummary(0, body, "text/markdown; charset=utf-8"), http.StatusOK)+ + summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)+ require.NoError(t, err)+ assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)+ assert.Equal(t, body, summary.Content)+ + staleUpdated := summary.Updated - 60+ _, err = db.GetEngine(t.Context()).ID(summary.ID).Cols("updated").Update(&actions_model.ActionRunJobSummary{Updated: staleUpdated})+ require.NoError(t, err)+ + updatedBody := "### Updated summary\n\n- refreshed\n"+ MakeRequest(t, putSummary(0, updatedBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)+ + summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)+ require.NoError(t, err)+ assert.Equal(t, updatedBody, summary.Content)+ assert.Greater(t, summary.Updated, staleUpdated)+ + stepTwoBody := "### Second step summary\n\n- another step\n"+ MakeRequest(t, putSummary(1, stepTwoBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)+ + summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 1)+ require.NoError(t, err)+ assert.Equal(t, stepTwoBody, summary.Content)+ + summaries, err := actions_model.ListActionRunJobSummaries(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, 0)+ require.NoError(t, err)+ require.Len(t, summaries, 2)+ assert.Equal(t, int64(0), summaries[0].StepIndex)+ assert.Equal(t, int64(1), summaries[1].StepIndex)+ })+ + t.Run("invalid-content-type", func(t *testing.T) {+ resp := MakeRequest(t, putSummary(0, "summary", "text/html"), http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "invalid summary content type")+ })+ + t.Run("size-limit", func(t *testing.T) {+ resp := MakeRequest(t, putSummary(0, strings.Repeat("a", actions_model.MaxJobSummarySize+1), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "invalid summary")+ })+ + t.Run("aggregate-size-limit", func(t *testing.T) {+ require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0,+ actions_model.JobSummaryContentTypeMarkdown, []byte(strings.Repeat("a", actions_model.MaxJobSummaryAggregateSize-1024))))+ resp := MakeRequest(t, putSummary(1, strings.Repeat("b", 4096), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "aggregate size exceeded")+ })+ + t.Run("job-mismatch", func(t *testing.T) {+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).+ AddTokenAuth(runnerToken).+ 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/steps/0/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).+ AddTokenAuth(runnerToken).+ SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)+ resp := MakeRequest(t, req, http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "run-id does not match")+ })+ + t.Run("invalid-step-index", func(t *testing.T) {+ resp := MakeRequest(t, putSummary(-1, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "invalid step_index")+ })+ + t.Run("step-index-mismatch", func(t *testing.T) {+ resp := MakeRequest(t, putSummary(999, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)+ assert.Contains(t, resp.Body.String(), "step_index mismatch")+ })+ + t.Run("empty-body-clears", func(t *testing.T) {+ MakeRequest(t, putSummary(0, "### keep me", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)+ MakeRequest(t, putSummary(0, "", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)+ + _, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)+ require.ErrorIs(t, err, util.ErrNotExist)+ })+}+ func TestActionsArtifactUploadSingleFile(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)()
6 Tests/Docs
testActionsRouteForIDBasedURL — ViewResponse summary scoping
Extends the existing route test to seed summaries for two jobs, then asserts the run view returns both while the single-job view returns only the requested job's summary (the key server-side scoping invariant).
Extends the existing route test to seed summaries for two jobs, then asserts the run view returns both while the single-job view returns only the requested job's summary (the key server-side scoping invariant).
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, 0, "text/markdown", []byte("### Hello summary\n\nFrom first step.\n")))+ require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 1, "text/markdown", []byte("From second step.\n")))+ // A second job's summary in the same run/attempt: the run view must include it,+ // but the single-job view must scope it out.+ otherJobID := job1.ID + 1+ require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, otherJobID, 0, "text/markdown", []byte("### Other 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) ⋯ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999)) user2Session.MakeRequest(t, req, http.StatusNotFound) - // run1 and job1 belong to repo1, success- req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))+ findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {+ for _, s := range viewResp.State.Run.JobSummaries {+ if s.JobID == jobID {+ return s+ }+ }+ return nil+ }+ assertJob1Summary := func(t *testing.T, s *actions_web.ViewJobSummary) {+ t.Helper()+ require.NotNil(t, s)+ assert.Contains(t, string(s.SummaryHTML), "Hello summary")+ assert.Contains(t, string(s.SummaryHTML), "From second step")+ }+ + // Run view: summaries for every job in the run.+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID)) resp := user2Session.MakeRequest(t, req, http.StatusOK) viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})⋯ resp := user2Session.MakeRequest(t, req, http.StatusOK) viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})+ require.Len(t, viewResp.State.Run.JobSummaries, 2)+ assertJob1Summary(t, findSummary(viewResp, job1.ID))+ assert.Contains(t, string(findSummary(viewResp, otherJobID).SummaryHTML), "Other job summary")+ + // Job view: scoped server-side to the requested job, the other job's summary excluded.+ req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))+ resp = user2Session.MakeRequest(t, req, http.StatusOK)+ 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)⋯ 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)+ assertJob1Summary(t, findSummary(viewResp, job1.ID))+ assert.Nil(t, findSummary(viewResp, otherJobID)) // 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))
Devtest mock — preview job summaries in the UI
6 Tests/Docs
MockActionsRunsJobs — jobSummaries fixture
Injects two mock ViewJobSummary entries (with rendered markdown) into the devtest response so the Summary panel can be previewed without a real runner.
Injects two mock
ViewJobSummary entries (with rendered markdown) into the devtest response so the Summary panel can be previewed without a real runner.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"⋯ 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"}⋯ 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,