Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions models/actions/run_job_summary.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions models/migrations/v1_27/v332.go
Original file line number Diff line number Diff line change
@@ -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))
}
3 changes: 3 additions & 0 deletions routers/api/actions/artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
bircni marked this conversation as resolved.

return m
}

Expand Down
96 changes: 96 additions & 0 deletions routers/api/actions/job_summary.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
bircni marked this conversation as resolved.
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
}
8 changes: 6 additions & 2 deletions routers/api/actions/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions routers/web/devtest/mock_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions routers/web/repo/actions/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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),
})
Comment thread
bircni marked this conversation as resolved.
}
}
}

// 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
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/actions_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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))
Expand Down
Loading