Skip to content

Commit a86e657

Browse files
authored
Use workflow_dispatch return_run_details to get run ID directly (#19414)
1 parent 31dc15f commit a86e657

File tree

5 files changed

+264
-13
lines changed

5 files changed

+264
-13
lines changed

.changeset/patch-log-run-id.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/dispatch_workflow.cjs

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,49 @@ async function main(config = {}) {
155155
const workflowFile = `${workflowName}${extension}`;
156156
core.info(`Dispatching workflow: ${workflowFile}`);
157157

158-
// Dispatch the workflow using the resolved file
159-
await githubClient.rest.actions.createWorkflowDispatch({
160-
owner: repo.owner,
161-
repo: repo.repo,
162-
workflow_id: workflowFile,
163-
ref: ref,
164-
inputs: inputs,
165-
});
158+
// Dispatch the workflow using the resolved file.
159+
// Request return_run_details for newer GitHub API support; fall back without it
160+
// for older GitHub Enterprise Server deployments that don't support the parameter.
161+
/** @type {{ data: { workflow_run_id?: number } }} */
162+
let response;
163+
try {
164+
response = await githubClient.rest.actions.createWorkflowDispatch({
165+
owner: repo.owner,
166+
repo: repo.repo,
167+
workflow_id: workflowFile,
168+
ref: ref,
169+
inputs: inputs,
170+
return_run_details: true,
171+
});
172+
} catch (dispatchError) {
173+
/** @type {any} */
174+
const err = dispatchError;
175+
const status = err && typeof err === "object" ? err.status : undefined;
176+
const message = err && typeof err === "object" && err.response && err.response.data && typeof err.response.data.message === "string" ? err.response.data.message : String(dispatchError);
177+
178+
const isValidationStatus = status === 400 || status === 422;
179+
const mentionsReturnRunDetails = typeof message === "string" && message.toLowerCase().includes("return_run_details");
180+
181+
if (isValidationStatus && mentionsReturnRunDetails) {
182+
core.info("Workflow dispatch failed due to unsupported 'return_run_details' parameter; retrying without it for GitHub Enterprise compatibility.");
183+
response = await githubClient.rest.actions.createWorkflowDispatch({
184+
owner: repo.owner,
185+
repo: repo.repo,
186+
workflow_id: workflowFile,
187+
ref: ref,
188+
inputs: inputs,
189+
});
190+
} else {
191+
throw err;
192+
}
193+
}
166194

167-
core.info(`✓ Successfully dispatched workflow: ${workflowFile}`);
195+
const runId = response && response.data ? response.data.workflow_run_id : undefined;
196+
if (runId) {
197+
core.info(`✓ Successfully dispatched workflow: ${workflowFile} (run ID: ${runId})`);
198+
} else {
199+
core.info(`✓ Successfully dispatched workflow: ${workflowFile}`);
200+
}
168201

169202
// Record the time of this dispatch for rate limiting
170203
lastDispatchTime = Date.now();
@@ -173,6 +206,7 @@ async function main(config = {}) {
173206
success: true,
174207
workflow_name: workflowName,
175208
inputs: inputs,
209+
run_id: runId,
176210
};
177211
} catch (error) {
178212
const errorMessage = getErrorMessage(error);

actions/setup/js/dispatch_workflow.test.cjs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ global.context = {
2525
global.github = {
2626
rest: {
2727
actions: {
28-
createWorkflowDispatch: vi.fn().mockResolvedValue({}),
28+
createWorkflowDispatch: vi.fn().mockResolvedValue({ data: { workflow_run_id: 123456 } }),
2929
},
3030
repos: {
3131
get: vi.fn().mockResolvedValue({
@@ -72,6 +72,7 @@ describe("dispatch_workflow handler factory", () => {
7272

7373
expect(result.success).toBe(true);
7474
expect(result.workflow_name).toBe("test-workflow");
75+
expect(result.run_id).toBe(123456);
7576
// Should use the extension from config
7677
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({
7778
owner: "test-owner",
@@ -82,6 +83,7 @@ describe("dispatch_workflow handler factory", () => {
8283
param1: "value1",
8384
param2: "42",
8485
},
86+
return_run_details: true,
8587
});
8688
});
8789

@@ -232,6 +234,7 @@ describe("dispatch_workflow handler factory", () => {
232234
workflow_id: "no-inputs-workflow.lock.yml",
233235
ref: expect.any(String),
234236
inputs: {}, // Should pass empty object even when inputs property is missing
237+
return_run_details: true,
235238
});
236239
});
237240

@@ -304,6 +307,7 @@ describe("dispatch_workflow handler factory", () => {
304307
workflow_id: "test-workflow.lock.yml",
305308
ref: "refs/heads/feature-branch",
306309
inputs: {},
310+
return_run_details: true,
307311
});
308312
});
309313

@@ -334,6 +338,7 @@ describe("dispatch_workflow handler factory", () => {
334338
workflow_id: "test-workflow.lock.yml",
335339
ref: "refs/heads/main",
336340
inputs: {},
341+
return_run_details: true,
337342
});
338343
});
339344

@@ -364,6 +369,7 @@ describe("dispatch_workflow handler factory", () => {
364369
workflow_id: "test-workflow.lock.yml",
365370
ref: "refs/heads/feature/add-new-feature",
366371
inputs: {},
372+
return_run_details: true,
367373
});
368374
});
369375

@@ -396,6 +402,7 @@ describe("dispatch_workflow handler factory", () => {
396402
workflow_id: "test-workflow.lock.yml",
397403
ref: "refs/heads/develop",
398404
inputs: {},
405+
return_run_details: true,
399406
});
400407
});
401408

@@ -439,6 +446,103 @@ describe("dispatch_workflow handler factory", () => {
439446
workflow_id: "test-workflow.lock.yml",
440447
ref: "refs/heads/staging",
441448
inputs: {},
449+
return_run_details: true,
442450
});
443451
});
452+
453+
it("should return run_id when API returns workflow_run_id", async () => {
454+
github.rest.actions.createWorkflowDispatch.mockResolvedValueOnce({
455+
data: { workflow_run_id: 987654 },
456+
});
457+
458+
const config = {
459+
workflows: ["test-workflow"],
460+
workflow_files: { "test-workflow": ".lock.yml" },
461+
};
462+
const handler = await main(config);
463+
464+
const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {});
465+
466+
expect(result.success).toBe(true);
467+
expect(result.run_id).toBe(987654);
468+
expect(core.info).toHaveBeenCalledWith(expect.stringContaining("run ID: 987654"));
469+
});
470+
471+
it("should succeed without run_id when API returns no workflow_run_id", async () => {
472+
github.rest.actions.createWorkflowDispatch.mockResolvedValueOnce({ data: {} });
473+
474+
const config = {
475+
workflows: ["test-workflow"],
476+
workflow_files: { "test-workflow": ".lock.yml" },
477+
};
478+
const handler = await main(config);
479+
480+
const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {});
481+
482+
expect(result.success).toBe(true);
483+
expect(result.run_id).toBeUndefined();
484+
});
485+
486+
it("should retry without return_run_details when API rejects with 422 mentioning it, and still succeed", async () => {
487+
const error = new Error("Unprocessable Entity");
488+
// @ts-ignore
489+
error.status = 422;
490+
// @ts-ignore
491+
error.response = { data: { message: "Unknown field 'return_run_details'" } };
492+
493+
github.rest.actions.createWorkflowDispatch.mockRejectedValueOnce(error).mockResolvedValueOnce({ data: {} });
494+
495+
const config = {
496+
workflows: ["test-workflow"],
497+
workflow_files: { "test-workflow": ".lock.yml" },
498+
};
499+
const handler = await main(config);
500+
501+
const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {});
502+
503+
expect(result.success).toBe(true);
504+
expect(result.run_id).toBeUndefined();
505+
506+
// First call should include return_run_details: true
507+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenNthCalledWith(1, {
508+
owner: "test-owner",
509+
repo: "test-repo",
510+
workflow_id: "test-workflow.lock.yml",
511+
ref: "refs/heads/main",
512+
inputs: {},
513+
return_run_details: true,
514+
});
515+
516+
// Second call should retry without return_run_details
517+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenNthCalledWith(2, {
518+
owner: "test-owner",
519+
repo: "test-repo",
520+
workflow_id: "test-workflow.lock.yml",
521+
ref: "refs/heads/main",
522+
inputs: {},
523+
});
524+
525+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledTimes(2);
526+
});
527+
528+
it("should not retry when API rejects with 422 for an unrelated reason", async () => {
529+
const error = new Error("Unprocessable Entity");
530+
// @ts-ignore
531+
error.status = 422;
532+
// @ts-ignore
533+
error.response = { data: { message: "Workflow does not exist" } };
534+
535+
github.rest.actions.createWorkflowDispatch.mockRejectedValueOnce(error);
536+
537+
const config = {
538+
workflows: ["test-workflow"],
539+
workflow_files: { "test-workflow": ".lock.yml" },
540+
};
541+
const handler = await main(config);
542+
543+
const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {});
544+
545+
expect(result.success).toBe(false);
546+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledTimes(1);
547+
});
444548
});

pkg/cli/run_workflow_execution.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/exec"
99
"path/filepath"
10+
"regexp"
1011
"strconv"
1112
"strings"
1213
"time"
@@ -395,9 +396,17 @@ func RunWorkflowOnGitHub(ctx context.Context, workflowIdOrName string, opts RunO
395396
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully triggered workflow: "+lockFileName))
396397
executionLog.Printf("Workflow triggered successfully: %s", lockFileName)
397398

398-
// Try to get the latest run for this workflow to show a direct link
399-
// Add a delay to allow GitHub Actions time to register the new workflow run
400-
runInfo, runErr := getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose)
399+
// Try to get run info: first attempt to parse from gh workflow run output (new in v2.87+),
400+
// then fall back to polling with getLatestWorkflowRunWithRetry for older gh CLI versions.
401+
// Parsing failure is expected for older gh CLI versions and the fallback ensures backward compatibility.
402+
var runInfo *WorkflowRunInfo
403+
var runErr error
404+
if parsedRunInfo := parseRunInfoFromOutput(output); parsedRunInfo != nil {
405+
executionLog.Printf("Parsed run info from gh output: id=%d, url=%s", parsedRunInfo.DatabaseID, parsedRunInfo.URL)
406+
runInfo = parsedRunInfo
407+
} else {
408+
runInfo, runErr = getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose)
409+
}
401410
if runErr == nil && runInfo.URL != "" {
402411
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("🔗 View workflow run: "+runInfo.URL))
403412
executionLog.Printf("Workflow run URL: %s (ID: %d)", runInfo.URL, runInfo.DatabaseID)
@@ -554,3 +563,26 @@ func RunWorkflowsOnGitHub(ctx context.Context, workflowNames []string, opts RunO
554563
UseStderr: false, // Use stdout for run command
555564
})
556565
}
566+
567+
// runInfoURLRegexp matches GitHub Actions run URLs of the form:
568+
// https://{host}/{owner}/{repo}/actions/runs/{run_id}
569+
// Supports both public GitHub (github.com) and GitHub Enterprise Server deployments.
570+
var runInfoURLRegexp = regexp.MustCompile(`https://[^/\s]+/[^/\s]+/[^/\s]+/actions/runs/(\d+)`)
571+
572+
// parseRunInfoFromOutput tries to extract workflow run information from the
573+
// output of `gh workflow run` (v2.87+), which now returns the run URL.
574+
// Returns nil if the run URL cannot be found in the output.
575+
func parseRunInfoFromOutput(output string) *WorkflowRunInfo {
576+
matches := runInfoURLRegexp.FindStringSubmatch(output)
577+
if len(matches) < 2 {
578+
return nil
579+
}
580+
runID, err := strconv.ParseInt(matches[1], 10, 64)
581+
if err != nil {
582+
return nil
583+
}
584+
return &WorkflowRunInfo{
585+
URL: matches[0],
586+
DatabaseID: runID,
587+
}
588+
}

pkg/cli/run_workflow_execution_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,79 @@ func TestRunWorkflowOnGitHub_FlagCombinations(t *testing.T) {
250250
// focus on input validation and early error conditions that can be tested
251251
// without those dependencies. Full end-to-end tests should be in integration
252252
// test files (run_command_test.go with //go:build integration tag).
253+
254+
// TestParseRunInfoFromOutput tests extracting run info from gh workflow run output
255+
func TestParseRunInfoFromOutput(t *testing.T) {
256+
tests := []struct {
257+
name string
258+
output string
259+
expectNil bool
260+
expectedID int64
261+
expectedURL string
262+
}{
263+
{
264+
name: "gh v2.87+ output with run URL",
265+
output: "Created workflow_dispatch event for test.lock.yml at refs/heads/main\n" +
266+
"To see the workflow run, visit:\n" +
267+
" https://github.com/owner/repo/actions/runs/12345678\n" +
268+
"Use `gh run view 12345678` to see the run logs",
269+
expectNil: false,
270+
expectedID: 12345678,
271+
expectedURL: "https://github.com/owner/repo/actions/runs/12345678",
272+
},
273+
{
274+
name: "old gh output without run URL",
275+
output: "Created workflow_dispatch event for test.lock.yml at refs/heads/main",
276+
expectNil: true,
277+
},
278+
{
279+
name: "empty output",
280+
output: "",
281+
expectNil: true,
282+
},
283+
{
284+
name: "URL with org and repo containing hyphens",
285+
output: "https://github.com/my-org/my-repo/actions/runs/9876543210",
286+
expectNil: false,
287+
expectedID: 9876543210,
288+
expectedURL: "https://github.com/my-org/my-repo/actions/runs/9876543210",
289+
},
290+
{
291+
name: "GitHub Enterprise Server URL",
292+
output: "https://github.mycompany.com/owner/repo/actions/runs/55554444",
293+
expectNil: false,
294+
expectedID: 55554444,
295+
expectedURL: "https://github.mycompany.com/owner/repo/actions/runs/55554444",
296+
},
297+
{
298+
name: "GHES URL in multi-line output",
299+
output: "Created workflow_dispatch event for test.lock.yml at refs/heads/main\n" +
300+
"To see the workflow run, visit:\n" +
301+
" https://ghe.example.com/myorg/myrepo/actions/runs/99887766\n",
302+
expectNil: false,
303+
expectedID: 99887766,
304+
expectedURL: "https://ghe.example.com/myorg/myrepo/actions/runs/99887766",
305+
},
306+
}
307+
308+
for _, tt := range tests {
309+
t.Run(tt.name, func(t *testing.T) {
310+
result := parseRunInfoFromOutput(tt.output)
311+
if tt.expectNil {
312+
if result != nil {
313+
t.Errorf("Expected nil result but got: %+v", result)
314+
}
315+
return
316+
}
317+
if result == nil {
318+
t.Fatalf("Expected non-nil result but got nil")
319+
}
320+
if result.DatabaseID != tt.expectedID {
321+
t.Errorf("Expected DatabaseID %d, got %d", tt.expectedID, result.DatabaseID)
322+
}
323+
if result.URL != tt.expectedURL {
324+
t.Errorf("Expected URL %q, got %q", tt.expectedURL, result.URL)
325+
}
326+
})
327+
}
328+
}

0 commit comments

Comments
 (0)