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
195 changes: 146 additions & 49 deletions .github/workflows/architecture-guardian.lock.yml

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion actions/setup/js/dispatch_workflow.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ async function main(config = {}) {
// repo_helpers.cjs for consistent slug validation and glob-pattern matching (e.g. "org/*").
if (isCrossRepoDispatch) {
if (allowedRepos.size === 0) {
throw new Error(`E004: Cross-repository dispatch to '${resolvedRepoSlug}' is not permitted. No allowlist is configured. Define 'allowed_repos' to enable cross-repository dispatch.`);
throw new Error(
`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.`
);
}
const repoValidation = validateTargetRepo(resolvedRepoSlug, contextRepoSlug, allowedRepos);
if (!repoValidation.valid) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-39080: Accept `allowed-repos` for cross-repo `dispatch-workflow`

**Date**: 2026-06-13
**Status**: Draft

## Context

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.

## Decision

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:

- 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.
- Parse `allowed-repos` into `DispatchWorkflowConfig.AllowedRepos` via the shared `ParseStringArrayFromConfig` helper.
- 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.

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.

## Alternatives Considered

### Alternative 1: Keep runtime-only enforcement, document the limitation
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.

### Alternative 2: Introduce a new, dispatch-specific allowlist key/shape
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.

## Consequences

### Positive
- Cross-repository `dispatch-workflow` can now be configured end to end (schema → compiler → handler) without compile-time failures.
- Behavior is consistent with other cross-repo safe outputs, including templated/expression-based allowlists, reducing surprise for authors.
- Regression tests cover schema acceptance, config parsing, and handler-config emission for the new field.

### Negative
- 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.
- Broadens the configuration surface of a security-sensitive feature (cross-repo dispatch), so the allowlist semantics and wildcard matching must be reviewed carefully.

### Neutral
- 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.
- No change to default behavior: omitting `allowed-repos` still blocks cross-repo dispatch via the existing `E004` error.

---

*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.*
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,9 @@ safe-outputs:

- **`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.
- **`max`** (optional) - Maximum number of workflow dispatches allowed (default: 1, maximum: 50). This prevents excessive workflow triggering.
- **`target-repo`** (optional) - Target repository in `owner/repo` format for cross-repository dispatch.
- **`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.
- **`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`.

#### Validation Rules

Expand Down
10 changes: 10 additions & 0 deletions pkg/parser/schema_safe_outputs_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ func TestMainWorkflowSchema_SafeOutputsTargetProperties(t *testing.T) {
},
},
},
{
name: "dispatch-workflow with target-repo and allowed-repos",
safeOutputs: map[string]any{
"dispatch-workflow": map[string]any{
"workflows": []any{"worker"},
"target-repo": "github/github",
"allowed-repos": []any{"github/docs"},
},
},
},
{
name: "push-to-pull-request-branch with target and target-repo",
safeOutputs: map[string]any{
Expand Down
16 changes: 16 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9059,6 +9059,22 @@
"type": "string",
"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."
},
"allowed-repos": {
"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'] }}').",
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string",
"pattern": "^\\$\\{\\{.*\\}\\}$",
"description": "GitHub Actions expression resolving to a comma-separated list of repository slugs (e.g. '${{ inputs['allowed-repos'] }}')"
}
]
},
"target-ref": {
"type": "string",
"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."
Expand Down
2 changes: 2 additions & 0 deletions pkg/workflow/dispatch_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type DispatchWorkflowConfig struct {
WorkflowFiles map[string]string `yaml:"workflow_files,omitempty"` // Map of workflow name to file extension (.lock.yml or .yml) - populated at compile time
AwContextWorkflows []string `yaml:"aw_context_workflows,omitempty"` // Workflows that declare aw_context in workflow_dispatch.inputs - populated at compile time
TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression)
AllowedRepos []string `yaml:"allowed-repos,omitempty"` // Allowlist for cross-repository dispatch targets
TargetRef string `yaml:"target-ref,omitempty"` // Target ref for cross-repo dispatch; overrides the caller's GITHUB_REF
}

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

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

// Cap max at 50 (absolute maximum allowed) – only for literal integer values
if maxVal := templatableIntValue(dispatchWorkflowConfig.Max); maxVal > 50 {
Expand Down
114 changes: 114 additions & 0 deletions pkg/workflow/safe_outputs_cross_repo_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,84 @@ import (
"github.com/stretchr/testify/require"
)

// TestDispatchWorkflowConfigTargetRepo verifies that dispatch-workflow correctly parses
// target-repo and allowed-repos fields.
func TestDispatchWorkflowConfigTargetRepo(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Missing test case for the template-expression form of allowed-repos — this would have caught the parse-function mismatch above.

💡 Suggested addition to the test table

Add a fourth case to TestDispatchWorkflowConfigTargetRepo:

{
    name: "allowed-repos as GitHub Actions expression",
    configMap: map[string]any{
        "dispatch-workflow": map[string]any{
            "workflows":     []any{"worker"},
            "target-repo":   "${{ inputs.target-repo }}",
            "allowed-repos": "${{ inputs.allowed-repos }}",
        },
    },
    expectedRepo:  "${{ inputs.target-repo }}",
    // With ParseStringArrayOrExprFromConfig the expression is wrapped:
    expectedRepos: []string{"${{ inputs.allowed-repos }}"},
},

With ParseStringArrayFromConfig (the current code) expectedRepos will be nil, making the test fail and immediately surfacing the wrong function choice.

compiler := NewCompiler()

tests := []struct {
name string
configMap map[string]any
expectedRepo string
expectedRepos []string
expectedToken string
}{
{
name: "target-repo and allowed-repos configured",
configMap: map[string]any{
"dispatch-workflow": map[string]any{
"workflows": []any{"worker"},
"target-repo": "githubnext/gh-aw-side-repo",
"allowed-repos": []any{"githubnext/gh-aw-side-repo"},
"github-token": "${{ secrets.TEMP_USER_PAT }}",
},
},
expectedRepo: "githubnext/gh-aw-side-repo",
expectedRepos: []string{"githubnext/gh-aw-side-repo"},
expectedToken: "${{ secrets.TEMP_USER_PAT }}",
},
{
name: "multiple allowed repos",
configMap: map[string]any{
"dispatch-workflow": map[string]any{
"workflows": []any{"worker"},
"target-repo": "org/primary-repo",
"allowed-repos": []any{"org/primary-repo", "org/secondary-repo"},
},
},
expectedRepo: "org/primary-repo",
expectedRepos: []string{"org/primary-repo", "org/secondary-repo"},
expectedToken: "",
},
Comment on lines +19 to +52
{
name: "allowed-repos as GitHub Actions expression",
configMap: map[string]any{
"dispatch-workflow": map[string]any{
"workflows": []any{"worker"},
"target-repo": "${{ inputs.target_repo }}",
"allowed-repos": "${{ inputs['allowed-repos'] }}",
},
},
expectedRepo: "${{ inputs.target_repo }}",
expectedRepos: []string{"${{ inputs['allowed-repos'] }}"},
expectedToken: "",
},
{
name: "no cross-repo config",
configMap: map[string]any{
"dispatch-workflow": map[string]any{
"workflows": []any{"worker"},
"max": 2,
},
},
expectedRepo: "",
expectedRepos: nil,
expectedToken: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := compiler.parseDispatchWorkflowConfig(tt.configMap)

require.NotNil(t, cfg, "config should not be nil")
assert.Equal(t, tt.expectedRepo, cfg.TargetRepoSlug, "TargetRepoSlug mismatch")
assert.Equal(t, tt.expectedRepos, cfg.AllowedRepos, "AllowedRepos mismatch")
assert.Equal(t, tt.expectedToken, cfg.GitHubToken, "GitHubToken mismatch")
})
}
}

// TestCreateCodeScanningAlertConfigTargetRepo verifies that create-code-scanning-alert
// correctly parses target-repo and allowed-repos fields.
func TestCreateCodeScanningAlertConfigTargetRepo(t *testing.T) {
Expand Down Expand Up @@ -420,6 +498,42 @@ func TestPushToPullRequestBranchCrossRepoInHandlerConfig(t *testing.T) {
assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo")
}

// TestDispatchWorkflowCrossRepoInHandlerConfig verifies that target-repo, allowed-repos,
// and github-token are included in the handler manager config JSON for dispatch-workflow.
func TestDispatchWorkflowCrossRepoInHandlerConfig(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The handler-config test only exercises a literal-array allowed-repos; add a parallel case for the expression form so that the AddTemplatableStringSlice → JSON-string path is covered too.

💡 Suggested parallel test
func TestDispatchWorkflowCrossRepoInHandlerConfigExpression(t *testing.T) {
    compiler := NewCompiler()
    workflowData := &WorkflowData{
        Name: "Test",
        SafeOutputs: &SafeOutputsConfig{
            DispatchWorkflow: &DispatchWorkflowConfig{
                Workflows:      []string{"worker"},
                TargetRepoSlug: "${{ inputs.target-repo }}",
                AllowedRepos:   []string{"${{ inputs.allowed-repos }}"},
            },
        },
    }
    var steps []string
    compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
    handlerConfig := extractHandlerConfig(t, strings.Join(steps, ""))
    dispatchWorkflow, ok := handlerConfig["dispatch_workflow"]
    require.True(t, ok)
    // AddTemplatableStringSlice emits a JSON string, not an array, for an expression
    allowedRepos := dispatchWorkflow["allowed_repos"]
    assert.Equal(t, "${{ inputs.allowed-repos }}", allowedRepos)
}

compiler := NewCompiler()

workflowData := &WorkflowData{
Name: "Test",
SafeOutputs: &SafeOutputsConfig{
DispatchWorkflow: &DispatchWorkflowConfig{
BaseSafeOutputConfig: BaseSafeOutputConfig{
GitHubToken: "${{ secrets.TEMP_USER_PAT }}",
},
Workflows: []string{"worker"},
TargetRepoSlug: "githubnext/gh-aw-side-repo",
AllowedRepos: []string{"githubnext/gh-aw-side-repo"},
},
},
}

var steps []string
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)

require.NotEmpty(t, steps)
handlerConfig := extractHandlerConfig(t, strings.Join(steps, ""))

dispatchWorkflow, ok := handlerConfig["dispatch_workflow"]
require.True(t, ok, "dispatch_workflow config should be present")

assert.Equal(t, "${{ secrets.TEMP_USER_PAT }}", dispatchWorkflow["github-token"], "github-token should be in handler config")
assert.Equal(t, "githubnext/gh-aw-side-repo", dispatchWorkflow["target-repo"], "target-repo should be in handler config")

allowedRepos, ok := dispatchWorkflow["allowed_repos"]
require.True(t, ok, "allowed_repos should be present")
assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo")
}

// TestHandlerManagerStepPerOutputTokenInHandlerConfig verifies that per-output tokens
// (e.g., add-comment.github-token) are wired into the handler config JSON (GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG)
// but NOT used as the step-level with.github-token. The step-level token follows the same
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/safe_outputs_handler_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,8 @@ var handlerRegistry = map[string]handlerBuilder{
builder := newHandlerConfigBuilder().
AddTemplatableInt("max", c.Max).
AddStringSlice("workflows", c.Workflows).
AddIfNotEmpty("target-repo", c.TargetRepoSlug)
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
AddTemplatableStringSlice("allowed_repos", c.AllowedRepos)

// Add workflow_files map if it has entries
if len(c.WorkflowFiles) > 0 {
Expand Down
Loading