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 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 17:47:14 -05:00
parent a39af1a829
commit bb6faa4c5d
30 changed files with 504 additions and 3 deletions
+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