Skip to content
Closed
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
1,047 changes: 1,047 additions & 0 deletions .github/workflows/dispatch-cross-repo-example.lock.yml

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions .github/workflows/dispatch-cross-repo-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
on: issues
engine: copilot
permissions:
contents: read

safe-outputs:
dispatch-workflow:
workflows:
- test-workflow
max: 3
target-repo: github/gh-aw
allowed-repos:
- github/test-repo
- octocat/hello-world
---

# Cross-Repository Dispatch Example

This workflow demonstrates dispatching workflows across repositories using the safe-outputs.dispatch-workflow feature.

## Same-Repository Dispatch

By default, workflows are dispatched to the current repository:

```json
{
"type": "dispatch_workflow",
"workflow_name": "test-workflow",
"inputs": {
"environment": "staging"
}
}
```

## Cross-Repository Dispatch

To dispatch to a different repository, specify the `repo` field. The repository must be in the allowed-repos list:

```json
{
"type": "dispatch_workflow",
"workflow_name": "test-workflow",
"repo": "github/test-repo",
"inputs": {
"environment": "production"
}
}
```

## Security

- **Deny-by-default**: Cross-repository dispatch requires explicit allowlist
- **Same-repo always allowed**: The workflow's repository is always allowed
- **Bare names auto-qualified**: `"test-repo"` becomes `"github/test-repo"` based on target-repo's org

## Configuration Options

- `workflows`: List of workflow names to allow dispatching
- `max`: Maximum number of dispatches per run (default: 1, max: 50)
- `target-repo`: Default target repository (optional, defaults to current repo)
- `allowed-repos`: Additional repositories that can be targeted (deny-by-default)
47 changes: 42 additions & 5 deletions actions/setup/js/dispatch_workflow.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
const HANDLER_TYPE = "dispatch_workflow";

const { getErrorMessage } = require("./error_helpers.cjs");
const { resolveTargetRepoConfig, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs");

/**
* Main handler factory for dispatch_workflow
Expand All @@ -21,13 +22,20 @@ async function main(config = {}) {
const maxCount = config.max || 1;
const workflowFiles = config.workflow_files || {}; // Map of workflow name to file extension

// Extract cross-repo configuration
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

core.info(`Dispatch workflow configuration: max=${maxCount}`);
if (allowedWorkflows.length > 0) {
core.info(`Allowed workflows: ${allowedWorkflows.join(", ")}`);
}
if (Object.keys(workflowFiles).length > 0) {
core.info(`Workflow files: ${JSON.stringify(workflowFiles)}`);
}
core.info(`Default target repository: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repositories: ${Array.from(allowedRepos).join(", ")}`);
}

// Track how many items we've processed for max limit
let processedCount = 0;
Expand Down Expand Up @@ -112,6 +120,34 @@ async function main(config = {}) {
};
}

// Determine target repository for this dispatch (default to current repo)
const itemRepo = item.repo ? String(item.repo).trim() : defaultTargetRepo;

// Validate the repository is allowed
const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos);
if (!repoValidation.valid) {
const errorMessage = repoValidation.error || `Repository '${itemRepo}' validation failed`;
core.warning(errorMessage);
return {
success: false,
error: errorMessage,
};
}

// Use the qualified repo from validation (handles bare names)
const qualifiedItemRepo = repoValidation.qualifiedRepo;

// Parse the repository slug
const repoParts = parseRepoSlug(qualifiedItemRepo);
if (!repoParts) {
const error = `Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`;
core.warning(error);
return {
success: false,
error: error,
};
}
Comment on lines +123 to +149
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

Consider using the newer resolveAndValidateRepo helper function instead of manually calling validateRepo and parseRepoSlug separately. This would make the code more consistent with other handlers like add_comment, create_pull_request, and reply_to_pr_review_comment.

The resolveAndValidateRepo function combines both steps and returns a result with success, error, repo, and repoParts fields, simplifying the logic:

const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "workflow dispatch");
if (!repoResult.success) {
  core.warning(repoResult.error);
  return {
    success: false,
    error: repoResult.error,
  };
}
const { repo: qualifiedItemRepo, repoParts } = repoResult;

This would replace lines 123-149 and make the code more maintainable and consistent with the established patterns in the codebase.

Copilot uses AI. Check for mistakes.

try {
// Add 5 second delay between dispatches (except for the first one)
if (lastDispatchTime > 0) {
Expand Down Expand Up @@ -151,25 +187,26 @@ async function main(config = {}) {
}

const workflowFile = `${workflowName}${extension}`;
core.info(`Dispatching workflow: ${workflowFile}`);
core.info(`Dispatching workflow: ${workflowFile} in repository: ${qualifiedItemRepo}`);

// Dispatch the workflow using the resolved file
// Dispatch the workflow using the resolved file and target repository
await github.rest.actions.createWorkflowDispatch({
owner: repo.owner,
repo: repo.repo,
owner: repoParts.owner,
repo: repoParts.repo,
workflow_id: workflowFile,
ref: ref,
inputs: inputs,
});

core.info(`✓ Successfully dispatched workflow: ${workflowFile}`);
core.info(`✓ Successfully dispatched workflow: ${workflowFile} in ${qualifiedItemRepo}`);

// Record the time of this dispatch for rate limiting
lastDispatchTime = Date.now();

return {
success: true,
workflow_name: workflowName,
repo: qualifiedItemRepo,
inputs: inputs,
};
} catch (error) {
Expand Down
198 changes: 198 additions & 0 deletions actions/setup/js/dispatch_workflow.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,201 @@ describe("dispatch_workflow handler factory", () => {
});
});
});

describe("dispatch_workflow cross-repository support", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.GITHUB_REF = "refs/heads/main";
delete process.env.GITHUB_HEAD_REF;
});

it("should dispatch to same repository by default", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.repo).toBe("test-owner/test-repo");
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({
owner: "test-owner",
repo: "test-repo",
workflow_id: "test-workflow.lock.yml",
ref: expect.any(String),
inputs: {},
});
});

it("should allow dispatching to target-repo", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
"target-repo": "other-owner/other-repo",
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.repo).toBe("other-owner/other-repo");
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({
owner: "other-owner",
repo: "other-repo",
workflow_id: "test-workflow.lock.yml",
ref: expect.any(String),
inputs: {},
});
});

it("should allow dispatching to allowed-repos", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
allowed_repos: ["org/repo-a", "org/repo-b"],
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
repo: "org/repo-a",
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.repo).toBe("org/repo-a");
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({
owner: "org",
repo: "repo-a",
workflow_id: "test-workflow.lock.yml",
ref: expect.any(String),
inputs: {},
});
});

it("should reject non-allowlisted repositories", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
allowed_repos: ["org/repo-a"],
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
repo: "org/unauthorized-repo",
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(false);
expect(result.error).toContain("not in the allowed-repos list");
expect(result.error).toContain("org/unauthorized-repo");
expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled();
});

it("should reject malformed repository references", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
allowed_repos: ["org/repo-a"],
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
repo: "invalid-repo-format",
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(false);
expect(result.error).toContain("not in the allowed-repos list");
expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled();
});

it("should auto-qualify bare repository names with default org", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
"target-repo": "test-owner/test-repo",
allowed_repos: ["test-owner/other-repo"],
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
repo: "other-repo", // Bare name should be qualified
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(result.repo).toBe("test-owner/other-repo");
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({
owner: "test-owner",
repo: "other-repo",
workflow_id: "test-workflow.lock.yml",
ref: expect.any(String),
inputs: {},
});
});

it("should always allow same repository without explicit allowlist", async () => {
const config = {
workflows: ["test-workflow"],
workflow_files: {
"test-workflow": ".lock.yml",
},
// No allowed_repos configured
};
const handler = await main(config);

const message = {
type: "dispatch_workflow",
workflow_name: "test-workflow",
repo: "test-owner/test-repo", // Same as default repo
inputs: {},
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalled();
});
});
Loading
Loading