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 <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ChristopherHX
2026-03-01 20:58:16 +01:00
committed by GitHub
parent 553277b0be
commit bc9817b317
7 changed files with 110 additions and 18 deletions

View File

@@ -205,3 +205,10 @@ type ActionRunnersResponse struct {
Entries []*ActionRunner `json:"runners"` Entries []*ActionRunner `json:"runners"`
TotalCount int64 `json:"total_count"` 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"`
}

View File

@@ -1004,9 +1004,15 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
// in: body // in: body
// schema: // schema:
// "$ref": "#/definitions/CreateActionWorkflowDispatch" // "$ref": "#/definitions/CreateActionWorkflowDispatch"
// - name: return_run_details
// description: Whether the response should include the workflow run ID and URLs.
// in: query
// type: boolean
// responses: // responses:
// "200":
// "$ref": "#/responses/RunDetails"
// "204": // "204":
// description: No Content // description: No Content, if return_run_details is missing or false
// "400": // "400":
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "403": // "403":
@@ -1023,7 +1029,7 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
return 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") { 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 // 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 // So we have to manually read the `inputs[key]` from the form
@@ -1054,7 +1060,22 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
return 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) { func ActionsEnableWorkflow(ctx *context.APIContext) {

View File

@@ -46,3 +46,10 @@ type swaggerResponseActionWorkflowList struct {
// in:body // in:body
Body api.ActionWorkflowResponse `json:"body"` Body api.ActionWorkflowResponse `json:"body"`
} }
// RunDetails
// swagger:response RunDetails
type swaggerResponseRunDetails struct {
// in:body
Body api.RunDetails `json:"body"`
}

View File

@@ -936,7 +936,7 @@ func Run(ctx *context_module.Context) {
ctx.ServerError("ref", nil) ctx.ServerError("ref", nil)
return 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 { for name, config := range workflowDispatch.Inputs {
value := ctx.Req.PostFormValue(name) value := ctx.Req.PostFormValue(name)
if config.Type == "boolean" { if config.Type == "boolean" {

View File

@@ -44,16 +44,16 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
return repo_model.UpdateRepoUnit(ctx, cfgUnit) 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 == "" { if workflowID == "" {
return util.ErrorWrapTranslatable( return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"), util.NewNotExistErrorf("workflowID is empty"),
"actions.workflow.not_found", workflowID, "actions.workflow.not_found", workflowID,
) )
} }
if ref == "" { if ref == "" {
return util.ErrorWrapTranslatable( return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref is empty"), util.NewNotExistErrorf("ref is empty"),
"form.target_ref_not_exist", ref, "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) cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig() cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) { if cfg.IsWorkflowDisabled(workflowID) {
return util.ErrorWrapTranslatable( return 0, util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"), util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled", "actions.workflow.disabled",
) )
@@ -82,7 +82,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
runTargetCommit, err = gitRepo.GetBranchCommit(ref) runTargetCommit, err = gitRepo.GetBranchCommit(ref)
} }
if err != nil { if err != nil {
return util.ErrorWrapTranslatable( return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref %q doesn't exist", ref), util.NewNotExistErrorf("ref %q doesn't exist", ref),
"form.target_ref_not_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 // get workflow entry from runTargetCommit
_, entries, err := actions.ListWorkflows(runTargetCommit) _, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil { if err != nil {
return err return 0, err
} }
// find workflow from commit // find workflow from commit
@@ -122,7 +122,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
} }
if entry == nil { if entry == nil {
return util.ErrorWrapTranslatable( return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", 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) content, err := actions.GetContentFromEntry(entry)
if err != nil { if err != nil {
return err return 0, err
} }
singleWorkflow := &jobparser.SingleWorkflow{} singleWorkflow := &jobparser.SingleWorkflow{}
if err := yaml.Unmarshal(content, singleWorkflow); err != nil { 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 // get inputs from post
workflow := &model.Workflow{ workflow := &model.Workflow{
@@ -144,7 +144,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
inputsWithDefaults := make(map[string]any) inputsWithDefaults := make(map[string]any)
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
if err = processInputs(workflowDispatch, inputsWithDefaults); err != 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 var eventPayload []byte
if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
return fmt.Errorf("JSONPayload: %w", err) return 0, fmt.Errorf("JSONPayload: %w", err)
} }
run.EventPayload = string(eventPayload) run.EventPayload = string(eventPayload)
// Insert the action run and its associated jobs into the database // Insert the action run and its associated jobs into the database
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil { 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
} }

View File

@@ -6031,11 +6031,20 @@
"schema": { "schema": {
"$ref": "#/definitions/CreateActionWorkflowDispatch" "$ref": "#/definitions/CreateActionWorkflowDispatch"
} }
},
{
"type": "boolean",
"description": "Whether the response should include the workflow run ID and URLs.",
"name": "return_run_details",
"in": "query"
} }
], ],
"responses": { "responses": {
"200": {
"$ref": "#/responses/RunDetails"
},
"204": { "204": {
"description": "No Content" "description": "No Content, if return_run_details is missing or false"
}, },
"400": { "400": {
"$ref": "#/responses/error" "$ref": "#/responses/error"
@@ -28319,6 +28328,26 @@
"type": "string", "type": "string",
"x-go-package": "code.gitea.io/gitea/modules/structs" "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": { "SearchResults": {
"description": "SearchResults results of a successful search", "description": "SearchResults results of a successful search",
"type": "object", "type": "object",
@@ -30192,6 +30221,12 @@
} }
} }
}, },
"RunDetails": {
"description": "RunDetails",
"schema": {
"$ref": "#/definitions/RunDetails"
}
},
"Runner": { "Runner": {
"description": "Runner", "description": "Runner",
"schema": { "schema": {

View File

@@ -38,6 +38,7 @@ import (
files_service "code.gitea.io/gitea/services/repository/files" files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestPullRequestTargetEvent(t *testing.T) { func TestPullRequestTargetEvent(t *testing.T) {
@@ -906,6 +907,27 @@ jobs:
CommitSHA: branch.CommitID, CommitSHA: branch.CommitID,
}) })
assert.NotNil(t, run) 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)
}) })
} }