From 21892efd7078245625b8bb610708c27e04b4a035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:23:07 +0000 Subject: [PATCH 1/3] Initial plan From 37bb4c7db616d77dcf75ed61194966c2b601023d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:38:58 +0000 Subject: [PATCH 2/3] fix: dispatch_workflow honors target-repo in cross-repo relays 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> --- actions/setup/js/dispatch_workflow.cjs | 21 ++++-- actions/setup/js/dispatch_workflow.test.cjs | 79 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index 6a483ac709b..2dd8ea364ed 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -10,6 +10,7 @@ const HANDLER_TYPE = "dispatch_workflow"; const { getErrorMessage } = require("./error_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); +const { resolveTargetRepoConfig, parseRepoSlug } = require("./repo_helpers.cjs"); /** * Main handler factory for dispatch_workflow @@ -22,6 +23,12 @@ async function main(config = {}) { const maxCount = config.max || 1; const workflowFiles = config.workflow_files || {}; // Map of workflow name to file extension const githubClient = await createAuthenticatedGitHubClient(config); + const { defaultTargetRepo } = resolveTargetRepoConfig(config); + + // 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}`; core.info(`Dispatch workflow configuration: max=${maxCount}`); if (allowedWorkflows.length > 0) { @@ -30,22 +37,22 @@ async function main(config = {}) { if (Object.keys(workflowFiles).length > 0) { core.info(`Workflow files: ${JSON.stringify(workflowFiles)}`); } + if (isCrossRepoDispatch) { + core.info(`Dispatching to target repo: ${resolvedRepoSlug}`); + } // Track how many items we've processed for max limit let processedCount = 0; let lastDispatchTime = 0; - // Get the current repository context and ref - const repo = context.repo; - - // Helper function to get the default branch + // Helper function to get the default branch of the dispatch target repository const getDefaultBranchRef = async () => { - // Try to get from context payload first - if (context.payload.repository?.default_branch) { + // Only use the context payload's default_branch when dispatching to the caller's own repo + if (!isCrossRepoDispatch && context.payload.repository?.default_branch) { return `refs/heads/${context.payload.repository.default_branch}`; } - // Fall back to querying the repository + // Fall back to querying the target repository try { const { data: repoData } = await githubClient.rest.repos.get({ owner: repo.owner, diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index 9a624bde57e..b33add08af8 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -545,4 +545,83 @@ describe("dispatch_workflow handler factory", () => { expect(result.success).toBe(false); expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledTimes(1); }); + + it("dispatches to target-repo when configured", async () => { + process.env.GITHUB_REF = "refs/heads/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); + // Must dispatch to the configured target-repo, NOT context.repo + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "platform-org", + repo: "platform-repo", + workflow_id: "platform-worker.lock.yml", + }) + ); + }); + + it("default-branch lookup uses target-repo when configured", async () => { + 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", + }) + ); + }); + + it("falls back to context.repo when no target-repo is configured", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + + const config = { + workflows: ["test-workflow"], + workflow_files: { "test-workflow": ".lock.yml" }, + }; + const handler = await main(config); + + const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {}); + + expect(result.success).toBe(true); + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + }) + ); + }); }); From db9c64d4066b89057dfa6fe08cff08f2190bc466 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:59:21 +0000 Subject: [PATCH 3/3] fix: address review feedback on dispatch_workflow target-repo handling - 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> --- actions/setup/js/dispatch_workflow.cjs | 20 ++++- actions/setup/js/dispatch_workflow.test.cjs | 79 +++++++++++++------ .../reference/safe-outputs-specification.md | 9 ++- 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index 2dd8ea364ed..c2a2af31029 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -26,9 +26,23 @@ async function main(config = {}) { const { defaultTargetRepo } = resolveTargetRepoConfig(config); // 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}`; + const contextRepoSlug = `${context.repo.owner}/${context.repo.repo}`; + const normalizedTargetRepo = (defaultTargetRepo ?? "").toString().trim(); + + let resolvedRepoSlug = contextRepoSlug; + let repo = context.repo; + + if (normalizedTargetRepo) { + 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; + } + } + + const isCrossRepoDispatch = resolvedRepoSlug !== contextRepoSlug; core.info(`Dispatch workflow configuration: max=${maxCount}`); if (allowedWorkflows.length > 0) { diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index b33add08af8..196a8eaa15d 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -42,6 +42,9 @@ describe("dispatch_workflow handler factory", () => { vi.clearAllMocks(); process.env.GITHUB_REF = "refs/heads/main"; delete process.env.GITHUB_HEAD_REF; // Clean up PR environment variable + // Reset shared context to a known baseline so tests are order-independent + global.context.ref = "refs/heads/main"; + global.context.payload = { repository: { default_branch: "main" } }; }); it("should create a handler function", async () => { @@ -570,45 +573,74 @@ describe("dispatch_workflow handler factory", () => { }); it("default-branch lookup uses target-repo when configured", async () => { - 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" } }; + 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; + } + }); - github.rest.repos.get.mockResolvedValueOnce({ - data: { default_branch: "platform-main" }, - }); + it("falls back to context.repo when no target-repo is configured", async () => { + process.env.GITHUB_REF = "refs/heads/main"; const config = { - "target-repo": "platform-org/platform-repo", - workflows: ["platform-worker"], - workflow_files: { "platform-worker": ".lock.yml" }, + workflows: ["test-workflow"], + workflow_files: { "test-workflow": ".lock.yml" }, }; const handler = await main(config); - const result = await handler({ type: "dispatch_workflow", workflow_name: "platform-worker", inputs: {} }, {}); + const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", 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", + owner: "test-owner", + repo: "test-repo", }) ); }); - it("falls back to context.repo when no target-repo is configured", async () => { + it("falls back to context.repo and warns when target-repo is an invalid slug", async () => { process.env.GITHUB_REF = "refs/heads/main"; const config = { + "target-repo": "not-a-valid-slug", workflows: ["test-workflow"], workflow_files: { "test-workflow": ".lock.yml" }, }; @@ -617,6 +649,9 @@ describe("dispatch_workflow handler factory", () => { const result = await handler({ type: "dispatch_workflow", workflow_name: "test-workflow", inputs: {} }, {}); expect(result.success).toBe(true); + // Must emit a warning about the invalid slug including the bad value and the fallback + expect(core.warning).toHaveBeenCalledWith(expect.stringMatching(/Invalid 'target-repo' configuration value 'not-a-valid-slug'.*falling back.*test-owner\/test-repo/)); + // Must fall back to context.repo expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( expect.objectContaining({ owner: "test-owner", diff --git a/docs/src/content/docs/reference/safe-outputs-specification.md b/docs/src/content/docs/reference/safe-outputs-specification.md index 115be30dfc6..7df65dfa256 100644 --- a/docs/src/content/docs/reference/safe-outputs-specification.md +++ b/docs/src/content/docs/reference/safe-outputs-specification.md @@ -3055,7 +3055,7 @@ safe-outputs: **Purpose**: Trigger workflow_dispatch events to invoke other workflows. **Default Max**: 3 -**Cross-Repository Support**: No (same repository only) +**Cross-Repository Support**: Yes (via `target-repo`) **Mandatory**: No **Required Permissions**: @@ -3067,10 +3067,17 @@ safe-outputs: - `actions: write` - Workflow dispatch operations - `metadata: read` - Repository metadata (automatically granted) +**Configuration Parameters**: +- `max`: Operation limit (default: 3) +- `workflows`: Allowlist of workflow names that may be dispatched +- `target-repo`: Cross-repository target (owner/repo) +- `allowed-repos`: Cross-repo allowlist (supports wildcards, e.g. `org/*`) + **Notes**: - Requires ONLY `actions: write` permission (no `contents: read` needed) - Target workflow must support `workflow_dispatch` trigger - Workflow inputs are validated against target workflow's input schema +- Cross-repository dispatch requires appropriate `actions: write` permissions in the target repository ---