From a0dea00fdd58f20fa06fe37ea0dc12a981d39238 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 11:53:33 +0000 Subject: [PATCH 01/12] fix(featuredetection): add `ActionsFeatures` to detect workflow dispatch features Signed-off-by: Babak K. Shandiz --- .../featuredetection/feature_detection.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 2aff20d98..8100882fb 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -18,6 +18,7 @@ type Detector interface { ProjectsV1() gh.ProjectsV1Support SearchFeatures() (SearchFeatures, error) ReleaseFeatures() (ReleaseFeatures, error) + ActionsFeatures() (ActionsFeatures, error) } type IssueFeatures struct { @@ -98,6 +99,16 @@ type ReleaseFeatures struct { ImmutableReleases bool } +type ActionsFeatures struct { + // DispatchRunDetails indicates whether the API supports the `return_run_details` + // field in workflow dispatches that, when set to true, will return the details + // of the created workflow run in the response (with status code 200). + // + // On older API versions (e.g. GHES 3.20 or earlier), this new field is now + // supported, and setting it will cause error. + DispatchRunDetails bool +} + type detector struct { host string httpClient *http.Client @@ -393,6 +404,52 @@ func (d *detector) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +const ( + enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0" +) + +func (d *detector) ActionsFeatures() (ActionsFeatures, error) { + // TODO workflowDispatchRunDetailsCleanup + // Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support). + // + // On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will + // result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API + // will keep the old behavior of returning a 204 No Content response. + // + // On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response. + // + // Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls. + // + // IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to + // always return the details of the created workflow run in the response, and the `return_run_details` field is + // going to be ignored/removed. So, once we are migrating to the new API version we should double check the status + // of the API. + + var dispatchRunDetailsSupported bool + + if !ghauth.IsEnterprise(d.host) { + dispatchRunDetailsSupported = true + } else { + minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport) + if err != nil { + return ActionsFeatures{}, err + } + + hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + if err != nil { + return ActionsFeatures{}, err + } + + if hostVersion.GreaterThanOrEqual(minSupportedVersion) { + dispatchRunDetailsSupported = true + } + } + + return ActionsFeatures{ + DispatchRunDetails: dispatchRunDetailsSupported, + }, nil +} + func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { var metaResponse struct { InstalledVersion string `json:"installed_version"` From 33825477aefe2dc0e27e05776451f2bec64e9146 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 11:54:07 +0000 Subject: [PATCH 02/12] test(featuredetection): add tests for `ActionsFeatures` Signed-off-by: Babak K. Shandiz --- internal/featuredetection/detector_mock.go | 10 +++ .../feature_detection_test.go | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index b0ca81f40..a2dd8c2f8 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -28,6 +28,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{}, nil +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -56,6 +60,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { }, nil } +func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil +} + type AdvancedIssueSearchDetectorMock struct { EnabledDetectorMock searchFeatures SearchFeatures diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index d3ee1a7e9..b5d268f5b 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -696,3 +696,71 @@ func TestReleaseFeatures(t *testing.T) { }) } } + +func TestActionsFeatures(t *testing.T) { + tests := []struct { + name string + hostname string + httpStubs func(*httpmock.Registry) + wantFeatures ActionsFeatures + }{ + { + name: "github.com, workflow dispatch run details supported", + hostname: "github.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "ghec data residency (ghe.com), workflow dispatch run details supported", + hostname: "stampname.ghe.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "GHE 3.20, workflow dispatch run details not supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.20.999"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: false, + }, + }, + { + name: "GHE 3.21, workflow dispatch run details not supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.21.0"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + + detector := NewDetector(httpClient, tt.hostname) + + features, err := detector.ActionsFeatures() + require.NoError(t, err) + require.Equal(t, tt.wantFeatures, features) + }) + } +} From e73bf113dd72d16c5f4342d4e811da0d03d034f2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 12:56:01 +0000 Subject: [PATCH 03/12] feat(workflow run): retrieve workflow run if supported by the API Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run.go | 61 ++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 2acd1d4cc..5f9a44764 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -10,9 +10,11 @@ import ( "reflect" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +27,7 @@ type RunOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Detector fd.Detector Prompter iprompter Selector string @@ -64,6 +67,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command - Interactively - Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags - As JSON, via standard input + + When running non-interactively, the created workflow run URL will be returned if available. `, "`"), Example: heredoc.Doc(` # Have gh prompt you for what workflow you'd like to run and interactively collect inputs @@ -260,6 +265,11 @@ func runRun(opts *RunOptions) error { return err } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(c, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + ref := opts.Ref if ref == "" { @@ -303,20 +313,48 @@ func runRun(opts *RunOptions) error { } } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches", - ghrepo.FullName(repo), workflow.ID) + var returnRunDetailsSupported bool + if features, err := opts.Detector.ActionsFeatures(); err == nil { + // If there's an error detecting features, we assume the feature is not supported. + returnRunDetailsSupported = features.DispatchRunDetails + } - requestByte, err := json.Marshal(map[string]interface{}{ + path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches", ghrepo.FullName(repo), workflow.ID) + + requestBody := map[string]interface{}{ "ref": ref, "inputs": providedInputs, - }) + } + + if returnRunDetailsSupported { + requestBody["return_run_details"] = true + } + + requestByte, err := json.Marshal(requestBody) if err != nil { return fmt.Errorf("failed to serialize workflow inputs: %w", err) } body := bytes.NewReader(requestByte) - err = client.REST(repo.RepoHost(), "POST", path, body, nil) + var response struct { + WorkflowRunID int64 `json:"workflow_run_id"` + RunURL string `json:"run_url"` + HtmlURL string `json:"html_url"` + } + + // Note that the workflow dispatch endpoint used to return 204 No Content + // (with no body, obviously). Now it's possible for the endpoint to also + // return 200 OK with created run details. So, we have to handle both cases + // because old GHE versions still return 204. Even on github.com, we + // may still get 204 for any reason. + // + // Our REST client library is smart enough to ignore JSON unmarshal when it + // receives 204, so we're safe here anyway. + // + // As a related note, the new REST API version (which will come with breaking + // changes) will probably default to return 200 + run details. + err = client.REST(repo.RepoHost(), "POST", path, body, &response) if err != nil { return fmt.Errorf("could not create workflow dispatch event: %w", err) } @@ -327,10 +365,23 @@ func runRun(opts *RunOptions) error { fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n", cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref)) + if response.HtmlURL != "" { + fmt.Fprintln(out, response.HtmlURL) + } + fmt.Fprintln(out) + if response.WorkflowRunID != 0 { + fmt.Fprintf(out, "To see the created workflow run, try: %s\n", + cs.Boldf("gh run view %d", response.WorkflowRunID)) + } + fmt.Fprintf(out, "To see runs for this workflow, try: %s\n", cs.Boldf("gh run list --workflow=%q", workflow.Base())) + } else { + if response.HtmlURL != "" { + fmt.Fprintln(opts.IO.Out, response.HtmlURL) + } } return nil From bf2ff7c39b78bdc3c7eccb042f5796f3cccbee5d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 12:57:01 +0000 Subject: [PATCH 04/12] test(workflow run): verify retrieval of workflow run details Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run_test.go | 425 +++++++++++++++++++++++++++---- 1 file changed, 380 insertions(+), 45 deletions(-) diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index b121a573d..a4e44e5da 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -10,7 +10,9 @@ import ( "os" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" @@ -394,6 +396,7 @@ jobs: run: echo "${{ github.event.inputs.message }} ${{ fromJSON('["", "🥳"]')[github.event.inputs.use-emoji == 'true'] }} ${{ github.event.inputs.name }}"`) encodedYAMLContentMissingChoiceIp := base64.StdEncoding.EncodeToString(yamlContentMissingChoiceIp) + // Old GitHub API servers return 204 No Content for successful workflow dispatches. stubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), @@ -406,6 +409,24 @@ jobs: httpmock.StatusStringResponse(204, "cool")) } + // Current GitHub API servers return 200 OK with run info for successful workflow dispatches, + // if `return_run_details` is enabled in the request body. + stubsWithRunInfo := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), + httpmock.JSONResponse(shared.Workflow{ + Path: ".github/workflows/workflow.yml", + ID: 12345, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + } + tests := []struct { name string opts *RunOptions @@ -434,11 +455,14 @@ jobs: errOut: "could not parse provided JSON: unexpected end of JSON input", }, { - name: "good JSON", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "good JSON without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -447,13 +471,44 @@ jobs: "ref": "trunk", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "nontty good JSON", + name: "good JSON with run info", + tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good JSON without run info (204)", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -464,11 +519,31 @@ jobs: httpStubs: stubs, }, { - name: "nontty good input fields", + name: "nontty good JSON with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good input fields without run info (204)", opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`name=scully`}, MagicFields: []string{`greeting=hey`}, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -480,12 +555,34 @@ jobs: httpStubs: stubs, }, { - name: "respects ref", + name: "nontty good input fields with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + RawFields: []string{`name=scully`}, + MagicFields: []string{`greeting=hey`}, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hey", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "respects ref, without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, Ref: "good-branch", + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -494,7 +591,36 @@ jobs: "ref": "good-branch", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "respects ref, with run info", + tty: true, + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Ref: "good-branch", + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "good-branch", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -503,6 +629,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"greeting":"hello there"}`, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -515,6 +642,13 @@ jobs: httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), httpmock.StatusStringResponse(422, "missing something")) }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "greeting": "hello there", + }, + "ref": "trunk", + "return_run_details": true, + }, wantErr: true, errOut: "could not create workflow dispatch event: HTTP 422 (https://api.github.com/repos/OWNER/REPO/actions/workflows/12345/dispatches)", }, @@ -523,6 +657,7 @@ jobs: tty: false, opts: &RunOptions{ Selector: "workflow.yaml", + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -530,13 +665,19 @@ jobs: httpmock.StatusStringResponse(200, `{"id": 12345}`)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "")) + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) }, wantBody: map[string]interface{}{ - "inputs": map[string]interface{}{}, - "ref": "trunk", + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, }, wantErr: false, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -544,6 +685,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`greeting="hello there"`}, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -563,7 +705,8 @@ jobs: name: "prompt, no workflows enabled", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -585,7 +728,8 @@ jobs: name: "prompt, no workflows", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -598,10 +742,13 @@ jobs: errOut: "could not fetch workflows for OWNER/REPO: no workflows are enabled", }, { - name: "prompt, minimal yaml", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, minimal yaml, without run info (204)", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -634,13 +781,71 @@ jobs: "inputs": map[string]interface{}{}, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"minimal.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), }, { - name: "prompt", + name: "prompt, minimal yaml, with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "minimal workflow", + ID: 1, + State: shared.Active, + Path: ".github/workflows/minimal.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedNoInputsYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/1/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"minimal workflow (minimal.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -682,13 +887,80 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "prompt, workflow choice input", + name: "prompt with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "a workflow", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a workflow (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterInput("greeting", func(_, _ string) (string, error) { + return "hi", nil + }) + pm.RegisterInput("name (required)", func(_, _ string) (string, error) { + return "scully", nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hi", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, workflow choice input without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -731,13 +1003,79 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "prompt, workflow choice input with run info", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "choice inputs", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContentChoiceIp, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"choice inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("favourite-animal (required)", []string{"dog", "cat"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("name", []string{"monalisa", "cschleiden"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "monalisa", + "favourite-animal": "dog", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { name: "prompt, workflow choice missing input", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -757,9 +1095,6 @@ jobs: httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentMissingChoiceIp, })) - reg.Register( - httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "cool")) }, promptStubs: func(pm *prompter.MockPrompter) { pm.RegisterSelect("Select a workflow", []string{"choice missing inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { @@ -775,27 +1110,28 @@ jobs: } for _, tt := range tests { - reg := &httpmock.Registry{} - if tt.httpStubs != nil { - tt.httpStubs(reg) - } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - - ios, _, stdout, _ := iostreams.Test() - ios.SetStdinTTY(tt.tty) - ios.SetStdoutTTY(tt.tty) - tt.opts.IO = ios - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return api.InitRepoHostname(&api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - DefaultBranchRef: api.BranchRef{Name: "trunk"}, - }, "github.com"), nil - } - t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "trunk"}, + }, "github.com"), nil + } + pm := prompter.NewMockPrompter(t) tt.opts.Prompter = pm if tt.promptStubs != nil { @@ -810,7 +1146,6 @@ jobs: } assert.NoError(t, err) assert.Equal(t, tt.wantOut, stdout.String()) - reg.Verify(t) if len(reg.Requests) > 0 { lastRequest := reg.Requests[len(reg.Requests)-1] From b64dd58d8ba99796e5e97c748109bc8a421fb827 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 13:06:23 +0000 Subject: [PATCH 05/12] test(featuredetection): fix test case name Signed-off-by: Babak K. Shandiz --- internal/featuredetection/feature_detection_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index b5d268f5b..f4eeb5962 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -732,7 +732,7 @@ func TestActionsFeatures(t *testing.T) { }, }, { - name: "GHE 3.21, workflow dispatch run details not supported", + name: "GHE 3.21, workflow dispatch run details supported", hostname: "git.my.org", httpStubs: func(reg *httpmock.Registry) { reg.Register( From 61ab5e0b5db4ef6af0ffd6d7d0b09a8aacb1859c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 13:07:24 +0000 Subject: [PATCH 06/12] docs(featuredetection): fix typo in comment Signed-off-by: Babak K. Shandiz --- internal/featuredetection/feature_detection.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 8100882fb..2f7b6255a 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -104,8 +104,8 @@ type ActionsFeatures struct { // field in workflow dispatches that, when set to true, will return the details // of the created workflow run in the response (with status code 200). // - // On older API versions (e.g. GHES 3.20 or earlier), this new field is now - // supported, and setting it will cause error. + // On older API versions (e.g. GHES 3.20 or earlier), this new field is not + // supported and setting it will cause an error. DispatchRunDetails bool } From 3e9fbbb2fa74cfd9396000e5c80689a6ad079f1d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 21:53:17 +0000 Subject: [PATCH 07/12] refactor(workflow run): remove temp `out` Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 5f9a44764..0484b3c06 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -360,23 +360,22 @@ func runRun(opts *RunOptions) error { } if opts.IO.IsStdoutTTY() { - out := opts.IO.Out cs := opts.IO.ColorScheme() - fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n", + fmt.Fprintf(opts.IO.Out, "%s Created workflow_dispatch event for %s at %s\n", cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref)) if response.HtmlURL != "" { - fmt.Fprintln(out, response.HtmlURL) + fmt.Fprintln(opts.IO.Out, response.HtmlURL) } - fmt.Fprintln(out) + fmt.Fprintln(opts.IO.Out) if response.WorkflowRunID != 0 { - fmt.Fprintf(out, "To see the created workflow run, try: %s\n", + fmt.Fprintf(opts.IO.Out, "To see the created workflow run, try: %s\n", cs.Boldf("gh run view %d", response.WorkflowRunID)) } - fmt.Fprintf(out, "To see runs for this workflow, try: %s\n", + fmt.Fprintf(opts.IO.Out, "To see runs for this workflow, try: %s\n", cs.Boldf("gh run list --workflow=%q", workflow.Base())) } else { if response.HtmlURL != "" { From 36a85fd71febf4c96c0b1729c6f51311dbc1e198 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 21:55:25 +0000 Subject: [PATCH 08/12] fix(workflow run): apply `url.PathEscape` when compiling URL Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 0484b3c06..294e403d4 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "reflect" "sort" "strings" @@ -319,7 +320,7 @@ func runRun(opts *RunOptions) error { returnRunDetailsSupported = features.DispatchRunDetails } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches", ghrepo.FullName(repo), workflow.ID) + path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID) requestBody := map[string]interface{}{ "ref": ref, From c47a10e58364d65d33297fb489ad7e8ae40fe9e4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 22:02:18 +0000 Subject: [PATCH 09/12] docs(workflow run): add cleanup marker with explanation on future changes Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 294e403d4..1d4844cdb 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -327,6 +327,10 @@ func runRun(opts *RunOptions) error { "inputs": providedInputs, } + // TODO workflowDispatchRunDetailsCleanup + // We will have to always set the `return_run_details` field to true, unless + // we opt into the the new REST API version, which will probably return the + // details by default. if returnRunDetailsSupported { requestBody["return_run_details"] = true } From ce016217d0511573848ba91d262c1ad6dbadd624 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 16 Feb 2026 22:49:42 +0000 Subject: [PATCH 10/12] docs(workflow run): improve help docs Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- pkg/cmd/workflow/run/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 1d4844cdb..ee78ed4cf 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -69,7 +69,7 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command - Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags - As JSON, via standard input - When running non-interactively, the created workflow run URL will be returned if available. + The created workflow run URL will be returned if available. `, "`"), Example: heredoc.Doc(` # Have gh prompt you for what workflow you'd like to run and interactively collect inputs From 52eca968733ccbff720483a375bff2367ba6bad4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 17 Feb 2026 11:46:27 +0000 Subject: [PATCH 11/12] refactor(featuredetection): remove temp in favour of early returns Signed-off-by: Babak K. Shandiz --- .../featuredetection/feature_detection.go | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 2f7b6255a..b2fb6d65d 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -425,28 +425,30 @@ func (d *detector) ActionsFeatures() (ActionsFeatures, error) { // going to be ignored/removed. So, once we are migrating to the new API version we should double check the status // of the API. - var dispatchRunDetailsSupported bool - if !ghauth.IsEnterprise(d.host) { - dispatchRunDetailsSupported = true - } else { - minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport) - if err != nil { - return ActionsFeatures{}, err - } + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil + } - hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) - if err != nil { - return ActionsFeatures{}, err - } + minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport) + if err != nil { + return ActionsFeatures{}, err + } - if hostVersion.GreaterThanOrEqual(minSupportedVersion) { - dispatchRunDetailsSupported = true - } + hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + if err != nil { + return ActionsFeatures{}, err + } + + if hostVersion.GreaterThanOrEqual(minSupportedVersion) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil } return ActionsFeatures{ - DispatchRunDetails: dispatchRunDetailsSupported, + DispatchRunDetails: false, }, nil } From 31f3756089d177c0f2cd58faba6b74ebf960fc12 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 17 Feb 2026 11:48:22 +0000 Subject: [PATCH 12/12] fix(workflow run): bail out on feature detection error Signed-off-by: Babak K. Shandiz --- pkg/cmd/workflow/run/run.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index ee78ed4cf..386227bf1 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -314,10 +314,9 @@ func runRun(opts *RunOptions) error { } } - var returnRunDetailsSupported bool - if features, err := opts.Detector.ActionsFeatures(); err == nil { - // If there's an error detecting features, we assume the feature is not supported. - returnRunDetailsSupported = features.DispatchRunDetails + features, err := opts.Detector.ActionsFeatures() + if err != nil { + return err } path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID) @@ -331,7 +330,7 @@ func runRun(opts *RunOptions) error { // We will have to always set the `return_run_details` field to true, unless // we opt into the the new REST API version, which will probably return the // details by default. - if returnRunDetailsSupported { + if features.DispatchRunDetails { requestBody["return_run_details"] = true }