ncr demo · go-gitea/gitea#37500 on GitHub · ← all examples ·
what to notice A genuinely full-stack change (runner API → DB → web → UI) read as one story instead of eighteen alphabetized files.

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

67/67 blocks placed ✓

This PR adds GitHub-style job summaries (GITHUB_STEP_SUMMARY) to Gitea Actions. Reading path: start with the runner capability advertisement in Declare (the feature-negotiation contract), then the upload endpoint that runners call, then the server-side rendering that surfaces summaries in the run view, then the frontend that displays them, and finally the data model, migration, and cleanup that underpin everything.

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.
models/actions/run_job_summary.go · b004 b005 · Public contract — this is the handshake that lets runners know they can upload summaries
+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.
routers/api/actions/runner/runner.go · b028 b029 · Entry point for runner registration; the header is the feature-negotiation surface
 		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).
routers/api/actions/artifacts.go · b021 b025 · Route registration — where the endpoint lives in the HTTP router
 		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.
routers/api/actions/job_summary.go · b022 b023 b024 b026 b027 · Handler — the core request processing logic
+// 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.
routers/web/repo/actions/view.go · b033 b034 · DTO — shapes the JSON the frontend receives
 			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.
routers/web/repo/actions/view.go · b035 b036 · Application layer — orchestrates DB query and markdown rendering
 	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.
web_src/js/modules/gitea-actions.ts · b066 b067 · Frontend type definitions
     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.
web_src/js/components/ActionRunView.ts · b050 · Frontend initialisation
     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.
web_src/js/components/RepoActionView.vue · b051 b052 · Frontend rendering logic
 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.
web_src/js/components/RepoActionView.vue · b053 b054 b055 b056 b057 b058 b059 b060 b061 b062 b063 b064 · Frontend styling
   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.
templates/repo/actions/view_component.tmpl · b039 b065 · i18n plumbing
 		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.
options/locale/locale_en-US.json · b020 · i18n string
   "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.
models/actions/run_job_summary.go · b001 b002 b003 b006 b007 · Data model
+// 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.
models/actions/run_job_summary.go · b008 b009 b010 b011 b012 b013 b014 · Data access layer
+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.
models/migrations/migrations.go · b015 b016 b017 b018 b019 · Schema migration
 		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.
services/actions/cleanup.go · b037 · Cleanup/lifecycle
 		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.
services/repository/delete.go · b038 · Cleanup/lifecycle
 		&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.
tests/integration/api_actions_artifact_test.go · b044 b045 b046 b047 b048 b049 · Integration tests for the upload API
 	"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).
tests/integration/actions_route_test.go · b040 b041 b042 b043 · Integration tests for the view API
 	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.
routers/web/devtest/mock_actions.go · b030 b031 b032 · Developer tooling
 	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,