0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-10 09:41:52 +02:00

Merge da9090927182a03338ac563b081854a970c65fb5 into 0a3aaeafe7bef9d6935422f4b91c77c216c01b21

This commit is contained in:
Nicolas 2026-05-09 13:27:40 -07:00 committed by GitHub
commit ecd65cd9d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 450 additions and 18 deletions

View File

@ -0,0 +1,114 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/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 summary payload size in bytes.
// This is intentionally conservative to avoid DB bloat and UI abuse.
MaxJobSummarySize = 1024 * 1024 // 1 MiB
)
// ActionRunJobSummary stores the raw job summary markdown uploaded by the runner.
// It is internal state (not a downloadable artifact).
type ActionRunJobSummary struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(summary_key) INDEX"`
RunID int64 `xorm:"UNIQUE(summary_key) INDEX"`
RunAttemptID int64 `xorm:"UNIQUE(summary_key) NOT NULL DEFAULT 0 INDEX"`
JobID int64 `xorm:"UNIQUE(summary_key) INDEX"`
Content string `xorm:"LONGTEXT"`
ContentType string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'text/markdown'"`
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 int64) (*ActionRunJobSummary, error) {
var s ActionRunJobSummary
has, err := db.GetEngine(ctx).
Where("repo_id=? AND run_id=? AND run_attempt_id=? AND job_id=?", repoID, runID, runAttemptID, jobID).
Get(&s)
if err != nil {
return nil, err
}
if !has {
return nil, util.ErrNotExist
}
return &s, nil
}
func UpsertActionRunJobSummary(ctx context.Context, repoID, runID, runAttemptID, jobID int64, contentType string, content []byte) error {
if runID <= 0 || jobID <= 0 || repoID <= 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 == "" {
contentType = JobSummaryContentTypeMarkdown
}
if contentType != JobSummaryContentTypeMarkdown {
return util.ErrInvalidArgument
}
engine := db.GetEngine(ctx)
existing, err := GetActionRunJobSummary(ctx, repoID, runID, runAttemptID, jobID)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if existing == nil {
_, err := engine.Insert(&ActionRunJobSummary{
RepoID: repoID,
RunID: runID,
RunAttemptID: runAttemptID,
JobID: jobID,
Content: string(content),
ContentType: contentType,
})
return err
}
existing.Content = string(content)
existing.ContentType = contentType
_, err = engine.ID(existing.ID).Cols("content", "content_type").Update(existing)
return err
}
func ListActionRunJobSummariesByRunAttempt(ctx context.Context, repoID, runID, runAttemptID int64) ([]*ActionRunJobSummary, error) {
var summaries []*ActionRunJobSummary
if err := db.GetEngine(ctx).
Where("repo_id=? AND run_id=? AND run_attempt_id=?", repoID, runID, runAttemptID).
OrderBy("job_id ASC").
Find(&summaries); err != nil {
return nil, err
}
return summaries, nil
}

View File

@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.26.0 ends at migration ID number 330 (database version 331)
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
newMigration(332, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
}
return preparedMigrations
}

View File

@ -0,0 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"context"
"code.gitea.io/gitea/models/actions"
"xorm.io/xorm"
)
func AddActionRunJobSummaryTable(ctx context.Context, x *xorm.Engine) error {
return x.Sync(new(actions.ActionRunJobSummary))
}

View File

@ -121,6 +121,9 @@ func ArtifactsRoutes(prefix string) *web.Router {
m.Get("/{artifact_id}/download", r.downloadArtifact)
})
// Job summary upload endpoint (GITHUB_STEP_SUMMARY).
m.Put(jobSummaryRouteBase, uploadJobSummary)
return m
}

View File

@ -0,0 +1,96 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"errors"
"io"
"mime"
"net/http"
"strconv"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
)
const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/summary"
func uploadJobSummary(ctx *ArtifactContext) {
task, runID, ok := validateRunID(ctx)
if !ok {
return
}
jobID := ctx.PathParamInt64("job_id")
if jobID <= 0 {
ctx.HTTPError(http.StatusBadRequest, "invalid job_id")
return
}
if task == nil || task.Job == nil {
ctx.HTTPError(http.StatusInternalServerError, "task/job not loaded")
return
}
if task.Job.ID != jobID {
ctx.HTTPError(http.StatusBadRequest, "job_id mismatch")
return
}
if task.Job.RunID != runID {
ctx.HTTPError(http.StatusBadRequest, "run_id mismatch")
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
}
if len(body) == 0 {
ctx.JSON(http.StatusOK, map[string]string{"message": "empty"})
return
}
contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type"))
if !ok {
ctx.HTTPError(http.StatusBadRequest, "invalid summary content type")
return
}
if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, contentType, body); err != nil {
if errorsIsInvalidArg(err) {
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]string{
"message": "success",
"sizeBytes": strconv.Itoa(len(body)),
"runAttempt": strconv.FormatInt(task.Job.RunAttemptID, 10),
})
}
func errorsIsInvalidArg(err error) bool {
return errors.Is(err, util.ErrInvalidArgument)
}
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
}

View File

@ -118,7 +118,7 @@ func (s *Service) Declare(
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
}
return connect.NewResponse(&runnerv1.DeclareResponse{
resp := connect.NewResponse(&runnerv1.DeclareResponse{
Runner: &runnerv1.Runner{
Id: runner.ID,
Uuid: runner.UUID,
@ -127,7 +127,11 @@ func (s *Service) Declare(
Version: runner.Version,
Labels: runner.AgentLabels,
},
}), nil
})
// Capabilities are communicated via headers to avoid a hard dependency on a proto bump.
// Older runners ignore unknown headers; newer runners can use this for feature negotiation.
resp.Header().Set("X-Gitea-Actions-Capabilities", actions_model.JobSummaryCapability)
return resp, nil
}
// FetchTask assigns a task to the runner

View File

@ -15,6 +15,7 @@ import (
actions_model "code.gitea.io/gitea/models/actions"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
@ -90,6 +91,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
resp.State.Run.TriggerEvent = "push"
renderUtils := templates.NewRenderUtils(ctx)
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
Link: "./commit-link",
@ -185,6 +187,22 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.CanRerun = runID == 30 && isLatestAttempt
resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt
// Mock job summaries so the devtest page can preview the Summary panel rendering.
resp.State.Run.JobSummaries = []*actions.ViewJobSummary{
{
JobID: runID * 10,
JobName: "job 100 (testsubname)",
ContentType: "text/markdown",
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",
ContentType: "text/markdown",
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,

View File

@ -300,6 +300,8 @@ type ViewResponse struct {
Duration string `json:"duration"`
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
JobSummaries []*ViewJobSummary `json:"jobSummaries,omitempty"`
} `json:"run"`
CurrentJob struct {
Title string `json:"title"`
@ -323,6 +325,13 @@ type ViewJob struct {
Needs []string `json:"needs,omitempty"`
}
type ViewJobSummary struct {
JobID int64 `json:"jobId"`
JobName string `json:"jobName"`
ContentType string `json:"contentType"`
SummaryHTML template.HTML `json:"summaryHTML"`
}
type ViewRunAttempt struct {
Attempt int64 `json:"attempt"`
Status string `json:"status"`
@ -497,6 +506,39 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
}
resp.State.Run.TriggerEvent = run.TriggerEvent
// Job summaries (GITHUB_STEP_SUMMARY). Only show when present.
{
var runAttemptID int64
if attempt != nil {
runAttemptID = attempt.ID
}
summaries, err := actions_model.ListActionRunJobSummariesByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID)
if err != nil {
ctx.ServerError("ListActionRunJobSummariesByRunAttempt", err)
return
}
if len(summaries) > 0 {
jobNameByID := make(map[int64]string, len(jobs))
for _, j := range jobs {
jobNameByID[j.ID] = j.Name
}
resp.State.Run.JobSummaries = make([]*ViewJobSummary, 0, len(summaries))
renderUtils := templates.NewRenderUtils(ctx)
for _, s := range summaries {
if s.ContentType != actions_model.JobSummaryContentTypeMarkdown {
log.Warn("Skip unsupported job summary content type %q for run %d job %d", s.ContentType, s.RunID, s.JobID)
continue
}
resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, &ViewJobSummary{
JobID: s.JobID,
JobName: jobNameByID[s.JobID],
ContentType: s.ContentType,
SummaryHTML: renderUtils.MarkdownToHtml(s.Content),
})
}
}
}
// 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.
var runAttemptID int64

View File

@ -63,6 +63,8 @@ jobs:
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, "text/markdown", []byte("### Hello summary\n\nFrom 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)
@ -75,6 +77,9 @@ jobs:
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)
require.Len(t, viewResp.State.Run.JobSummaries, 1)
assert.Equal(t, job1.ID, viewResp.State.Run.JobSummaries[0].JobID)
assert.Contains(t, string(viewResp.State.Run.JobSummaries[0].SummaryHTML), "Hello summary")
// 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))

View File

@ -16,6 +16,7 @@ import (
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@ -44,6 +45,67 @@ func prepareTestEnvActionsArtifacts(t *testing.T) func() {
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()))
return task
}
func TestActionsJobSummaryUpload(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
task := getArtifactFixtureTask(t)
summaryURL := fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/summary", task.Job.RunID, task.Job.ID)
t.Run("success", func(t *testing.T) {
body := "### Uploaded summary\n\n- line one\n"
req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader(body)).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Type", "text/markdown; charset=utf-8")
MakeRequest(t, req, http.StatusOK)
summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID)
require.NoError(t, err)
assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)
assert.Equal(t, body, summary.Content)
})
t.Run("invalid-content-type", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader("summary")).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Type", "text/html")
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary content type")
})
t.Run("size-limit", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", summaryURL, strings.NewReader(strings.Repeat("a", actions_model.MaxJobSummarySize+1))).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary")
})
t.Run("job-mismatch", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
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/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "run-id does not match")
})
}
func TestActionsArtifactUploadSingleFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()

View File

@ -53,6 +53,7 @@ onBeforeUnmount(() => {
<span>{{ locale.status[run.status] }}</span> <span>{{ locale.totalDuration }} {{ run.duration || '' }}</span>
</div>
</div>
<WorkflowGraph
v-if="run.jobs.length > 0"
:store="store"
@ -81,4 +82,5 @@ onBeforeUnmount(() => {
border-radius: var(--border-radius) var(--border-radius) 0 0;
background: var(--color-box-header);
}
</style>

View File

@ -110,6 +110,7 @@ export function createEmptyActionsRun(): ActionsRun {
triggeredAt: 0,
triggerEvent: '',
jobs: [] as Array<ActionsJob>,
jobSummaries: [],
commit: {
localeCommit: '',
localePushedBy: '',

View File

@ -206,18 +206,36 @@ async function deleteArtifact(name: string) {
</div>
<div class="action-view-right">
<ActionRunSummaryView
v-if="!props.jobId"
:store="store"
:locale="locale"
/>
<ActionRunJobView
v-else
:store="store"
:locale="locale"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
/>
<template v-if="!props.jobId">
<div class="action-view-right-panel">
<ActionRunSummaryView
:store="store"
:locale="locale"
/>
</div>
<div v-if="run.jobSummaries?.length" class="action-view-right-panel job-summary-section">
<div class="job-summary-section-header">
{{ locale.jobSummaries ?? 'Job summaries' }}
</div>
<div class="job-summary-list">
<div v-for="s in run.jobSummaries" :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>
</template>
<div v-else class="action-view-right-panel">
<ActionRunJobView
:store="store"
:locale="locale"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
/>
</div>
</div>
</div>
</div>
@ -342,25 +360,32 @@ async function deleteArtifact(name: string) {
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);
display: flex;
flex-direction: column;
min-height: 0;
}
/* 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);
}
.action-view-right .ui.button:hover {
.action-view-right-panel .ui.button:hover {
background: var(--color-console-hover-bg);
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);
}
@ -378,4 +403,39 @@ async function deleteArtifact(name: string) {
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>

View File

@ -24,6 +24,7 @@ export type ActionsRun = {
triggeredAt: number,
triggerEvent: string,
jobs: Array<ActionsJob>,
jobSummaries?: Array<ActionsJobSummary>,
commit: {
localeCommit: string,
localePushedBy: string,
@ -41,6 +42,13 @@ export type ActionsRun = {
},
};
export type ActionsJobSummary = {
jobId: number;
jobName: string;
contentType: string;
summaryHTML: string;
};
export type ActionsRunAttempt = {
attempt: number;
status: ActionsStatus;