- 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>
208 lines
8.2 KiB
Go
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
|
|
}
|