diff --git a/models/actions/run_job_summary.go b/models/actions/run_job_summary.go new file mode 100644 index 0000000000..63e913c7bb --- /dev/null +++ b/models/actions/run_job_summary.go @@ -0,0 +1,207 @@ +// 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 +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 904a3ffa20..aedd679c57 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration { newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist), newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob), + newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v336.go b/models/migrations/v1_27/v336.go new file mode 100644 index 0000000000..9c5e0c9494 --- /dev/null +++ b/models/migrations/v1_27/v336.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "gitea.dev/models/db" + "gitea.dev/modules/timeutil" +) + +func AddActionRunJobSummaryTable(x db.EngineMigration) error { + 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 int64 `xorm:"NOT NULL DEFAULT 0"` + + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(ActionRunJobSummary)) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index a629f11adf..90c7c71fb7 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3798,6 +3798,7 @@ "actions.runs.view_workflow_file": "View workflow file", "actions.runs.summary": "Summary", "actions.runs.all_jobs": "All jobs", + "actions.runs.job_summaries": "Job summaries", "actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller", "actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller", "actions.runs.attempt": "Attempt", diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index d82ac6988c..eeea05c9b4 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -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 } diff --git a/routers/api/actions/job_summary.go b/routers/api/actions/job_summary.go new file mode 100644 index 0000000000..fa21cd6d11 --- /dev/null +++ b/routers/api/actions/job_summary.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "io" + "mime" + "net/http" + "slices" + "strconv" + + actions_model "gitea.dev/models/actions" + "gitea.dev/modules/log" + "gitea.dev/modules/util" +) + +const jobSummaryRouteBase = "/_apis/pipelines/workflows/{run_id}/jobs/{job_id}/steps/{step_index}/summary" + +func uploadJobSummary(ctx *ArtifactContext) { + task, _, ok := validateRunID(ctx) + if !ok { + return + } + + jobID := ctx.PathParamInt64("job_id") + if jobID <= 0 || task.Job.ID != jobID { + ctx.HTTPError(http.StatusBadRequest, "job_id mismatch") + return + } + + stepIndex, err := strconv.ParseInt(ctx.PathParam("step_index"), 10, 64) + if err != nil || stepIndex < 0 { + ctx.HTTPError(http.StatusBadRequest, "invalid step_index") + return + } + steps, err := actions_model.GetTaskStepsByTaskID(ctx, task.ID) + if err != nil { + log.Error("Error getting task steps: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error getting task steps") + return + } + if !slices.ContainsFunc(steps, func(s *actions_model.ActionTaskStep) bool { return s.Index == stepIndex }) { + ctx.HTTPError(http.StatusBadRequest, "step_index mismatch") + return + } + + contentType, ok := normalizeJobSummaryContentType(ctx.Req.Header.Get("Content-Type")) + if !ok { + ctx.HTTPError(http.StatusBadRequest, "invalid summary content type") + 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 + } + message := "success" + if len(body) == 0 { + // PUT with an empty body clears any previously-stored summary for this step. + if err := actions_model.DeleteActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex); err != nil { + log.Error("Error deleting job summary: %v", err) + ctx.HTTPError(http.StatusInternalServerError, "Error deleting job summary") + return + } + message = "cleared" + } else if err := actions_model.UpsertActionRunJobSummary(ctx, task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, stepIndex, contentType, body); err != nil { + if errors.Is(err, actions_model.ErrJobSummaryAggregateExceeded) { + ctx.HTTPError(http.StatusBadRequest, "job summary aggregate size exceeded") + return + } + if errors.Is(err, util.ErrInvalidArgument) { + 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]any{ + "message": message, + "sizeBytes": len(body), + "runAttempt": task.Job.RunAttemptID, + }) +} + +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 +} diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index e98aef9515..803b7c13a1 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -161,7 +161,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, @@ -170,7 +170,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.RunnerCapabilities()) + return resp, nil } // FetchTask assigns a task to the runner diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 185cdc8acb..bc6fdeb907 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -15,6 +15,7 @@ import ( actions_model "gitea.dev/models/actions" user_model "gitea.dev/models/user" "gitea.dev/modules/setting" + "gitea.dev/modules/templates" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" "gitea.dev/modules/web" @@ -89,6 +90,7 @@ func MockActionsRunsJobs(ctx *context.Context) { resp.State.Run.CanDeleteArtifact = true resp.State.Run.WorkflowID = "workflow-id.yml" resp.State.Run.TriggerEvent = "push" + renderUtils := templates.NewRenderUtils(ctx) user2, _ := user_model.GetUserByID(ctx, 2) if user2 == nil { user2 = &user_model.User{Name: "user2"} @@ -196,6 +198,20 @@ 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)", + 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", + 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, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 9f8477d4c0..2f4f1950ec 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -315,6 +315,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"` @@ -344,6 +346,12 @@ type ViewJob struct { CallUses string `json:"callUses,omitempty"` } +type ViewJobSummary struct { + JobID int64 `json:"jobId"` + JobName string `json:"jobName"` + SummaryHTML template.HTML `json:"summaryHTML"` +} + type ViewRunAttempt struct { Attempt int64 `json:"attempt"` Status string `json:"status"` @@ -649,12 +657,42 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, resp.State.Run.PullRequest = refInfo.PullRequest resp.State.Run.TriggerEvent = run.TriggerEvent - // 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. + // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts and summaries all + // share run_attempt_id=0, so passing 0 here scopes to this run's legacy rows only. var runAttemptID int64 if attempt != nil { runAttemptID = attempt.ID } + + // Each step's markdown is rendered independently so an unclosed construct + // in one step can't bleed into the next. + // On a single-job view only that job's summaries are needed; the run view shows all. + // Scoping server-side avoids rendering every job's markdown on each 1s poll. + summaries, err := actions_model.ListActionRunJobSummaries(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID, ctx.PathParamInt64("job")) + if err != nil { + ctx.ServerError("ListActionRunJobSummaries", err) + return + } + if len(summaries) > 0 { + jobNameByID := make(map[int64]string, len(jobs)) + for _, j := range jobs { + jobNameByID[j.ID] = j.Name + } + renderUtils := templates.NewRenderUtils(ctx) + var current *ViewJobSummary + for _, s := range summaries { + if s.ContentType != actions_model.JobSummaryContentTypeMarkdown { + log.Warn("Skip unsupported job summary content type %q for run %d job %d step %d", s.ContentType, s.RunID, s.JobID, s.StepIndex) + continue + } + if current == nil || current.JobID != s.JobID { + current = &ViewJobSummary{JobID: s.JobID, JobName: jobNameByID[s.JobID]} + resp.State.Run.JobSummaries = append(resp.State.Run.JobSummaries, current) + } + current.SummaryHTML += renderUtils.MarkdownToHtml(s.Content) + } + } + arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID) if err != nil { ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err) diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index dc8f13cdcb..7bf7c93dca 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -232,6 +232,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { RepoID: repoID, RunID: run.ID, }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{ + RepoID: repoID, + RunID: run.ID, + }) if err := db.WithTx(ctx, func(ctx context.Context) error { // TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX. diff --git a/services/repository/delete.go b/services/repository/delete.go index d1b41f980b..0666a1616b 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -177,6 +177,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams &actions_model.ActionScheduleSpec{RepoID: repoID}, &actions_model.ActionSchedule{RepoID: repoID}, &actions_model.ActionArtifact{RepoID: repoID}, + &actions_model.ActionRunJobSummary{RepoID: repoID}, &actions_model.ActionRunnerToken{RepoID: repoID}, &issues_model.IssuePin{RepoID: repoID}, ); err != nil { diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 8b8a6dfeff..0d508e69e5 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -15,6 +15,7 @@ data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}" data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}" data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}" + data-locale-job-summaries="{{ctx.Locale.Tr "actions.runs.job_summaries"}}" data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}" data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}" data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}" diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go index 1591a17ae0..862656186b 100644 --- a/tests/integration/actions_route_test.go +++ b/tests/integration/actions_route_test.go @@ -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)) diff --git a/tests/integration/api_actions_artifact_test.go b/tests/integration/api_actions_artifact_test.go index 9e8444525f..75b91b6135 100644 --- a/tests/integration/api_actions_artifact_test.go +++ b/tests/integration/api_actions_artifact_test.go @@ -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)() diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 70508c74fb..582ae0431f 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -128,6 +128,7 @@ export function createEmptyActionsRun(): ActionsRun { triggerEvent: '', pullRequest: null, jobs: [] as Array, + jobSummaries: [], commit: { localeCommit: '', localePushedBy: '', diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 7803a011be..03d62bd7cc 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -22,6 +22,11 @@ const props = defineProps<{ const locale = props.locale; const store = createActionRunViewStore(props.actionsViewUrl); const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData); +const visibleJobSummaries = computed(() => { + const summaries = run.value.jobSummaries || []; + if (!props.jobId) return summaries; + return summaries.filter((summary) => summary.jobId === props.jobId); +}); type JobListItem = { job: ActionsJob; @@ -277,19 +282,35 @@ async function deleteArtifact(name: string) {
- - +
+ + +
+
+
+ {{ locale.jobSummaries }} +
+
+
+
+ {{ s.jobName || `Job ${s.jobId}` }} +
+ +
+
+
+
@@ -463,25 +484,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); } @@ -499,4 +527,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); +} diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 5d114ea925..3d4bd49a4f 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -27,6 +27,7 @@ export function initRepositoryActionView() { pushedBy: el.getAttribute('data-locale-runs-pushed-by'), summary: el.getAttribute('data-locale-summary'), allJobs: el.getAttribute('data-locale-all-jobs'), + jobSummaries: el.getAttribute('data-locale-job-summaries'), expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'), collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'), triggeredVia: el.getAttribute('data-locale-triggered-via'), diff --git a/web_src/js/modules/gitea-actions.ts b/web_src/js/modules/gitea-actions.ts index 6446a58d11..bdaac6ef81 100644 --- a/web_src/js/modules/gitea-actions.ts +++ b/web_src/js/modules/gitea-actions.ts @@ -28,6 +28,7 @@ export type ActionsRun = { link: string, } | null, jobs: Array, + jobSummaries?: Array, commit: { localeCommit: string, localePushedBy: string, @@ -46,6 +47,12 @@ export type ActionsRun = { }, }; +export type ActionsJobSummary = { + jobId: number, + jobName: string, + summaryHTML: string, +}; + export type ActionsRunAttempt = { attempt: number; status: ActionsStatus;