mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
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:
co-authored by
silverwind
Claude
parent
b1c088e9cf
commit
3b1e75764e
@@ -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)()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user