chore: promote stage to main
Build & Push Gitea image / build-push (push) Successful in 2m37s

This commit was merged in pull request #3.
This commit is contained in:
2026-05-08 06:20:37 +00:00
31 changed files with 603 additions and 12 deletions
+98
View File
@@ -0,0 +1,98 @@
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:<sha> (production)
on:
push:
branches:
- dev
- stage
- main
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: Compute image tags
id: tag
run: |
REGISTRY="us-central1-docker.pkg.dev/neuron-785695/neuron-ci/gitea"
SHA="${GITHUB_SHA:0:8}"
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: 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
run: |
TAGS=""
for t in ${{ steps.tag.outputs.tags }}; do
TAGS="$TAGS -t $t"
done
docker buildx build \
--load \
--build-arg GITEA_VERSION="neuron-$(git rev-parse --short HEAD)" \
--build-arg TAGS="sqlite sqlite_unlock_notify" \
$TAGS \
.
- name: Push Gitea image
run: |
for t in ${{ steps.tag.outputs.tags }}; do
docker push "$t"
echo "Pushed: $t"
done
- name: Output
run: echo "::notice title=Image::${{ steps.tag.outputs.primary }}"
+1 -1
View File
@@ -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},
+23
View File
@@ -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, "", " ")
}
+4 -2
View File
@@ -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,
}
}
+2
View File
@@ -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",
+18
View File
@@ -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)
}
+1
View File
@@ -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")
+1
View File
@@ -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,
}
+24
View File
@@ -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) {
+15 -9
View File
@@ -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) {
@@ -391,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))
}
}
}
+1
View File
@@ -230,6 +230,7 @@ type WebhookForm struct {
Status bool
WorkflowRun bool
WorkflowJob bool
WorkflowStep bool
Active bool
BranchFilter string `binding:"GlobPattern"`
AuthorizationHeader string
+2
View File
@@ -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)
}
+7
View File
@@ -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)
}
}
+3
View File
@@ -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) {
}
+6
View File
@@ -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",
+6
View File
@@ -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 {
+15
View File
@@ -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) {
+6
View File
@@ -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 {
+31
View File
@@ -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) {
+119
View File
@@ -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()
+6
View File
@@ -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(`<a [^>]*?href="([^">]*?)">(.*?)</a>`)
func getMessageBody(htmlText string) string {
+14
View File
@@ -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 {
+49
View File
@@ -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,
+4
View File
@@ -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 {
+3
View File
@@ -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)
}
+6
View File
@@ -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
+9
View File
@@ -155,6 +155,15 @@ func TestSlackPayload(t *testing.T) {
assert.Equal(t, "[<http://localhost:3000/test/repo|test/repo>] Release created: <http://localhost:3000/test/repo/releases/tag/v1.0|v1.0> by <https://try.gitea.io/user1|user1>", 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: <http://localhost:3000/test/repo/actions/runs/42/jobs/1|build / Run tests:success> by <https://try.gitea.io/user1|user1>", pl.Text)
})
}
func TestSlackJSONPayload(t *testing.T) {
+6
View File
@@ -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{
+6
View File
@@ -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)
@@ -346,6 +346,16 @@
</div>
</div>
</div>
<!-- Workflow Step Event -->
<div class="seven wide column">
<div class="field">
<div class="ui checkbox">
<input name="workflow_step" type="checkbox" {{if .Webhook.HookEvents.Get "workflow_step"}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.event_workflow_step"}}</label>
<span class="help">{{ctx.Locale.Tr "repo.settings.event_workflow_step_desc"}}</span>
</div>
</div>
</div>
</div>
</div>
+107
View File
@@ -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