fix: dispatch_workflow honors target-repo in cross-repo relays#20708
fix: dispatch_workflow honors target-repo in cross-repo relays#20708
Conversation
In a caller-hosted relay topology, dispatch_workflow always dispatched to context.repo (the caller's repository) because the compiled target-repo value was never used. This caused createWorkflowDispatch to return Not Found when the worker workflow lived in a different repo. Fix: import resolveTargetRepoConfig and parseRepoSlug from repo_helpers, resolve the dispatch destination from target-repo config, and use the resolved repo for both default-branch lookup and createWorkflowDispatch. Same-repo behavior is unchanged (falls back to context.repo). Tests: add 3 tests covering cross-repo dispatch, default-branch lookup using target-repo, and regression for same-repo fallback. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes cross-repository relay behavior in dispatch_workflow by resolving the dispatch destination repo from configured target-repo (instead of always using context.repo), and ensures default-branch resolution targets the correct repository.
Changes:
- Resolve dispatch
owner/repofromtarget-repo(fallback tocontext.repowhen not configured). - Adjust default-branch lookup to avoid using caller payload defaults when dispatching cross-repo.
- Add tests covering cross-repo dispatch, cross-repo default-branch lookup, and same-repo fallback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| actions/setup/js/dispatch_workflow.cjs | Resolve dispatch repo from target-repo and adjust default-branch behavior for cross-repo dispatch. |
| actions/setup/js/dispatch_workflow.test.cjs | Add regression tests for cross-repo dispatch and default-branch lookup, plus same-repo fallback. |
Comments suppressed due to low confidence (1)
actions/setup/js/dispatch_workflow.cjs:31
isCrossRepoDispatchis computed from the raw slug string, butrepomay fall back tocontext.repowhen parsing fails. That can produce inconsistent behavior (e.g., logging “Dispatching to target repo …” while actually dispatching to the caller repo, and skipping the payload default_branch shortcut). Recommend computing cross-repo status from the parsedrepovscontext.repo(after normalization), and/or only settingisCrossRepoDispatchwhen parsing succeeds.
// Resolve the dispatch destination repository from target-repo config, falling back to context.repo
const resolvedRepoSlug = defaultTargetRepo || `${context.repo.owner}/${context.repo.repo}`;
const repo = parseRepoSlug(resolvedRepoSlug) || context.repo;
const isCrossRepoDispatch = resolvedRepoSlug !== `${context.repo.owner}/${context.repo.repo}`;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| if (isCrossRepoDispatch) { | ||
| core.info(`Dispatching to target repo: ${resolvedRepoSlug}`); | ||
| } |
There was a problem hiding this comment.
dispatch_workflow now supports cross-repo dispatch via target-repo, but the safe-outputs specification currently documents this type as “Cross-Repository Support: No” (docs/src/content/docs/reference/safe-outputs-specification.md around the dispatch_workflow section). Please update the spec (and consider whether cross-repo dispatch should honor an allowed_repos allowlist similar to other handlers) so the documented contract matches the new behavior.
| delete process.env.GITHUB_REF; | ||
| delete process.env.GITHUB_HEAD_REF; | ||
| global.context.ref = undefined; | ||
| // context.payload has a default_branch for the caller repo – must be ignored for cross-repo dispatch | ||
| global.context.payload = { repository: { default_branch: "caller-main" } }; | ||
|
|
||
| github.rest.repos.get.mockResolvedValueOnce({ | ||
| data: { default_branch: "platform-main" }, | ||
| }); | ||
|
|
||
| const config = { | ||
| "target-repo": "platform-org/platform-repo", | ||
| workflows: ["platform-worker"], | ||
| workflow_files: { "platform-worker": ".lock.yml" }, | ||
| }; | ||
| const handler = await main(config); | ||
|
|
||
| const result = await handler({ type: "dispatch_workflow", workflow_name: "platform-worker", inputs: {} }, {}); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| // Default-branch API lookup must target the configured target-repo | ||
| expect(github.rest.repos.get).toHaveBeenCalledWith({ | ||
| owner: "platform-org", | ||
| repo: "platform-repo", | ||
| }); | ||
| // Dispatch must use the target repo's default branch | ||
| expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| owner: "platform-org", | ||
| repo: "platform-repo", | ||
| ref: "refs/heads/platform-main", | ||
| }) | ||
| ); |
There was a problem hiding this comment.
These new tests mutate shared globals (global.context.ref / global.context.payload) but don’t restore them (and beforeEach only resets env/mocks). To keep the suite order-independent, reset global.context to a known baseline in beforeEach (or restore within each test) so future tests added after these won’t inherit modified context state.
| delete process.env.GITHUB_REF; | |
| delete process.env.GITHUB_HEAD_REF; | |
| global.context.ref = undefined; | |
| // context.payload has a default_branch for the caller repo – must be ignored for cross-repo dispatch | |
| global.context.payload = { repository: { default_branch: "caller-main" } }; | |
| github.rest.repos.get.mockResolvedValueOnce({ | |
| data: { default_branch: "platform-main" }, | |
| }); | |
| const config = { | |
| "target-repo": "platform-org/platform-repo", | |
| workflows: ["platform-worker"], | |
| workflow_files: { "platform-worker": ".lock.yml" }, | |
| }; | |
| const handler = await main(config); | |
| const result = await handler({ type: "dispatch_workflow", workflow_name: "platform-worker", inputs: {} }, {}); | |
| expect(result.success).toBe(true); | |
| // Default-branch API lookup must target the configured target-repo | |
| expect(github.rest.repos.get).toHaveBeenCalledWith({ | |
| owner: "platform-org", | |
| repo: "platform-repo", | |
| }); | |
| // Dispatch must use the target repo's default branch | |
| expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| owner: "platform-org", | |
| repo: "platform-repo", | |
| ref: "refs/heads/platform-main", | |
| }) | |
| ); | |
| const originalRef = global.context.ref; | |
| const originalPayload = global.context.payload; | |
| try { | |
| delete process.env.GITHUB_REF; | |
| delete process.env.GITHUB_HEAD_REF; | |
| global.context.ref = undefined; | |
| // context.payload has a default_branch for the caller repo – must be ignored for cross-repo dispatch | |
| global.context.payload = { repository: { default_branch: "caller-main" } }; | |
| github.rest.repos.get.mockResolvedValueOnce({ | |
| data: { default_branch: "platform-main" }, | |
| }); | |
| const config = { | |
| "target-repo": "platform-org/platform-repo", | |
| workflows: ["platform-worker"], | |
| workflow_files: { "platform-worker": ".lock.yml" }, | |
| }; | |
| const handler = await main(config); | |
| const result = await handler({ type: "dispatch_workflow", workflow_name: "platform-worker", inputs: {} }, {}); | |
| expect(result.success).toBe(true); | |
| // Default-branch API lookup must target the configured target-repo | |
| expect(github.rest.repos.get).toHaveBeenCalledWith({ | |
| owner: "platform-org", | |
| repo: "platform-repo", | |
| }); | |
| // Dispatch must use the target repo's default branch | |
| expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| owner: "platform-org", | |
| repo: "platform-repo", | |
| ref: "refs/heads/platform-main", | |
| }) | |
| ); | |
| } finally { | |
| global.context.ref = originalRef; | |
| global.context.payload = originalPayload; | |
| } |
| const resolvedRepoSlug = defaultTargetRepo || `${context.repo.owner}/${context.repo.repo}`; | ||
| const repo = parseRepoSlug(resolvedRepoSlug) || context.repo; | ||
| const isCrossRepoDispatch = resolvedRepoSlug !== `${context.repo.owner}/${context.repo.repo}`; | ||
|
|
There was a problem hiding this comment.
defaultTargetRepo can be non-string (or contain whitespace), and parseRepoSlug(resolvedRepoSlug) will throw if it’s not a string (because .split won’t exist). Consider normalizing with String(...).trim() before parsing, and treat an explicitly configured but invalid target-repo as a configuration error (or at least warn and avoid silently falling back to context.repo).
This issue also appears on line 28 of the same file.
| const resolvedRepoSlug = defaultTargetRepo || `${context.repo.owner}/${context.repo.repo}`; | |
| const repo = parseRepoSlug(resolvedRepoSlug) || context.repo; | |
| const isCrossRepoDispatch = resolvedRepoSlug !== `${context.repo.owner}/${context.repo.repo}`; | |
| const contextRepoSlug = `${context.repo.owner}/${context.repo.repo}`; | |
| const normalizedTargetRepo = | |
| defaultTargetRepo == null | |
| ? "" | |
| : String(defaultTargetRepo).trim(); | |
| let resolvedRepoSlug = contextRepoSlug; | |
| let repo = context.repo; | |
| if (normalizedTargetRepo) { | |
| try { | |
| const parsedRepo = parseRepoSlug(normalizedTargetRepo); | |
| if (!parsedRepo) { | |
| core.warning( | |
| `Invalid 'target-repo' configuration value '${normalizedTargetRepo}'; falling back to workflow context repository ${contextRepoSlug}.` | |
| ); | |
| } else { | |
| resolvedRepoSlug = normalizedTargetRepo; | |
| repo = parsedRepo; | |
| } | |
| } catch (error) { | |
| core.warning( | |
| `Error parsing 'target-repo' configuration value '${normalizedTargetRepo}': ${getErrorMessage( | |
| error | |
| )}; falling back to workflow context repository ${contextRepoSlug}.` | |
| ); | |
| } | |
| } | |
| const isCrossRepoDispatch = resolvedRepoSlug !== contextRepoSlug; |
|
@copilot review comments, add tests |
- Normalize target-repo with nullish coalescing (??) and trim before parsing - Warn with invalid value and fallback repo when target-repo slug is malformed - Fix test isolation: reset global.context in beforeEach; wrap mutating test in try/finally - Add test: warns and falls back to context.repo when target-repo is an invalid slug - Update safe-outputs-specification.md: dispatch_workflow now has cross-repo support via target-repo Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Addressed all three review comments in db9c64d:
|
In caller-hosted relay topologies,
dispatch_workflowalways dispatched againstcontext.repo(the caller) regardless of the compiledtarget-repovalue, causingcreateWorkflowDispatchto returnNot Foundwhen the worker workflow lived in a different repository.Changes
actions/setup/js/dispatch_workflow.cjsresolveTargetRepoConfigandparseRepoSlugfromrepo_helpers.cjstarget-repowith nullish coalescing and trim before parsing; emit a warning (including the invalid value and fallback repo) and fall back tocontext.repowhen the configured slug is malformed — no silent failuresisCrossRepoDispatchboolean to avoid repeated slug comparisongetDefaultBranchRefnow skips thecontext.payload.repository.default_branchshortcut for cross-repo dispatch — that value belongs to the caller, not the targetactions/setup/js/dispatch_workflow.test.cjs— four new tests:dispatches to target-repo when configured— assertscreateWorkflowDispatchreceives the target owner/repo, notcontext.repodefault-branch lookup uses target-repo when configured— assertsrepos.gettargets the configured repo and the caller's payloaddefault_branchis ignored; test wrapped in try/finally to restore mutated globalsfalls back to context.repo when no target-repo is configured— regression guard for same-repo behaviorfalls back to context.repo and warns when target-repo is an invalid slug— covers the new validation pathbeforeEachnow resetsglobal.context.refandglobal.context.payloadto a known baseline for order-independent test executiondocs/src/content/docs/reference/safe-outputs-specification.mddispatch_workflowCross-Repository Support fromNo (same repository only)toYes (via \target-repo`)`target-repoandallowed-reposactions: writein the target repository📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.