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>
This commit is contained in:
bircni
2026-06-08 19:11:00 +00:00
committed by GitHub
co-authored by silverwind Claude
parent b1c088e9cf
commit 3b1e75764e
18 changed files with 683 additions and 23 deletions
+35 -2
View File
@@ -63,18 +63,51 @@ 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, 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{})
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)
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))
@@ -17,10 +17,13 @@ import (
"testing"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
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"
"gitea.dev/modules/util"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -44,6 +47,148 @@ 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()))
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)()