diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index 6a483ac709b..c2a2af31029 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,26 @@ 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 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) { @@ -30,22 +51,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..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 () => { @@ -545,4 +548,115 @@ 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 () => { + 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; + } + }); + + 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", + }) + ); + }); + + 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" }, + }; + const handler = await main(config); + + 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", + repo: "test-repo", + }) + ); + }); }); 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 ---