From bb6faa4c5d8cf18bb510217be0e85343752ee254 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 6 May 2026 17:47:14 -0500 Subject: [PATCH 1/5] feat(webhook): add workflow_step webhook events for step-level CI notifications Adds HookEventWorkflowStep event type that fires on every step state transition (queued -> in_progress -> completed). Follows the same pattern as the existing workflow_job events. - New WorkflowStepPayload struct with run/job/step context - WorkflowStepStatusUpdate notifier interface + dispatch - Step state change detection in UpdateTask runner endpoint - Fix: register workflow_step in updateHookEvents API mapping - Full test coverage mirroring workflow_job tests Co-Authored-By: Claude Sonnet 4.6 --- models/webhook/webhook_test.go | 2 +- modules/structs/hook.go | 23 ++++ modules/webhook/type.go | 6 +- options/locale/locale_en-US.json | 2 + routers/api/actions/runner/runner.go | 18 +++ routers/api/v1/utils/hook.go | 1 + routers/web/repo/setting/webhook.go | 1 + services/actions/notify.go | 24 ++++ services/convert/convert.go | 14 +++ services/forms/repo_form.go | 1 + services/notify/notifier.go | 2 + services/notify/notify.go | 7 ++ services/notify/null.go | 3 + services/webhook/dingtalk.go | 6 + services/webhook/discord.go | 6 + services/webhook/discord_test.go | 15 +++ services/webhook/feishu.go | 6 + services/webhook/general.go | 31 +++++ services/webhook/general_test.go | 119 ++++++++++++++++++ services/webhook/matrix.go | 6 + services/webhook/msteams.go | 14 +++ services/webhook/notifier.go | 49 ++++++++ services/webhook/packagist.go | 4 + services/webhook/payloader.go | 3 + services/webhook/slack.go | 6 + services/webhook/slack_test.go | 9 ++ services/webhook/telegram.go | 6 + services/webhook/wechatwork.go | 6 + templates/repo/settings/webhook/settings.tmpl | 10 ++ tests/integration/repo_webhook_test.go | 107 ++++++++++++++++ 30 files changed, 504 insertions(+), 3 deletions(-) diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index 073af91de2..3af6070d10 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -139,7 +139,7 @@ func TestWebhook_EventsArray(t *testing.T) { "pull_request", "pull_request_assign", "pull_request_label", "pull_request_milestone", "pull_request_comment", "pull_request_review_approved", "pull_request_review_rejected", "pull_request_review_comment", "pull_request_sync", "pull_request_review_request", "wiki", "repository", "release", - "package", "status", "workflow_run", "workflow_job", + "package", "status", "workflow_run", "workflow_job", "workflow_step", }, (&Webhook{ HookEvent: &webhook_module.HookEvent{SendEverything: true}, diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 99c1535155..ff30d48197 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -648,3 +648,26 @@ type WorkflowJobPayload struct { func (p *WorkflowJobPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } + +// WorkflowStepPayload represents a payload information of workflow step event. +type WorkflowStepPayload struct { + // The action performed on the workflow step + Action string `json:"action"` + // The workflow run that contains the step + WorkflowRun *ActionWorkflowRun `json:"workflow_run"` + // The workflow job that contains the step + WorkflowJob *ActionWorkflowJob `json:"workflow_job"` + // The workflow step that was acted upon + Step *ActionWorkflowStep `json:"step"` + // The organization that owns the repository (if applicable) + Organization *Organization `json:"organization,omitempty"` + // The repository containing the workflow + Repo *Repository `json:"repository"` + // The user who triggered the workflow + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowStepPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} diff --git a/modules/webhook/type.go b/modules/webhook/type.go index 18a4086710..a04887afa6 100644 --- a/modules/webhook/type.go +++ b/modules/webhook/type.go @@ -38,8 +38,9 @@ const ( HookEventPullRequestReview HookEventType = "pull_request_review" // Actions event only HookEventSchedule HookEventType = "schedule" - HookEventWorkflowRun HookEventType = "workflow_run" - HookEventWorkflowJob HookEventType = "workflow_job" + HookEventWorkflowRun HookEventType = "workflow_run" + HookEventWorkflowJob HookEventType = "workflow_job" + HookEventWorkflowStep HookEventType = "workflow_step" ) func AllEvents() []HookEventType { @@ -70,6 +71,7 @@ func AllEvents() []HookEventType { HookEventStatus, HookEventWorkflowRun, HookEventWorkflowJob, + HookEventWorkflowStep, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..78bbd40273 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2329,6 +2329,8 @@ "repo.settings.event_workflow_run_desc": "Gitea Actions Workflow run queued, waiting, in progress, or completed.", "repo.settings.event_workflow_job": "Workflow Jobs", "repo.settings.event_workflow_job_desc": "Gitea Actions Workflow job queued, waiting, in progress, or completed.", + "repo.settings.event_workflow_step": "Workflow Steps", + "repo.settings.event_workflow_step_desc": "Gitea Actions Workflow step started or completed.", "repo.settings.event_package": "Package", "repo.settings.event_package_desc": "Package created or deleted in a repository.", "repo.settings.branch_filter": "Branch filter", diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index eee39760ed..c39e09b104 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -183,6 +183,16 @@ func (s *Service) UpdateTask( ) (*connect.Response[runnerv1.UpdateTaskResponse], error) { runner := GetRunner(ctx) + // Snapshot step states before the update so we can detect which steps changed. + oldStepStates := make(map[int64]actions_model.Status) + if existingTask, err := actions_model.GetTaskByID(ctx, req.Msg.State.Id); err == nil { + if err := existingTask.LoadAttributes(ctx); err == nil { + for _, step := range existingTask.Steps { + oldStepStates[step.Index] = step.Status + } + } + } + task, err := actions_model.UpdateTaskByState(ctx, runner.ID, req.Msg.State) if err != nil { return nil, status.Errorf(codes.Internal, "update task: %v", err) @@ -222,6 +232,14 @@ func (s *Service) UpdateTask( actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job) + // Emit step events for any step whose status changed during this update. + for _, step := range task.Steps { + oldStatus, seen := oldStepStates[step.Index] + if !seen || oldStatus != step.Status { + actions_service.NotifyWorkflowStepStatusUpdate(ctx, task.Job, task, step) + } + } + if task.Status.IsDone() { actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task) } diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 8dc19b63a8..ba3cda58b1 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -176,6 +176,7 @@ func updateHookEvents(events []string) webhook_module.HookEvents { hookEvents[webhook_module.HookEventStatus] = util.SliceContainsString(events, string(webhook_module.HookEventStatus), true) hookEvents[webhook_module.HookEventWorkflowRun] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowRun), true) hookEvents[webhook_module.HookEventWorkflowJob] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowJob), true) + hookEvents[webhook_module.HookEventWorkflowStep] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowStep), true) // Issues hookEvents[webhook_module.HookEventIssues] = issuesHook(events, "issues_only") diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 8c57a68b25..5fb38f7d5e 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -187,6 +187,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { webhook_module.HookEventStatus: form.Status, webhook_module.HookEventWorkflowRun: form.WorkflowRun, webhook_module.HookEventWorkflowJob: form.WorkflowJob, + webhook_module.HookEventWorkflowStep: form.WorkflowStep, }, BranchFilter: form.BranchFilter, } diff --git a/services/actions/notify.go b/services/actions/notify.go index e8b05c9fec..035ced3785 100644 --- a/services/actions/notify.go +++ b/services/actions/notify.go @@ -119,6 +119,30 @@ func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model. } } +// NotifyWorkflowStepStatusUpdate notifies a single step status update when a concrete task is available. +// Use it when a step transitions between queued, in_progress, and completed states. +func NotifyWorkflowStepStatusUpdate(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + if job.RunAttemptID == 0 { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + notify_service.WorkflowStepStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task, step) + return + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + notify_service.WorkflowStepStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task, step) +} + // NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available. // Use it for runner/task lifecycle callbacks so the notification includes the originating task context. func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { diff --git a/services/convert/convert.go b/services/convert/convert.go index dc1692b6ca..0854fb4478 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -357,6 +357,20 @@ func ToActionsStatus(status actions_model.Status) (string, string) { return action, conclusion } +// ToActionWorkflowStep converts an actions_model.ActionTaskStep to an api.ActionWorkflowStep. +// The step index (0-based position) must be passed by the caller because it is not stored on the model. +func ToActionWorkflowStep(step *actions_model.ActionTaskStep, stepIndex int) *api.ActionWorkflowStep { + stepStatus, stepConclusion := ToActionsStatus(step.Status) + return &api.ActionWorkflowStep{ + Name: step.Name, + Number: int64(stepIndex), + Status: stepStatus, + Conclusion: stepConclusion, + StartedAt: step.Started.AsTime().UTC(), + CompletedAt: step.Stopped.AsTime().UTC(), + } +} + // ToActionWorkflowJob convert a actions_model.ActionRunJob to an api.ActionWorkflowJob // task is optional and can be nil func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, job *actions_model.ActionRunJob) (*api.ActionWorkflowJob, error) { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..a77cceae89 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -230,6 +230,7 @@ type WebhookForm struct { Status bool WorkflowRun bool WorkflowJob bool + WorkflowStep bool Active bool BranchFilter string `binding:"GlobPattern"` AuthorizationHeader string diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 875a70e564..511f80244e 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -82,4 +82,6 @@ type Notifier interface { WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) + + WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) } diff --git a/services/notify/notify.go b/services/notify/notify.go index 152d53b01c..c2f229b372 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -416,3 +416,10 @@ func WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, s notifier.WorkflowJobStatusUpdate(ctx, repo, sender, job, task) } } + +// WorkflowStepStatusUpdate dispatches a workflow step status change to every registered notifier. +func WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + for _, notifier := range notifiers { + notifier.WorkflowStepStatusUpdate(ctx, repo, sender, job, task, step) + } +} diff --git a/services/notify/null.go b/services/notify/null.go index c3085d7c9e..b4bf4fc622 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -219,3 +219,6 @@ func (*NullNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_mod func (*NullNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { } + +func (*NullNotifier) WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { +} diff --git a/services/webhook/dingtalk.go b/services/webhook/dingtalk.go index 955826544f..b5496d217d 100644 --- a/services/webhook/dingtalk.go +++ b/services/webhook/dingtalk.go @@ -188,6 +188,12 @@ func (dingtalkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DingtalkPayload return createDingtalkPayload(text, text, "Workflow Job", p.WorkflowJob.HTMLURL), nil } +func (dingtalkConvertor) WorkflowStep(p *api.WorkflowStepPayload) (DingtalkPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, noneLinkFormatter, true) + + return createDingtalkPayload(text, text, "Workflow Step", p.WorkflowJob.HTMLURL), nil +} + func createDingtalkPayload(title, text, singleTitle, singleURL string) DingtalkPayload { return DingtalkPayload{ MsgType: "actionCard", diff --git a/services/webhook/discord.go b/services/webhook/discord.go index c0af7c0243..f988f3e03a 100644 --- a/services/webhook/discord.go +++ b/services/webhook/discord.go @@ -290,6 +290,12 @@ func (d discordConvertor) WorkflowJob(p *api.WorkflowJobPayload) (DiscordPayload return d.createPayload(p.Sender, text, "", p.WorkflowJob.HTMLURL, color), nil } +func (d discordConvertor) WorkflowStep(p *api.WorkflowStepPayload) (DiscordPayload, error) { + text, color := getWorkflowStepPayloadInfo(p, noneLinkFormatter, false) + + return d.createPayload(p.Sender, text, "", p.WorkflowJob.HTMLURL, color), nil +} + func newDiscordRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &DiscordMeta{} if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { diff --git a/services/webhook/discord_test.go b/services/webhook/discord_test.go index 7f503e3374..370a06b629 100644 --- a/services/webhook/discord_test.go +++ b/services/webhook/discord_test.go @@ -280,6 +280,21 @@ func TestDiscordPayload(t *testing.T) { assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) }) + + t.Run("WorkflowStep", func(t *testing.T) { + p := workflowStepTestPayload() + + pl, err := dc.WorkflowStep(p) + require.NoError(t, err) + + assert.Len(t, pl.Embeds, 1) + assert.Equal(t, "Workflow Step completed: build / Run tests:success", pl.Embeds[0].Title) + assert.Empty(t, pl.Embeds[0].Description) + assert.Equal(t, p.WorkflowJob.HTMLURL, pl.Embeds[0].URL) + assert.Equal(t, p.Sender.UserName, pl.Embeds[0].Author.Name) + assert.Equal(t, setting.AppURL+p.Sender.UserName, pl.Embeds[0].Author.URL) + assert.Equal(t, p.Sender.AvatarURL, pl.Embeds[0].Author.IconURL) + }) } func TestDiscordJSONPayload(t *testing.T) { diff --git a/services/webhook/feishu.go b/services/webhook/feishu.go index ac581df85a..1267235431 100644 --- a/services/webhook/feishu.go +++ b/services/webhook/feishu.go @@ -191,6 +191,12 @@ func (feishuConvertor) WorkflowJob(p *api.WorkflowJobPayload) (FeishuPayload, er return newFeishuTextPayload(text), nil } +func (feishuConvertor) WorkflowStep(p *api.WorkflowStepPayload) (FeishuPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, noneLinkFormatter, true) + + return newFeishuTextPayload(text), nil +} + // feishuGenSign generates a signature for Feishu webhook // https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot func feishuGenSign(secret string, timestamp int64) string { diff --git a/services/webhook/general.go b/services/webhook/general.go index 9572c926df..61455bbd16 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -389,6 +389,37 @@ func getWorkflowJobPayloadInfo(p *api.WorkflowJobPayload, linkFormatter linkForm return text, color } +func getWorkflowStepPayloadInfo(p *api.WorkflowStepPayload, linkFormatter linkFormatter, withSender bool) (text string, color int) { + description := p.Step.Conclusion + if description == "" { + description = p.Step.Status + } + stepLink := linkFormatter(p.WorkflowJob.HTMLURL, fmt.Sprintf("%s / %s", p.WorkflowJob.Name, p.Step.Name)+":"+description) + + text = fmt.Sprintf("Workflow Step %s: %s", p.Action, stepLink) + switch description { + case "waiting": + color = orangeColor + case "queued": + color = orangeColorLight + case "success": + color = greenColor + case "failure": + color = redColor + case "cancelled": + color = yellowColor + case "skipped": + color = purpleColor + default: + color = greyColor + } + if withSender { + text += " by " + linkFormatter(setting.AppURL+url.PathEscape(p.Sender.UserName), p.Sender.UserName) + } + + return text, color +} + // ToHook convert models.Webhook to api.Hook // This function is not part of the convert package to prevent an import cycle func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) { diff --git a/services/webhook/general_test.go b/services/webhook/general_test.go index ec735d785a..2689f82381 100644 --- a/services/webhook/general_test.go +++ b/services/webhook/general_test.go @@ -610,6 +610,125 @@ func TestGetReleasePayloadInfo(t *testing.T) { } } +func workflowJobTestPayload() *api.WorkflowJobPayload { + return &api.WorkflowJobPayload{ + Action: "completed", + WorkflowJob: &api.ActionWorkflowJob{ + ID: 1, + Name: "build", + Status: "completed", + RunID: 42, + HeadSha: "abc123", + HTMLURL: "http://localhost:3000/test/repo/actions/runs/42/jobs/1", + }, + Repo: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + Sender: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + } +} + +func workflowStepTestPayload() *api.WorkflowStepPayload { + return &api.WorkflowStepPayload{ + Action: "completed", + WorkflowRun: &api.ActionWorkflowRun{ + ID: 42, + DisplayTitle: "Push: main", + }, + WorkflowJob: &api.ActionWorkflowJob{ + ID: 1, + Name: "build", + Status: "completed", + RunID: 42, + HeadSha: "abc123", + HTMLURL: "http://localhost:3000/test/repo/actions/runs/42/jobs/1", + }, + Step: &api.ActionWorkflowStep{ + Name: "Run tests", + Number: 0, + Status: "completed", + Conclusion: "success", + }, + Repo: &api.Repository{ + HTMLURL: "http://localhost:3000/test/repo", + Name: "repo", + FullName: "test/repo", + }, + Sender: &api.User{ + UserName: "user1", + AvatarURL: "http://localhost:3000/user1/avatar", + }, + } +} + +func TestGetWorkflowStepPayloadInfo(t *testing.T) { + cases := []struct { + action string + status string + conclusion string + expectText string + expectColor int + }{ + { + action: "queued", + status: "queued", + conclusion: "", + expectText: "Workflow Step queued: build / Run tests:queued", + expectColor: orangeColorLight, + }, + { + action: "in_progress", + status: "in_progress", + conclusion: "", + expectText: "Workflow Step in_progress: build / Run tests:in_progress", + expectColor: greyColor, + }, + { + action: "completed", + status: "completed", + conclusion: "success", + expectText: "Workflow Step completed: build / Run tests:success", + expectColor: greenColor, + }, + { + action: "completed", + status: "completed", + conclusion: "failure", + expectText: "Workflow Step completed: build / Run tests:failure", + expectColor: redColor, + }, + { + action: "completed", + status: "completed", + conclusion: "cancelled", + expectText: "Workflow Step completed: build / Run tests:cancelled", + expectColor: yellowColor, + }, + { + action: "completed", + status: "completed", + conclusion: "skipped", + expectText: "Workflow Step completed: build / Run tests:skipped", + expectColor: purpleColor, + }, + } + + for i, c := range cases { + p := workflowStepTestPayload() + p.Action = c.action + p.Step.Status = c.status + p.Step.Conclusion = c.conclusion + text, color := getWorkflowStepPayloadInfo(p, noneLinkFormatter, false) + assert.Equal(t, c.expectText, text, "case %d", i) + assert.Equal(t, c.expectColor, color, "case %d", i) + } +} + func TestGetIssueCommentPayloadInfo(t *testing.T) { p := pullRequestCommentTestPayload() diff --git a/services/webhook/matrix.go b/services/webhook/matrix.go index fa01ecd0b1..7b4abe5a50 100644 --- a/services/webhook/matrix.go +++ b/services/webhook/matrix.go @@ -265,6 +265,12 @@ func (m matrixConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MatrixPayload, return m.newPayload(text) } +func (m matrixConvertor) WorkflowStep(p *api.WorkflowStepPayload) (MatrixPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, htmlLinkFormatter, true) + + return m.newPayload(text) +} + var urlRegex = regexp.MustCompile(`]*?href="([^">]*?)">(.*?)`) func getMessageBody(htmlText string) string { diff --git a/services/webhook/msteams.go b/services/webhook/msteams.go index 34db903712..73b193ae0c 100644 --- a/services/webhook/msteams.go +++ b/services/webhook/msteams.go @@ -346,6 +346,20 @@ func (msteamsConvertor) WorkflowJob(p *api.WorkflowJobPayload) (MSTeamsPayload, ), nil } +func (msteamsConvertor) WorkflowStep(p *api.WorkflowStepPayload) (MSTeamsPayload, error) { + title, color := getWorkflowStepPayloadInfo(p, noneLinkFormatter, false) + + return createMSTeamsPayload( + p.Repo, + p.Sender, + title, + "", + p.WorkflowJob.HTMLURL, + color, + &MSTeamsFact{"WorkflowStep:", p.Step.Name}, + ), nil +} + func createMSTeamsPayload(r *api.Repository, s *api.User, title, text, actionTarget string, color int, fact *MSTeamsFact) MSTeamsPayload { facts := make([]MSTeamsFact, 0, 2) if r != nil { diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 7627935a32..bc12d6b5a2 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -984,6 +984,55 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo } } +func (*webhookNotifier) WorkflowStepStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask, step *actions_model.ActionTaskStep) { + source := EventSource{ + Repository: repo, + Owner: repo.Owner, + } + + var org *api.Organization + if repo.Owner.IsOrganization() { + org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner)) + } + + stepStatus, _ := convert.ToActionsStatus(step.Status) + + convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job) + if err != nil { + log.Error("ToActionWorkflowJob: %v", err) + return + } + + // Find the step index within the task steps for the Number field. + stepIndex := int(step.Index) + + convertedStep := convert.ToActionWorkflowStep(step, stepIndex) + + // Build a minimal WorkflowRun for the payload. + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, job.Run, nil) + if err != nil { + log.Error("ToActionWorkflowRun: %v", err) + return + } + + if err := PrepareWebhooks(ctx, source, webhook_module.HookEventWorkflowStep, &api.WorkflowStepPayload{ + Action: stepStatus, + WorkflowRun: convertedRun, + WorkflowJob: convertedJob, + Step: convertedStep, + Organization: org, + Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner}), + Sender: convert.ToUser(ctx, sender, nil), + }); err != nil { + log.Error("PrepareWebhooks: %v", err) + } +} + func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { source := EventSource{ Repository: repo, diff --git a/services/webhook/packagist.go b/services/webhook/packagist.go index e6a00b0293..cdb8a6ffd9 100644 --- a/services/webhook/packagist.go +++ b/services/webhook/packagist.go @@ -122,6 +122,10 @@ func (pc packagistConvertor) WorkflowJob(_ *api.WorkflowJobPayload) (PackagistPa return PackagistPayload{}, nil } +func (pc packagistConvertor) WorkflowStep(_ *api.WorkflowStepPayload) (PackagistPayload, error) { + return PackagistPayload{}, nil +} + func newPackagistRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { meta := &PackagistMeta{} if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { diff --git a/services/webhook/payloader.go b/services/webhook/payloader.go index b607bf3250..b1ec74284c 100644 --- a/services/webhook/payloader.go +++ b/services/webhook/payloader.go @@ -31,6 +31,7 @@ type payloadConvertor[T any] interface { Status(*api.CommitStatusPayload) (T, error) WorkflowRun(*api.WorkflowRunPayload) (T, error) WorkflowJob(*api.WorkflowJobPayload) (T, error) + WorkflowStep(*api.WorkflowStepPayload) (T, error) } func convertUnmarshalledJSON[T, P any](convert func(P) (T, error), data []byte) (t T, err error) { @@ -86,6 +87,8 @@ func newPayload[T any](rc payloadConvertor[T], data []byte, event webhook_module return convertUnmarshalledJSON(rc.WorkflowRun, data) case webhook_module.HookEventWorkflowJob: return convertUnmarshalledJSON(rc.WorkflowJob, data) + case webhook_module.HookEventWorkflowStep: + return convertUnmarshalledJSON(rc.WorkflowStep, data) } return t, fmt.Errorf("newPayload unsupported event: %s", event) } diff --git a/services/webhook/slack.go b/services/webhook/slack.go index 94d41d2179..d237b19b24 100644 --- a/services/webhook/slack.go +++ b/services/webhook/slack.go @@ -185,6 +185,12 @@ func (s slackConvertor) WorkflowJob(p *api.WorkflowJobPayload) (SlackPayload, er return s.createPayload(text, nil), nil } +func (s slackConvertor) WorkflowStep(p *api.WorkflowStepPayload) (SlackPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, SlackLinkFormatter, true) + + return s.createPayload(text, nil), nil +} + // Push implements payloadConvertor Push method func (s slackConvertor) Push(p *api.PushPayload) (SlackPayload, error) { // n new commits diff --git a/services/webhook/slack_test.go b/services/webhook/slack_test.go index 839ed6f770..1e31ef0368 100644 --- a/services/webhook/slack_test.go +++ b/services/webhook/slack_test.go @@ -155,6 +155,15 @@ func TestSlackPayload(t *testing.T) { assert.Equal(t, "[] Release created: by ", pl.Text) }) + + t.Run("WorkflowStep", func(t *testing.T) { + p := workflowStepTestPayload() + + pl, err := sc.WorkflowStep(p) + require.NoError(t, err) + + assert.Equal(t, "Workflow Step completed: by ", pl.Text) + }) } func TestSlackJSONPayload(t *testing.T) { diff --git a/services/webhook/telegram.go b/services/webhook/telegram.go index 8e9a53a5de..d040da289b 100644 --- a/services/webhook/telegram.go +++ b/services/webhook/telegram.go @@ -192,6 +192,12 @@ func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload return createTelegramPayloadHTML(text), nil } +func (telegramConvertor) WorkflowStep(p *api.WorkflowStepPayload) (TelegramPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, htmlLinkFormatter, true) + + return createTelegramPayloadHTML(text), nil +} + func createTelegramPayloadHTML(msgHTML string) TelegramPayload { // https://core.telegram.org/bots/api#formatting-options return TelegramPayload{ diff --git a/services/webhook/wechatwork.go b/services/webhook/wechatwork.go index cac6a700c0..010adf1c49 100644 --- a/services/webhook/wechatwork.go +++ b/services/webhook/wechatwork.go @@ -193,6 +193,12 @@ func (wc wechatworkConvertor) WorkflowJob(p *api.WorkflowJobPayload) (Wechatwork return newWechatworkMarkdownPayload(text), nil } +func (wc wechatworkConvertor) WorkflowStep(p *api.WorkflowStepPayload) (WechatworkPayload, error) { + text, _ := getWorkflowStepPayloadInfo(p, noneLinkFormatter, true) + + return newWechatworkMarkdownPayload(text), nil +} + func newWechatworkRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) { var pc payloadConvertor[WechatworkPayload] = wechatworkConvertor{} return newJSONRequest(pc, w, t, true) diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl index 5f8ba7bbf1..b8e02b45d4 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -346,6 +346,16 @@ + +
+
+
+ + + {{ctx.Locale.Tr "repo.settings.event_workflow_step_desc"}} +
+
+
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 12a11d45bd..dfade358dd 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -35,9 +35,11 @@ import ( "code.gitea.io/gitea/tests" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "connectrpc.com/connect" "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestNewWebHookLink(t *testing.T) { @@ -1112,6 +1114,111 @@ jobs: }) } +func Test_WebhookWorkflowStep(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + var payloads []api.WorkflowStepPayload + var triggeredEvent string + provider := newMockWebhookProvider(func(r *http.Request) { + assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_step", "X-GitHub-Event-Type should contain workflow_step") + assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_step", "X-Gitea-Event-Type should contain workflow_step") + assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_step", "X-Gogs-Event-Type should contain workflow_step") + content, _ := io.ReadAll(r.Body) + var payload api.WorkflowStepPayload + err := json.Unmarshal(content, &payload) + assert.NoError(t, err) + payloads = append(payloads, payload) + triggeredEvent = "workflow_step" + }, http.StatusOK) + defer provider.Close() + + // 1. create a webhook subscribed to workflow_step for repo1 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + testAPICreateWebhookForRepo(t, session, "user2", "repo1", provider.URL(), "workflow_step") + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1}) + + gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1) + assert.NoError(t, err) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, "user2", "repo1", "mock-runner", []string{"ubuntu-latest"}, false) + + // 2. push a workflow with a single step + wfTreePath := ".gitea/workflows/push.yml" + wfFileContent := `name: Push +on: push +jobs: + wf1-job: + runs-on: ubuntu-latest + steps: + - run: echo 'step one' +` + opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent) + createWorkflowFile(t, token, "user2", "repo1", wfTreePath, opts) + + commitID, err := gitRepo1.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + + // 3. no step webhooks yet — only job-level events have fired at this point + assert.Empty(t, payloads) + + // 4. fetch the task so the runner picks it up (step rows created in DB) + task := runner.fetchTask(t) + assert.NotNil(t, task) + + // 5. send an intermediate UpdateTask marking step 0 as in_progress + _, err = runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Steps: []*runnerv1.StepState{ + {Id: 0, StartedAt: timestamppb.Now()}, + }, + }, + })) + assert.NoError(t, err) + + assert.Equal(t, "workflow_step", triggeredEvent) + require.Len(t, payloads, 1) + assert.Equal(t, "in_progress", payloads[0].Action) + assert.Equal(t, "in_progress", payloads[0].Step.Status) + assert.Equal(t, commitID, payloads[0].WorkflowJob.HeadSha) + assert.Equal(t, "repo1", payloads[0].Repo.Name) + assert.Equal(t, "user2/repo1", payloads[0].Repo.FullName) + + // 6. send final UpdateTask marking the task as success and step 0 as completed + _, err = runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Result: runnerv1.Result_RESULT_SUCCESS, + StoppedAt: timestamppb.Now(), + Steps: []*runnerv1.StepState{ + { + Id: 0, + Result: runnerv1.Result_RESULT_SUCCESS, + StartedAt: timestamppb.Now(), + StoppedAt: timestamppb.Now(), + }, + }, + }, + })) + assert.NoError(t, err) + + assert.Equal(t, "workflow_step", triggeredEvent) + require.Len(t, payloads, 2) + assert.Equal(t, "completed", payloads[1].Action) + assert.Equal(t, "completed", payloads[1].Step.Status) + assert.Equal(t, "success", payloads[1].Step.Conclusion) + assert.Equal(t, commitID, payloads[1].WorkflowJob.HeadSha) + assert.Equal(t, "repo1", payloads[1].Repo.Name) + assert.Equal(t, "user2/repo1", payloads[1].Repo.FullName) + assert.NotNil(t, payloads[1].WorkflowRun) + assert.NotNil(t, payloads[1].WorkflowJob) + }) +} + type workflowRunWebhook struct { URL string payloads []api.WorkflowRunPayload From 3d9a09496de04b5a80b25dad7f631b522a4f1c41 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 7 May 2026 18:46:18 -0500 Subject: [PATCH 2/5] fix(actions): use per-step status in workflow job API response; add build workflow ToActionWorkflowJob was calling ToActionsStatus(job.Status) for every step, making each step reflect the whole job conclusion instead of its own state. Replace the inline loop with the existing ToActionWorkflowStep helper which correctly uses step.Status. Add .gitea/workflows/build-push.yaml to build and push a Docker image to Artifact Registry on push to this branch, enabling deployment to GKE. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build-push.yaml | 59 ++++++++++++++++++++++++++++++++ services/convert/convert.go | 10 +----- 2 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/build-push.yaml diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml new file mode 100644 index 0000000000..bfb4eacd4f --- /dev/null +++ b/.gitea/workflows/build-push.yaml @@ -0,0 +1,59 @@ +name: Build & Push Gitea image + +on: + push: + branches: + - feat/workflow-step-events + workflow_dispatch: + +jobs: + build-push: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: neuron-785695 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet + + - name: Set image tag + id: tag + run: | + SHA="${GITHUB_SHA:0:8}" + echo "tag=us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea:${SHA}" >> "$GITHUB_OUTPUT" + echo "latest=us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea:latest" >> "$GITHUB_OUTPUT" + echo "SHA=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Build Gitea image + run: | + docker build \ + --build-arg GITEA_VERSION="neuron-$(git rev-parse --short HEAD)" \ + --build-arg TAGS="sqlite sqlite_unlock_notify" \ + -t "${{ steps.tag.outputs.tag }}" \ + -t "${{ steps.tag.outputs.latest }}" \ + . + + - name: Push Gitea image + run: | + docker push "${{ steps.tag.outputs.tag }}" + docker push "${{ steps.tag.outputs.latest }}" + echo "Pushed gitea:${{ steps.tag.outputs.SHA }} and :latest" + + - name: Output image tag + run: | + echo "::notice title=Image pushed::${{ steps.tag.outputs.tag }}" + echo "Update deployment.yaml image to: ${{ steps.tag.outputs.tag }}" diff --git a/services/convert/convert.go b/services/convert/convert.go index 0854fb4478..05fee95a74 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -405,15 +405,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task runnerName = runner.Name } for i, step := range task.Steps { - stepStatus, stepConclusion := ToActionsStatus(job.Status) - steps = append(steps, &api.ActionWorkflowStep{ - Name: step.Name, - Number: int64(i), - Status: stepStatus, - Conclusion: stepConclusion, - StartedAt: step.Started.AsTime().UTC(), - CompletedAt: step.Stopped.AsTime().UTC(), - }) + steps = append(steps, ToActionWorkflowStep(step, i)) } } } From 4e929ad814e1f6bbcb6987bd41a4af63a7b7ead9 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 7 May 2026 18:47:04 -0500 Subject: [PATCH 3/5] ci: dev/stage/main branch build pipeline for custom Gitea image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build and push to Artifact Registry on merge to dev/stage/main: dev → gitea:dev stage → gitea:stage main → gitea:latest + gitea: Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build-push.yaml | 58 ++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index bfb4eacd4f..4cf161ac7f 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -1,9 +1,17 @@ name: Build & Push Gitea image +# Builds and pushes a custom Gitea image to Artifact Registry. +# Branch → image tag mapping: +# dev → gitea:dev (inner dev loop) +# stage → gitea:stage (staging verification) +# main → gitea:latest + gitea: (production) + on: push: branches: - - feat/workflow-step-events + - dev + - stage + - main workflow_dispatch: jobs: @@ -30,30 +38,52 @@ jobs: - name: Configure Docker for Artifact Registry run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet - - name: Set image tag + - name: Compute image tags id: tag run: | + REGISTRY="us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea" SHA="${GITHUB_SHA:0:8}" - echo "tag=us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea:${SHA}" >> "$GITHUB_OUTPUT" - echo "latest=us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea:latest" >> "$GITHUB_OUTPUT" - echo "SHA=${SHA}" >> "$GITHUB_OUTPUT" + BRANCH="${GITEA_REF_NAME:-${GITHUB_REF_NAME}}" + case "$BRANCH" in + main) + TAGS="${REGISTRY}:latest ${REGISTRY}:${SHA}" + ENV_TAG="latest" + ;; + stage) + TAGS="${REGISTRY}:stage ${REGISTRY}:stage-${SHA}" + ENV_TAG="stage" + ;; + dev) + TAGS="${REGISTRY}:dev ${REGISTRY}:dev-${SHA}" + ENV_TAG="dev" + ;; + *) + TAGS="${REGISTRY}:${SHA}" + ENV_TAG="${SHA}" + ;; + esac + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + echo "primary=${REGISTRY}:${ENV_TAG}" >> "$GITHUB_OUTPUT" + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - name: Build Gitea image run: | + TAGS="" + for t in ${{ steps.tag.outputs.tags }}; do + TAGS="$TAGS -t $t" + done docker build \ --build-arg GITEA_VERSION="neuron-$(git rev-parse --short HEAD)" \ --build-arg TAGS="sqlite sqlite_unlock_notify" \ - -t "${{ steps.tag.outputs.tag }}" \ - -t "${{ steps.tag.outputs.latest }}" \ + $TAGS \ . - name: Push Gitea image run: | - docker push "${{ steps.tag.outputs.tag }}" - docker push "${{ steps.tag.outputs.latest }}" - echo "Pushed gitea:${{ steps.tag.outputs.SHA }} and :latest" + for t in ${{ steps.tag.outputs.tags }}; do + docker push "$t" + echo "Pushed: $t" + done - - name: Output image tag - run: | - echo "::notice title=Image pushed::${{ steps.tag.outputs.tag }}" - echo "Update deployment.yaml image to: ${{ steps.tag.outputs.tag }}" + - name: Output + run: echo "::notice title=Image::${{ steps.tag.outputs.primary }}" From 5393a85b045a463f6d8622e05dafdcdb26664f46 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 7 May 2026 18:57:30 -0500 Subject: [PATCH 4/5] ci: enable DOCKER_BUILDKIT for BuildKit FROM --platform support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gitea Dockerfile uses FROM --platform=$BUILDPLATFORM which requires BuildKit. The GCE runner defaults to the legacy builder — set DOCKER_BUILDKIT=1 to enable it. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build-push.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 4cf161ac7f..36e6199b64 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -67,6 +67,8 @@ jobs: echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - name: Build Gitea image + env: + DOCKER_BUILDKIT: "1" run: | TAGS="" for t in ${{ steps.tag.outputs.tags }}; do From 83223bb939f5320762d90754e977b85992f2d3dd Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 7 May 2026 19:01:46 -0500 Subject: [PATCH 5/5] ci: install docker buildx plugin; switch to buildx build --load DOCKER_BUILDKIT=1 requires the buildx plugin which isn't installed on the runner. Install it explicitly then use docker buildx build --load (outputs to local daemon so the push step can tag+push normally). Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build-push.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 36e6199b64..88f6c4d649 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -66,15 +66,22 @@ jobs: echo "primary=${REGISTRY}:${ENV_TAG}" >> "$GITHUB_OUTPUT" echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + - name: Install docker buildx + run: | + mkdir -p ~/.docker/cli-plugins + BUILDX_URL="https://github.com/docker/buildx/releases/download/v0.20.1/buildx-v0.20.1.linux-amd64" + curl -fsSL "$BUILDX_URL" -o ~/.docker/cli-plugins/docker-buildx + chmod +x ~/.docker/cli-plugins/docker-buildx + docker buildx version + - name: Build Gitea image - env: - DOCKER_BUILDKIT: "1" run: | TAGS="" for t in ${{ steps.tag.outputs.tags }}; do TAGS="$TAGS -t $t" done - docker build \ + docker buildx build \ + --load \ --build-arg GITEA_VERSION="neuron-$(git rev-parse --short HEAD)" \ --build-arg TAGS="sqlite sqlite_unlock_notify" \ $TAGS \