Skip to content

Commit 54ad1f8

Browse files
authored
Allow cross-repo safe-outputs.dispatch-workflow allowlists (#39080)
1 parent cbc240f commit 54ad1f8

8 files changed

Lines changed: 195 additions & 2 deletions

actions/setup/js/dispatch_workflow.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ async function main(config = {}) {
5555
// repo_helpers.cjs for consistent slug validation and glob-pattern matching (e.g. "org/*").
5656
if (isCrossRepoDispatch) {
5757
if (allowedRepos.size === 0) {
58-
throw new Error(`E004: Cross-repository dispatch to '${resolvedRepoSlug}' is not permitted. No allowlist is configured. Define 'allowed_repos' to enable cross-repository dispatch.`);
58+
throw new Error(
59+
`E004: Cross-repository dispatch to '${resolvedRepoSlug}' is not permitted. No allowlist is configured. Define 'allowed-repos' in the workflow's 'safe-outputs.dispatch-workflow' section to enable cross-repository dispatch.`
60+
);
5961
}
6062
const repoValidation = validateTargetRepo(resolvedRepoSlug, contextRepoSlug, allowedRepos);
6163
if (!repoValidation.valid) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# ADR-39080: Accept `allowed-repos` for cross-repo `dispatch-workflow`
2+
3+
**Date**: 2026-06-13
4+
**Status**: Draft
5+
6+
## Context
7+
8+
The `safe-outputs.dispatch-workflow` feature lets an agent dispatch other workflows, including in a different repository when `target-repo` is set. The JavaScript handler already enforced an allowlist at runtime (error `E004`), expecting an `allowed_repos` config value, but the workflow frontmatter schema and the Go compiler never accepted an `allowed-repos` key. As a result, an author who wrote a valid cross-repo configuration with `allowed-repos` failed at compile time, leaving no supported way to configure cross-repository dispatch end to end. Other cross-repo safe outputs (e.g. `create-code-scanning-alert`, `push-to-pull-request-branch`) already follow a consistent pattern of declaring an `allowed-repos` allowlist in frontmatter that is parsed by the compiler and emitted into the handler config.
9+
10+
## Decision
11+
12+
We will add `allowed-repos` to the `dispatch-workflow` safe-output as a first-class frontmatter field, wired through the full pipeline so cross-repo dispatch is configurable end to end:
13+
14+
- Add `allowed-repos` to the `dispatch-workflow` JSON schema, accepting both an array of `owner/repo` slugs (supporting wildcards such as `org/*`) and a GitHub Actions expression resolving to a comma-separated list.
15+
- Parse `allowed-repos` into `DispatchWorkflowConfig.AllowedRepos` via the shared `ParseStringArrayFromConfig` helper.
16+
- Emit it into the runtime handler config as `allowed_repos` using `AddTemplatableStringSlice`, matching the JS handler contract and preserving templated allowlists the same way other cross-repo safe outputs do.
17+
18+
We chose to mirror the existing cross-repo safe-output convention rather than invent a new mechanism, so configuration and templating behave identically across all safe outputs. The runtime error message is also tightened to point authors at the `safe-outputs.dispatch-workflow` section instead of an internal config key name.
19+
20+
## Alternatives Considered
21+
22+
### Alternative 1: Keep runtime-only enforcement, document the limitation
23+
Leave the schema and compiler unchanged and require users to rely on the runtime check, documenting that cross-repo dispatch could not be statically configured. Rejected because it leaves a valid feature unreachable through supported configuration — any `allowed-repos` config fails to compile — and contradicts the runtime handler that already expects the allowlist.
24+
25+
### Alternative 2: Introduce a new, dispatch-specific allowlist key/shape
26+
Add a bespoke field (e.g. `dispatch-allowlist`) with its own parsing and emission logic specific to `dispatch-workflow`. Rejected because it would diverge from the established `allowed-repos` convention used by other cross-repo safe outputs, duplicating templating logic and increasing cognitive load for authors who already know the existing pattern.
27+
28+
## Consequences
29+
30+
### Positive
31+
- Cross-repository `dispatch-workflow` can now be configured end to end (schema → compiler → handler) without compile-time failures.
32+
- Behavior is consistent with other cross-repo safe outputs, including templated/expression-based allowlists, reducing surprise for authors.
33+
- Regression tests cover schema acceptance, config parsing, and handler-config emission for the new field.
34+
35+
### Negative
36+
- Adds another surface (schema + parsing + emission) that must stay in sync with the JS handler's `allowed_repos` contract; a future change to one side risks drift.
37+
- Broadens the configuration surface of a security-sensitive feature (cross-repo dispatch), so the allowlist semantics and wildcard matching must be reviewed carefully.
38+
39+
### Neutral
40+
- The frontmatter key is `allowed-repos` while the emitted handler key is `allowed_repos`; the naming difference follows the existing convention but must be remembered when tracing config through the pipeline.
41+
- No change to default behavior: omitting `allowed-repos` still blocks cross-repo dispatch via the existing `E004` error.
42+
43+
---
44+
45+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27468597143) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

docs/src/content/docs/reference/safe-outputs.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,9 @@ safe-outputs:
12181218

12191219
- **`workflows`** (required) - List of workflow names (without `.md` extension) that the agent is allowed to dispatch. For same-repo dispatch, each workflow must exist locally and support the `workflow_dispatch` trigger.
12201220
- **`max`** (optional) - Maximum number of workflow dispatches allowed (default: 1, maximum: 50). This prevents excessive workflow triggering.
1221+
- **`target-repo`** (optional) - Target repository in `owner/repo` format for cross-repository dispatch.
1222+
- **`allowed-repos`** (optional) - Allowlist of cross-repository dispatch targets. Required when `target-repo` points to a different repository. Supports repository slugs and wildcards such as `org/*`, or a GitHub Actions expression string (e.g. `"${{ inputs['allowed-repos'] }}"`) for dynamic allowlists.
1223+
- **`target-ref`** (optional) - Git ref to dispatch on. In `workflow_call` relay scenarios, the compiler injects this automatically so the dispatch uses the target repository's branch or tag instead of the caller's `GITHUB_REF`.
12211224

12221225
#### Validation Rules
12231226

pkg/parser/schema_safe_outputs_target_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,16 @@ func TestMainWorkflowSchema_SafeOutputsTargetProperties(t *testing.T) {
269269
},
270270
},
271271
},
272+
{
273+
name: "dispatch-workflow with target-repo and allowed-repos",
274+
safeOutputs: map[string]any{
275+
"dispatch-workflow": map[string]any{
276+
"workflows": []any{"worker"},
277+
"target-repo": "github/github",
278+
"allowed-repos": []any{"github/docs"},
279+
},
280+
},
281+
},
272282
{
273283
name: "push-to-pull-request-branch with target and target-repo",
274284
safeOutputs: map[string]any{

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9059,6 +9059,22 @@
90599059
"type": "string",
90609060
"description": "Target repository in format 'owner/repo' for cross-repository workflow dispatch. When specified, the workflow will be dispatched to the target repository instead of the current one."
90619061
},
9062+
"allowed-repos": {
9063+
"description": "List of repositories in format 'owner/repo' that cross-repository workflow dispatch may target. Supports arrays and GitHub Actions expressions resolving to a comma-separated list (e.g. '${{ inputs['allowed-repos'] }}').",
9064+
"oneOf": [
9065+
{
9066+
"type": "array",
9067+
"items": {
9068+
"type": "string"
9069+
}
9070+
},
9071+
{
9072+
"type": "string",
9073+
"pattern": "^\\$\\{\\{.*\\}\\}$",
9074+
"description": "GitHub Actions expression resolving to a comma-separated list of repository slugs (e.g. '${{ inputs['allowed-repos'] }}')"
9075+
}
9076+
]
9077+
},
90629078
"target-ref": {
90639079
"type": "string",
90649080
"description": "Git ref (branch, tag, or SHA) to use when dispatching the workflow. For workflow_call relay scenarios this is auto-injected by the compiler from needs.activation.outputs.target_ref. Overrides the caller's GITHUB_REF."

pkg/workflow/dispatch_workflow.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type DispatchWorkflowConfig struct {
1313
WorkflowFiles map[string]string `yaml:"workflow_files,omitempty"` // Map of workflow name to file extension (.lock.yml or .yml) - populated at compile time
1414
AwContextWorkflows []string `yaml:"aw_context_workflows,omitempty"` // Workflows that declare aw_context in workflow_dispatch.inputs - populated at compile time
1515
TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression)
16+
AllowedRepos []string `yaml:"allowed-repos,omitempty"` // Allowlist for cross-repository dispatch targets
1617
TargetRef string `yaml:"target-ref,omitempty"` // Target ref for cross-repo dispatch; overrides the caller's GITHUB_REF
1718
}
1819

@@ -60,6 +61,7 @@ func (c *Compiler) parseDispatchWorkflowConfig(outputMap map[string]any) *Dispat
6061

6162
// Parse target-repo (optional cross-repo dispatch target)
6263
dispatchWorkflowConfig.TargetRepoSlug = extractStringFromMap(configMap, "target-repo", dispatchWorkflowLog)
64+
dispatchWorkflowConfig.AllowedRepos = ParseStringArrayOrExprFromConfig(configMap, "allowed-repos", dispatchWorkflowLog)
6365

6466
// Cap max at 50 (absolute maximum allowed) – only for literal integer values
6567
if maxVal := templatableIntValue(dispatchWorkflowConfig.Max); maxVal > 50 {

pkg/workflow/safe_outputs_cross_repo_config_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,84 @@ import (
1111
"github.com/stretchr/testify/require"
1212
)
1313

14+
// TestDispatchWorkflowConfigTargetRepo verifies that dispatch-workflow correctly parses
15+
// target-repo and allowed-repos fields.
16+
func TestDispatchWorkflowConfigTargetRepo(t *testing.T) {
17+
compiler := NewCompiler()
18+
19+
tests := []struct {
20+
name string
21+
configMap map[string]any
22+
expectedRepo string
23+
expectedRepos []string
24+
expectedToken string
25+
}{
26+
{
27+
name: "target-repo and allowed-repos configured",
28+
configMap: map[string]any{
29+
"dispatch-workflow": map[string]any{
30+
"workflows": []any{"worker"},
31+
"target-repo": "githubnext/gh-aw-side-repo",
32+
"allowed-repos": []any{"githubnext/gh-aw-side-repo"},
33+
"github-token": "${{ secrets.TEMP_USER_PAT }}",
34+
},
35+
},
36+
expectedRepo: "githubnext/gh-aw-side-repo",
37+
expectedRepos: []string{"githubnext/gh-aw-side-repo"},
38+
expectedToken: "${{ secrets.TEMP_USER_PAT }}",
39+
},
40+
{
41+
name: "multiple allowed repos",
42+
configMap: map[string]any{
43+
"dispatch-workflow": map[string]any{
44+
"workflows": []any{"worker"},
45+
"target-repo": "org/primary-repo",
46+
"allowed-repos": []any{"org/primary-repo", "org/secondary-repo"},
47+
},
48+
},
49+
expectedRepo: "org/primary-repo",
50+
expectedRepos: []string{"org/primary-repo", "org/secondary-repo"},
51+
expectedToken: "",
52+
},
53+
{
54+
name: "allowed-repos as GitHub Actions expression",
55+
configMap: map[string]any{
56+
"dispatch-workflow": map[string]any{
57+
"workflows": []any{"worker"},
58+
"target-repo": "${{ inputs.target_repo }}",
59+
"allowed-repos": "${{ inputs['allowed-repos'] }}",
60+
},
61+
},
62+
expectedRepo: "${{ inputs.target_repo }}",
63+
expectedRepos: []string{"${{ inputs['allowed-repos'] }}"},
64+
expectedToken: "",
65+
},
66+
{
67+
name: "no cross-repo config",
68+
configMap: map[string]any{
69+
"dispatch-workflow": map[string]any{
70+
"workflows": []any{"worker"},
71+
"max": 2,
72+
},
73+
},
74+
expectedRepo: "",
75+
expectedRepos: nil,
76+
expectedToken: "",
77+
},
78+
}
79+
80+
for _, tt := range tests {
81+
t.Run(tt.name, func(t *testing.T) {
82+
cfg := compiler.parseDispatchWorkflowConfig(tt.configMap)
83+
84+
require.NotNil(t, cfg, "config should not be nil")
85+
assert.Equal(t, tt.expectedRepo, cfg.TargetRepoSlug, "TargetRepoSlug mismatch")
86+
assert.Equal(t, tt.expectedRepos, cfg.AllowedRepos, "AllowedRepos mismatch")
87+
assert.Equal(t, tt.expectedToken, cfg.GitHubToken, "GitHubToken mismatch")
88+
})
89+
}
90+
}
91+
1492
// TestCreateCodeScanningAlertConfigTargetRepo verifies that create-code-scanning-alert
1593
// correctly parses target-repo and allowed-repos fields.
1694
func TestCreateCodeScanningAlertConfigTargetRepo(t *testing.T) {
@@ -420,6 +498,42 @@ func TestPushToPullRequestBranchCrossRepoInHandlerConfig(t *testing.T) {
420498
assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo")
421499
}
422500

501+
// TestDispatchWorkflowCrossRepoInHandlerConfig verifies that target-repo, allowed-repos,
502+
// and github-token are included in the handler manager config JSON for dispatch-workflow.
503+
func TestDispatchWorkflowCrossRepoInHandlerConfig(t *testing.T) {
504+
compiler := NewCompiler()
505+
506+
workflowData := &WorkflowData{
507+
Name: "Test",
508+
SafeOutputs: &SafeOutputsConfig{
509+
DispatchWorkflow: &DispatchWorkflowConfig{
510+
BaseSafeOutputConfig: BaseSafeOutputConfig{
511+
GitHubToken: "${{ secrets.TEMP_USER_PAT }}",
512+
},
513+
Workflows: []string{"worker"},
514+
TargetRepoSlug: "githubnext/gh-aw-side-repo",
515+
AllowedRepos: []string{"githubnext/gh-aw-side-repo"},
516+
},
517+
},
518+
}
519+
520+
var steps []string
521+
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
522+
523+
require.NotEmpty(t, steps)
524+
handlerConfig := extractHandlerConfig(t, strings.Join(steps, ""))
525+
526+
dispatchWorkflow, ok := handlerConfig["dispatch_workflow"]
527+
require.True(t, ok, "dispatch_workflow config should be present")
528+
529+
assert.Equal(t, "${{ secrets.TEMP_USER_PAT }}", dispatchWorkflow["github-token"], "github-token should be in handler config")
530+
assert.Equal(t, "githubnext/gh-aw-side-repo", dispatchWorkflow["target-repo"], "target-repo should be in handler config")
531+
532+
allowedRepos, ok := dispatchWorkflow["allowed_repos"]
533+
require.True(t, ok, "allowed_repos should be present")
534+
assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo")
535+
}
536+
423537
// TestHandlerManagerStepPerOutputTokenInHandlerConfig verifies that per-output tokens
424538
// (e.g., add-comment.github-token) are wired into the handler config JSON (GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG)
425539
// but NOT used as the step-level with.github-token. The step-level token follows the same

pkg/workflow/safe_outputs_handler_registry.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,8 @@ var handlerRegistry = map[string]handlerBuilder{
598598
builder := newHandlerConfigBuilder().
599599
AddTemplatableInt("max", c.Max).
600600
AddStringSlice("workflows", c.Workflows).
601-
AddIfNotEmpty("target-repo", c.TargetRepoSlug)
601+
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
602+
AddTemplatableStringSlice("allowed_repos", c.AllowedRepos)
602603

603604
// Add workflow_files map if it has entries
604605
if len(c.WorkflowFiles) > 0 {

0 commit comments

Comments
 (0)