Files
m8sh/models/actions/run_job_summary.go
T
3b1e75764e 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>
2026-06-08 19:11:00 +00:00

208 lines
8.2 KiB
Go

// 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"
)
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
}
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))
}
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
}