1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-28 05:08:37 +00:00

Add workflow_run api + webhook (#33964)

Implements 
- https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#list-jobs-for-a-workflow-run--code-samples
- https://docs.github.com/en/rest/actions/workflow-jobs?apiVersion=2022-11-28#get-a-job-for-a-workflow-run--code-samples
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-repository
- https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#get-a-workflow-run
  - `/actions/runs` for global + user + org (Gitea only)
  - `/actions/jobs` for global + user + org + repository (Gitea only)
  - workflow_run webhook + action trigger
    - limitations
- workflow id is assigned to a string, this may result into problems in
strongly typed clients

Fixes
- workflow_job webhook url to no longer contain the `runs/<run>` part to
align with api
- workflow instance does now use it's name inside the file instead of
filename if set

Refactoring
- Moved a lot of logic from workflows/workflow_job into a shared module
used by both webhook and api

TODO
- [x] Verify Keda Compatibility
- [x] Edit Webhook API bug is resolved
 
Closes https://github.com/go-gitea/gitea/issues/23670
Closes https://github.com/go-gitea/gitea/issues/23796
Closes https://github.com/go-gitea/gitea/issues/24898
Replaces https://github.com/go-gitea/gitea/pull/28047 and is much more
complete

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ChristopherHX
2025-06-20 14:14:00 +02:00
committed by GitHub
parent d462ce149d
commit cda90eca31
51 changed files with 2815 additions and 235 deletions

View File

@@ -720,7 +720,7 @@ func TestWorkflowDispatchPublicApi(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch
jobs:
@@ -800,7 +800,7 @@ func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
jobs:
@@ -891,7 +891,7 @@ func TestWorkflowDispatchPublicApiJSON(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
jobs:
@@ -977,7 +977,7 @@ func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
jobs:
@@ -1071,7 +1071,7 @@ func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch
jobs:
@@ -1107,7 +1107,7 @@ jobs:
{
Operation: "update",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
jobs:
@@ -1209,7 +1209,7 @@ func TestWorkflowApi(t *testing.T) {
{
Operation: "create",
TreePath: ".gitea/workflows/dispatch.yml",
ContentReader: strings.NewReader(`name: test
ContentReader: strings.NewReader(`
on:
workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }
jobs:

View File

@@ -910,8 +910,7 @@ jobs:
assert.Equal(t, commitID, payloads[3].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[3].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[3].Repo.FullName)
assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[3].WorkflowJob.RunID, payloads[3].WorkflowJob.ID))
assert.Contains(t, payloads[3].WorkflowJob.URL, payloads[3].WorkflowJob.RunURL)
assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID))
assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 0))
assert.Len(t, payloads[3].WorkflowJob.Steps, 1)
@@ -947,9 +946,207 @@ jobs:
assert.Equal(t, commitID, payloads[6].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[6].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[6].Repo.FullName)
assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/runs/%d/jobs/%d", payloads[6].WorkflowJob.RunID, payloads[6].WorkflowJob.ID))
assert.Contains(t, payloads[6].WorkflowJob.URL, payloads[6].WorkflowJob.RunURL)
assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID))
assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", 1))
assert.Len(t, payloads[6].WorkflowJob.Steps, 2)
})
}
type workflowRunWebhook struct {
URL string
payloads []api.WorkflowRunPayload
triggeredEvent string
}
func Test_WebhookWorkflowRun(t *testing.T) {
webhookData := &workflowRunWebhook{}
provider := newMockWebhookProvider(func(r *http.Request) {
assert.Contains(t, r.Header["X-Github-Event-Type"], "workflow_run", "X-GitHub-Event-Type should contain workflow_run")
assert.Contains(t, r.Header["X-Gitea-Event-Type"], "workflow_run", "X-Gitea-Event-Type should contain workflow_run")
assert.Contains(t, r.Header["X-Gogs-Event-Type"], "workflow_run", "X-Gogs-Event-Type should contain workflow_run")
content, _ := io.ReadAll(r.Body)
var payload api.WorkflowRunPayload
err := json.Unmarshal(content, &payload)
assert.NoError(t, err)
webhookData.payloads = append(webhookData.payloads, payload)
webhookData.triggeredEvent = "workflow_run"
}, http.StatusOK)
defer provider.Close()
webhookData.URL = provider.URL()
tests := []struct {
name string
callback func(t *testing.T, webhookData *workflowRunWebhook)
}{
{
name: "WorkflowRun",
callback: testWebhookWorkflowRun,
},
{
name: "WorkflowRunDepthLimit",
callback: testWebhookWorkflowRunDepthLimit,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
webhookData.payloads = nil
webhookData.triggeredEvent = ""
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
test.callback(t, webhookData)
})
})
}
}
func testWebhookWorkflowRun(t *testing.T, webhookData *workflowRunWebhook) {
// 1. create a new webhook with special webhook 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", webhookData.URL, "workflow_run")
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.1 add workflow_run workflow file to the repo
opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+"dispatch.yml", `
on:
workflow_run:
workflows: ["Push"]
types:
- completed
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- run: echo 'test the webhook'
`)
createWorkflowFile(t, token, "user2", "repo1", ".gitea/workflows/dispatch.yml", opts)
// 2.2 trigger the webhooks
// add workflow file to the repo
// init the workflow
wfTreePath := ".gitea/workflows/push.yml"
wfFileContent := `name: Push
on: push
jobs:
wf1-job:
runs-on: ubuntu-latest
steps:
- run: echo 'test the webhook'
wf2-job:
runs-on: ubuntu-latest
needs: wf1-job
steps:
- run: echo 'cmd 1'
- run: echo 'cmd 2'
`
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. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Len(t, webhookData.payloads, 1)
assert.Equal(t, "requested", webhookData.payloads[0].Action)
assert.Equal(t, "queued", webhookData.payloads[0].WorkflowRun.Status)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[0].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[0].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[0].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[0].Repo.FullName)
// 4. Execute two Jobs
task := runner.fetchTask(t)
outcome := &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
}
runner.execTask(t, task, outcome)
task = runner.fetchTask(t)
outcome = &mockTaskOutcome{
result: runnerv1.Result_RESULT_FAILURE,
}
runner.execTask(t, task, outcome)
// 7. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Len(t, webhookData.payloads, 3)
assert.Equal(t, "completed", webhookData.payloads[1].Action)
assert.Equal(t, "push", webhookData.payloads[1].WorkflowRun.Event)
// 3. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
assert.Len(t, webhookData.payloads, 3)
assert.Equal(t, "requested", webhookData.payloads[2].Action)
assert.Equal(t, "queued", webhookData.payloads[2].WorkflowRun.Status)
assert.Equal(t, "workflow_run", webhookData.payloads[2].WorkflowRun.Event)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[2].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[2].WorkflowRun.HeadSha)
assert.Equal(t, "repo1", webhookData.payloads[2].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[2].Repo.FullName)
}
func testWebhookWorkflowRunDepthLimit(t *testing.T, webhookData *workflowRunWebhook) {
// 1. create a new webhook with special webhook 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", webhookData.URL, "workflow_run")
repo1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1})
gitRepo1, err := gitrepo.OpenRepository(t.Context(), repo1)
assert.NoError(t, err)
// 2. trigger the webhooks
// add workflow file to the repo
// init the workflow
wfTreePath := ".gitea/workflows/push.yml"
wfFileContent := `name: Endless Loop
on:
push:
workflow_run:
types:
- requested
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- run: echo 'test the webhook'
`
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. validate the webhook is triggered
assert.Equal(t, "workflow_run", webhookData.triggeredEvent)
// 1x push + 5x workflow_run requested chain
assert.Len(t, webhookData.payloads, 6)
for i := range 6 {
assert.Equal(t, "requested", webhookData.payloads[i].Action)
assert.Equal(t, "queued", webhookData.payloads[i].WorkflowRun.Status)
assert.Equal(t, repo1.DefaultBranch, webhookData.payloads[i].WorkflowRun.HeadBranch)
assert.Equal(t, commitID, webhookData.payloads[i].WorkflowRun.HeadSha)
if i == 0 {
assert.Equal(t, "push", webhookData.payloads[i].WorkflowRun.Event)
} else {
assert.Equal(t, "workflow_run", webhookData.payloads[i].WorkflowRun.Event)
}
assert.Equal(t, "repo1", webhookData.payloads[i].Repo.Name)
assert.Equal(t, "user2/repo1", webhookData.payloads[i].Repo.FullName)
}
}

View File

@@ -0,0 +1,167 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIWorkflowRun(t *testing.T) {
t.Run("AdminRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/admin/actions", "User1", 802, auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeReadRepository)
})
t.Run("UserRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/user/actions", "User2", 803, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
})
t.Run("OrgRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/orgs/org3/actions", "User1", 802, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
})
t.Run("RepoRuns", func(t *testing.T) {
testAPIWorkflowRunBasic(t, "/api/v1/repos/org3/repo5/actions", "User2", 802, auth_model.AccessTokenScopeReadRepository)
})
}
func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runID int64, scope ...auth_model.AccessTokenScope) {
defer tests.PrepareTestEnv(t)()
token := getUserToken(t, userUsername, scope...)
apiRunsURL := fmt.Sprintf("%s/%s", apiRootURL, "runs")
req := NewRequest(t, "GET", apiRunsURL).AddTokenAuth(token)
runnerListResp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionWorkflowRunsResponse{}
DecodeJSON(t, runnerListResp, &runnerList)
foundRun := false
for _, run := range runnerList.Entries {
// Verify filtering works
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "")
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "")
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", run.HeadBranch, "", "")
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", run.Event, "", "", "")
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, "")
verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", "", "", "", run.TriggerActor.UserName, run.HeadSha)
// Verify run url works
req := NewRequest(t, "GET", run.URL).AddTokenAuth(token)
runResp := MakeRequest(t, req, http.StatusOK)
apiRun := api.ActionWorkflowRun{}
DecodeJSON(t, runResp, &apiRun)
assert.Equal(t, run.ID, apiRun.ID)
assert.Equal(t, run.Status, apiRun.Status)
assert.Equal(t, run.Conclusion, apiRun.Conclusion)
assert.Equal(t, run.Event, apiRun.Event)
// Verify jobs list works
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token)
jobsResp := MakeRequest(t, req, http.StatusOK)
jobList := api.ActionWorkflowJobsResponse{}
DecodeJSON(t, jobsResp, &jobList)
if run.ID == runID {
foundRun = true
assert.Len(t, jobList.Entries, 1)
for _, job := range jobList.Entries {
// Check the jobs list of the run
verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, "", job.Status)
verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", run.URL, "jobs"), token, job.ID, job.Conclusion, "")
// Check the run independent job list
verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, "", job.Status)
verifyWorkflowJobCanbeFoundWithStatusFilter(t, fmt.Sprintf("%s/%s", apiRootURL, "jobs"), token, job.ID, job.Conclusion, "")
// Verify job url works
req := NewRequest(t, "GET", job.URL).AddTokenAuth(token)
jobsResp := MakeRequest(t, req, http.StatusOK)
apiJob := api.ActionWorkflowJob{}
DecodeJSON(t, jobsResp, &apiJob)
assert.Equal(t, job.ID, apiJob.ID)
assert.Equal(t, job.RunID, apiJob.RunID)
assert.Equal(t, job.Status, apiJob.Status)
assert.Equal(t, job.Conclusion, apiJob.Conclusion)
}
}
}
assert.True(t, foundRun, "Expected to find run with ID %d", runID)
}
func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status, event, branch, actor, headSHA string) {
filter := url.Values{}
if conclusion != "" {
filter.Add("status", conclusion)
}
if status != "" {
filter.Add("status", status)
}
if event != "" {
filter.Set("event", event)
}
if branch != "" {
filter.Set("branch", branch)
}
if actor != "" {
filter.Set("actor", actor)
}
if headSHA != "" {
filter.Set("head_sha", headSHA)
}
req := NewRequest(t, "GET", runAPIURL+"?"+filter.Encode()).AddTokenAuth(token)
runResp := MakeRequest(t, req, http.StatusOK)
runList := api.ActionWorkflowRunsResponse{}
DecodeJSON(t, runResp, &runList)
found := false
for _, run := range runList.Entries {
if conclusion != "" {
assert.Equal(t, conclusion, run.Conclusion)
}
if status != "" {
assert.Equal(t, status, run.Status)
}
if event != "" {
assert.Equal(t, event, run.Event)
}
if branch != "" {
assert.Equal(t, branch, run.HeadBranch)
}
if actor != "" {
assert.Equal(t, actor, run.Actor.UserName)
}
found = found || run.ID == id
}
assert.True(t, found, "Expected to find run with ID %d", id)
}
func verifyWorkflowJobCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token string, id int64, conclusion, status string) {
filter := conclusion
if filter == "" {
filter = status
}
if filter == "" {
return
}
req := NewRequest(t, "GET", runAPIURL+"?status="+filter).AddTokenAuth(token)
jobListResp := MakeRequest(t, req, http.StatusOK)
jobList := api.ActionWorkflowJobsResponse{}
DecodeJSON(t, jobListResp, &jobList)
found := false
for _, job := range jobList.Entries {
if conclusion != "" {
assert.Equal(t, conclusion, job.Conclusion)
} else {
assert.Equal(t, status, job.Status)
}
found = found || job.ID == id
}
assert.True(t, found, "Expected to find job with ID %d", id)
}