From bc9817b317a70afae99c6ea1599bdbcd3bcd5bf4 Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Sun, 1 Mar 2026 20:58:16 +0100 Subject: [PATCH] WorkflowDispatch api optionally return runid (#36706) Implements https://github.blog/changelog/2026-02-19-workflow-dispatch-api-now-returns-run-ids --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang --- modules/structs/repo_actions.go | 7 +++++ routers/api/v1/repo/action.go | 27 +++++++++++++++-- routers/api/v1/swagger/action.go | 7 +++++ routers/web/repo/actions/view.go | 2 +- services/actions/workflow.go | 26 ++++++++-------- templates/swagger/v1_json.tmpl | 37 ++++++++++++++++++++++- tests/integration/actions_trigger_test.go | 22 ++++++++++++++ 7 files changed, 110 insertions(+), 18 deletions(-) diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index b491d6ccce..86bf4959d1 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -205,3 +205,10 @@ type ActionRunnersResponse struct { Entries []*ActionRunner `json:"runners"` TotalCount int64 `json:"total_count"` } + +// RunDetails returns workflow_dispatch runid and url +type RunDetails struct { + WorkflowRunID int64 `json:"workflow_run_id"` + RunURL string `json:"run_url"` + HTMLURL string `json:"html_url"` +} diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 03ce0d3aab..a2124f5646 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1004,9 +1004,15 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { // in: body // schema: // "$ref": "#/definitions/CreateActionWorkflowDispatch" + // - name: return_run_details + // description: Whether the response should include the workflow run ID and URLs. + // in: query + // type: boolean // responses: + // "200": + // "$ref": "#/responses/RunDetails" // "204": - // description: No Content + // description: No Content, if return_run_details is missing or false // "400": // "$ref": "#/responses/error" // "403": @@ -1023,7 +1029,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { return } - err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + runID, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") { // The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string // So we have to manually read the `inputs[key]` from the form @@ -1054,7 +1060,22 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { return } - ctx.Status(http.StatusNoContent) + if !ctx.FormBool("return_run_details") { + ctx.Status(http.StatusNoContent) + return + } + + workflowRun, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, &api.RunDetails{ + WorkflowRunID: runID, + HTMLURL: fmt.Sprintf("%s/actions/runs/%d", ctx.Repo.Repository.HTMLURL(ctx), workflowRun.Index), + RunURL: fmt.Sprintf("%s/actions/runs/%d", ctx.Repo.Repository.APIURL(), runID), + }) } func ActionsEnableWorkflow(ctx *context.APIContext) { diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index 0606505950..328998db21 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -46,3 +46,10 @@ type swaggerResponseActionWorkflowList struct { // in:body Body api.ActionWorkflowResponse `json:"body"` } + +// RunDetails +// swagger:response RunDetails +type swaggerResponseRunDetails struct { + // in:body + Body api.RunDetails `json:"body"` +} diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 33c1e73aa4..4c023d9252 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -936,7 +936,7 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + _, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 305963cc00..faa540421f 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -44,16 +44,16 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl return repo_model.UpdateRepoUnit(ctx, cfgUnit) } -func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error { +func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) { if workflowID == "" { - return util.ErrorWrapTranslatable( + return 0, util.ErrorWrapTranslatable( util.NewNotExistErrorf("workflowID is empty"), "actions.workflow.not_found", workflowID, ) } if ref == "" { - return util.ErrorWrapTranslatable( + return 0, util.ErrorWrapTranslatable( util.NewNotExistErrorf("ref is empty"), "form.target_ref_not_exist", ref, ) @@ -63,7 +63,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() if cfg.IsWorkflowDisabled(workflowID) { - return util.ErrorWrapTranslatable( + return 0, util.ErrorWrapTranslatable( util.NewPermissionDeniedErrorf("workflow is disabled"), "actions.workflow.disabled", ) @@ -82,7 +82,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re runTargetCommit, err = gitRepo.GetBranchCommit(ref) } if err != nil { - return util.ErrorWrapTranslatable( + return 0, util.ErrorWrapTranslatable( util.NewNotExistErrorf("ref %q doesn't exist", ref), "form.target_ref_not_exist", ref, ) @@ -91,7 +91,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re // get workflow entry from runTargetCommit _, entries, err := actions.ListWorkflows(runTargetCommit) if err != nil { - return err + return 0, err } // find workflow from commit @@ -122,7 +122,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re } if entry == nil { - return util.ErrorWrapTranslatable( + return 0, util.ErrorWrapTranslatable( util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), "actions.workflow.not_found", workflowID, ) @@ -130,12 +130,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re content, err := actions.GetContentFromEntry(entry) if err != nil { - return err + return 0, err } singleWorkflow := &jobparser.SingleWorkflow{} if err := yaml.Unmarshal(content, singleWorkflow); err != nil { - return fmt.Errorf("failed to unmarshal workflow content: %w", err) + return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err) } // get inputs from post workflow := &model.Workflow{ @@ -144,7 +144,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re inputsWithDefaults := make(map[string]any) if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { - return err + return 0, err } } @@ -161,13 +161,13 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re var eventPayload []byte if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { - return fmt.Errorf("JSONPayload: %w", err) + return 0, fmt.Errorf("JSONPayload: %w", err) } run.EventPayload = string(eventPayload) // Insert the action run and its associated jobs into the database if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil { - return fmt.Errorf("PrepareRun: %w", err) + return 0, fmt.Errorf("PrepareRun: %w", err) } - return nil + return run.ID, nil } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 7031f0aab9..6ae0fc300b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6031,11 +6031,20 @@ "schema": { "$ref": "#/definitions/CreateActionWorkflowDispatch" } + }, + { + "type": "boolean", + "description": "Whether the response should include the workflow run ID and URLs.", + "name": "return_run_details", + "in": "query" } ], "responses": { + "200": { + "$ref": "#/responses/RunDetails" + }, "204": { - "description": "No Content" + "description": "No Content, if return_run_details is missing or false" }, "400": { "$ref": "#/responses/error" @@ -28319,6 +28328,26 @@ "type": "string", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "RunDetails": { + "description": "RunDetails returns workflow_dispatch runid and url", + "type": "object", + "properties": { + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "run_url": { + "type": "string", + "x-go-name": "RunURL" + }, + "workflow_run_id": { + "type": "integer", + "format": "int64", + "x-go-name": "WorkflowRunID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "SearchResults": { "description": "SearchResults results of a successful search", "type": "object", @@ -30192,6 +30221,12 @@ } } }, + "RunDetails": { + "description": "RunDetails", + "schema": { + "$ref": "#/definitions/RunDetails" + } + }, "Runner": { "description": "Runner", "schema": { diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 7fff796af6..5ad574bebc 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -38,6 +38,7 @@ import ( files_service "code.gitea.io/gitea/services/repository/files" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPullRequestTargetEvent(t *testing.T) { @@ -906,6 +907,27 @@ jobs: CommitSHA: branch.CommitID, }) assert.NotNil(t, run) + + // Now trigger with rundetails + values.Set("return_run_details", "true") + + req = NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + runDetails := &api.RunDetails{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(runDetails)) + assert.NotEqual(t, 0, runDetails.WorkflowRunID) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + ID: runDetails.WorkflowRunID, + Title: "add workflow", + RepoID: repo.ID, + Event: "workflow_dispatch", + Ref: "refs/heads/main", + WorkflowID: "dispatch.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) }) }