Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions actions/setup/js/dispatch_workflow.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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}`);
}
Comment on lines +54 to +56
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

// 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,
Expand Down
114 changes: 114 additions & 0 deletions actions/setup/js/dispatch_workflow.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
})
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand All @@ -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

---

Expand Down
Loading