Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-42150: Make `dispatch-repository` the Canonical Safe-Output Key

**Date**: 2026-06-29
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `dispatch_repository` safe-output type allowed agents to trigger `repository_dispatch` events in external repositories. When first introduced, both the underscore form (`dispatch_repository`) and the dashed form (`dispatch-repository`) were accepted at runtime as aliases of each other. This created a mismatch between the runtime behavior and the rest of the safe-output naming convention, where all other types use hyphen-case (e.g., `dispatch-workflow`, `call-workflow`, `create-check-run`). The drift between runtime behavior, the JSON schema, and the public documentation made the contract ambiguous for both users and tooling. A codemod-based migration path allows backward compatibility to be preserved while the canonical key is established.

### Decision

We will make `dispatch-repository` (hyphen) the canonical key for this safe-output type, demote `dispatch_repository` (underscore) to a deprecated backward-compatible alias in the JSON schema and runtime parser, and ship a `safe-output-dispatch-repository-key` codemod that automatically renames existing frontmatter. All documentation, warning messages, and schema definitions are updated to reference only the dashed form. The underscore alias is preserved in the schema as a `$ref` to avoid hard-breaking existing workflows while the codemod propagates.

### Alternatives Considered

#### Alternative 1: Keep `dispatch_repository` (underscore) as canonical

The underscore key could remain primary and the dashed key could become the alias, matching Go struct field conventions. This was rejected because it contradicts the established hyphen-case naming pattern shared by every other safe-output type, and would make the public API feel inconsistent with its siblings.

#### Alternative 2: Hard removal — drop the underscore key with no alias

The underscore key could be removed entirely in a single release with no backward-compatible alias, requiring an immediate breaking migration. This was rejected because it would silently break all existing workflow files using `dispatch_repository` without any automated migration path, violating the project's convention of providing codemods for deprecations.

### Consequences

#### Positive
- Schema, runtime, documentation, and compiler warnings are now consistent: all references use `dispatch-repository`.
- Aligns with every other safe-output type (`dispatch-workflow`, `call-workflow`, `create-check-run`, etc.), reducing cognitive overhead for new users.
- The automated codemod (`safe-output-dispatch-repository-key`) lets existing users upgrade without manual edits.

#### Negative
- The JSON schema retains a `dispatch_repository` `$ref` alias entry, adding a small amount of schema complexity that must be maintained until the alias can be removed.
- Any downstream tooling or documentation outside this repository that hard-codes the underscore key will require an update.

#### Neutral
- The runtime parser lookup order is inverted: `dispatch-repository` is now checked first, then `dispatch_repository` as fallback — behavior is identical for users until the alias is eventually removed.
- Compiler warning text changes from `dispatch_repository` to `dispatch-repository`; any scripts that grep for the old warning string will need updating.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,9 @@ A recognized "magic" repository secret name used as the default fallback token f

An extension mechanism for safe outputs that enables integration with third-party services beyond built-in GitHub operations. Defined under `safe-outputs.jobs:`, custom safe outputs separate read and write operations: agents use read-only MCP tools for queries, while custom jobs execute write operations with secret access after agent completion. Supports services like Slack, Notion, Jira, or any external API. See [Custom Safe Outputs](/gh-aw/reference/custom-safe-outputs/).

### Dispatch Repository (`dispatch_repository`)
### Dispatch Repository (`dispatch-repository`)

An experimental safe output type that triggers `repository_dispatch` events in external repositories for cross-repository orchestration. Each key under `safe-outputs.dispatch_repository:` defines a named tool exposed to the agent. A tool requires a `workflow` identifier (forwarded in `client_payload` for routing), an `event_type`, and either a static `repository` slug or an `allowed_repositories` list. GitHub Actions expressions (`${{ ... }}`) are supported in repository fields and are passed through without format validation. At compile time the compiler emits a warning: `Using experimental feature: dispatch_repository`. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/#repository-dispatch-dispatch_repository).
An experimental safe output type that triggers `repository_dispatch` events in external repositories for cross-repository orchestration. Each key under `safe-outputs.dispatch-repository:` defines a named tool exposed to the agent. A tool requires a `workflow` identifier (forwarded in `client_payload` for routing), an `event_type`, and either a static `repository` slug or an `allowed_repositories` list. GitHub Actions expressions (`${{ ... }}`) are supported in repository fields and are passed through without format validation. At compile time the compiler emits a warning: `Using experimental feature: dispatch-repository`. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/#repository-dispatch-dispatch-repository).

### Safe Output Actions

Expand Down
12 changes: 6 additions & 6 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The agent requests issue creation; a separate job with `issues: write` creates i
|--------|-----|-------------|
| [Dispatch Workflow](#workflow-dispatch-dispatch-workflow) | `dispatch-workflow` | Trigger other workflows with inputs (max: 3, same-repo only) |
| [Call Workflow](#workflow-call-call-workflow) | `call-workflow` | Call reusable workflows via compile-time fan-out (max: 1, same-repo only) |
| [Dispatch Repository Event](#repository-dispatch-dispatch_repository) | `dispatch_repository` | Trigger `repository_dispatch` events in external repositories, experimental (cross-repo) |
| [Dispatch Repository Event](#repository-dispatch-dispatch-repository) | `dispatch-repository` | Trigger `repository_dispatch` events in external repositories, experimental (cross-repo) |
| [Code Scanning Alerts](#code-scanning-alerts-create-code-scanning-alert) | `create-code-scanning-alert` | Generate SARIF security advisories (max: unlimited, same-repo only) |
| [Autofix Code Scanning Alerts](#autofix-code-scanning-alerts-autofix-code-scanning-alert) | `autofix-code-scanning-alert` | Create automated fixes for code scanning alerts (max: 10, same-repo only) |
| [Create Check Run](#check-run-creation-create-check-run) | `create-check-run` | Create GitHub Check Runs to surface analysis results in the PR checks UI (default max: 1, same-repo only) |
Expand Down Expand Up @@ -1340,18 +1340,18 @@ Use `call-workflow` for deterministic fan-out where actor attribution and zero A

**Security**: Same-repo only; only allowlisted workflows can be called; compile-time validation catches misconfiguration early.

### Repository Dispatch (`dispatch_repository`)
### Repository Dispatch (`dispatch-repository`)

> [!CAUTION]
> This is an experimental feature. Compiling a workflow with `dispatch_repository` emits a warning: `Using experimental feature: dispatch_repository`. The API may change in future releases.
> This is an experimental feature. Compiling a workflow with `dispatch-repository` emits a warning: `Using experimental feature: dispatch-repository`. The API may change in future releases.

Triggers [`repository_dispatch`](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#repository_dispatch) events in external repositories. Unlike `dispatch-workflow` (same-repo only), `dispatch_repository` is designed for cross-repository orchestration.
Triggers [`repository_dispatch`](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#repository_dispatch) events in external repositories. Unlike `dispatch-workflow` (same-repo only), `dispatch-repository` is designed for cross-repository orchestration.

Each key under `dispatch_repository:` defines a named tool exposed to the agent:
Each key under `dispatch-repository:` defines a named tool exposed to the agent:

```yaml wrap
safe-outputs:
dispatch_repository:
dispatch-repository:
trigger_ci:
description: Trigger CI in another repository
workflow: ci.yml
Expand Down
109 changes: 109 additions & 0 deletions pkg/cli/codemod_safe_output_dispatch_repository_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cli

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var safeOutputDispatchRepositoryKeyCodemodLog = logger.New("cli:codemod_safe_output_dispatch_repository_key")

func getSafeOutputDispatchRepositoryKeyCodemod() Codemod {
return Codemod{
ID: "safe-output-dispatch-repository-key",
Name: "Rename safe-outputs.dispatch_repository to dispatch-repository",
Description: "Renames deprecated safe-outputs.dispatch_repository to safe-outputs.dispatch-repository.",
IntroducedIn: "1.0.65",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
if safeOutputDispatchRepositoryKeyHasBothKeys(frontmatter) {
safeOutputDispatchRepositoryKeyCodemodLog.Print("WARN: safe-outputs has both dispatch_repository and dispatch-repository; manual review needed, skipping migration")
return content, false, nil
}
if !safeOutputDispatchRepositoryKeyNeedsMigration(frontmatter) {
return content, false, nil
}

newContent, applied, err := applyFrontmatterLineTransform(content, renameSafeOutputDispatchRepositoryKey)
if applied {
safeOutputDispatchRepositoryKeyCodemodLog.Print("Renamed safe-outputs.dispatch_repository to safe-outputs.dispatch-repository")
}
return newContent, applied, err
},
}
}

func safeOutputDispatchRepositoryKeyHasBothKeys(frontmatter map[string]any) bool {
safeOutputsAny, ok := frontmatter["safe-outputs"]
if !ok {
return false
}
safeOutputsMap, ok := safeOutputsAny.(map[string]any)
if !ok {
return false
}
_, hasOld := safeOutputsMap["dispatch_repository"]
_, hasNew := safeOutputsMap["dispatch-repository"]
return hasOld && hasNew
}

func safeOutputDispatchRepositoryKeyNeedsMigration(frontmatter map[string]any) bool {
safeOutputsAny, ok := frontmatter["safe-outputs"]
if !ok {
return false
}
safeOutputsMap, ok := safeOutputsAny.(map[string]any)
if !ok {
return false
}
_, hasOld := safeOutputsMap["dispatch_repository"]
_, hasNew := safeOutputsMap["dispatch-repository"]
return hasOld && !hasNew

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.

Silent no-op when both keys coexist — no user-facing signal: When a workflow has both dispatch_repository and dispatch-repository under safe-outputs, gh aw fix will silently do nothing, leaving the user with a deprecated key that the parser ignores (since dispatch-repository takes priority at runtime).

💡 Suggested fix

At minimum emit a log that the user can see via --log-level debug (or upgrade to a warning), so they know manual cleanup is needed:

_, hasOld := safeOutputsMap["dispatch_repository"]
_, hasNew := safeOutputsMap["dispatch-repository"]
if hasOld && hasNew {
    // Both keys present; skip migration but warn
    safeOutputDispatchRepositoryKeyCodemodLog.Print(
        "WARN: safe-outputs has both dispatch_repository and dispatch-repository; manual review needed")
    return false
}
return hasOld

Without this, gh aw fix is not idempotent in user-perceptible terms: running it on a dual-key file produces no output and no change, but the deprecated key persists.

}

func renameSafeOutputDispatchRepositoryKey(lines []string) ([]string, bool) {
result := make([]string, 0, len(lines))
modified := false

inSafeOutputs := false
safeOutputsIndent := ""
safeOutputsChildIndent := ""

for i, line := range lines {
trimmed := strings.TrimSpace(line)
indent := getIndentation(line)

if !strings.HasPrefix(trimmed, "#") {
if inSafeOutputs && hasExitedBlock(line, safeOutputsIndent) {
inSafeOutputs = false
safeOutputsChildIndent = ""
}
}

if strings.HasPrefix(trimmed, "safe-outputs:") {
inSafeOutputs = true
safeOutputsIndent = indent
safeOutputsChildIndent = ""
result = append(result, line)
continue
}

if inSafeOutputs && isDescendant(indent, safeOutputsIndent) && !strings.HasPrefix(trimmed, "#") {
if safeOutputsChildIndent == "" {
safeOutputsChildIndent = indent
}
if indent == safeOutputsChildIndent && strings.HasPrefix(trimmed, "dispatch_repository:") {
newLine, replaced := findAndReplaceInLine(line, "dispatch_repository", "dispatch-repository")
if replaced {
result = append(result, newLine)
modified = true
safeOutputDispatchRepositoryKeyCodemodLog.Printf("Renamed dispatch_repository to dispatch-repository in safe-outputs on line %d", i+1)
continue
}
}
}

result = append(result, line)
}

return result, modified
}
166 changes: 166 additions & 0 deletions pkg/cli/codemod_safe_output_dispatch_repository_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSafeOutputDispatchRepositoryKeyCodemod(t *testing.T) {
codemod := getSafeOutputDispatchRepositoryKeyCodemod()

t.Run("metadata", func(t *testing.T) {
assert.Equal(t, "safe-output-dispatch-repository-key", codemod.ID)
assert.Equal(t, "Rename safe-outputs.dispatch_repository to dispatch-repository", codemod.Name)
assert.Equal(t, "Renames deprecated safe-outputs.dispatch_repository to safe-outputs.dispatch-repository.", codemod.Description)
assert.Equal(t, "1.0.65", codemod.IntroducedIn)
require.NotNil(t, codemod.Apply)
})

t.Run("renames safe-outputs dispatch_repository key", func(t *testing.T) {
content := `---
on: workflow_dispatch
safe-outputs:
dispatch_repository:
relay:
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
---

Body text.
`
frontmatter := map[string]any{
"on": "workflow_dispatch",
"safe-outputs": map[string]any{
"dispatch_repository": map[string]any{
"relay": map[string]any{
"workflow": "router.yml",
"event_type": "dispatch",
"repository": "github/gh-aw",
},
},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.True(t, applied)
assert.Contains(t, result, " dispatch-repository:")
assert.NotContains(t, result, " dispatch_repository:")
assert.Contains(t, result, "\n\nBody text.")
})

t.Run("preserves comments and indentation", func(t *testing.T) {
content := `---
safe-outputs:
# relay config
dispatch_repository: # inline comment
relay:
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
---
`
frontmatter := map[string]any{
"safe-outputs": map[string]any{
"dispatch_repository": map[string]any{
"relay": map[string]any{
"workflow": "router.yml",
"event_type": "dispatch",
"repository": "github/gh-aw",
},
},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.True(t, applied)
assert.Contains(t, result, " dispatch-repository: # inline comment")
assert.Contains(t, result, " # relay config")
})

t.Run("no-op when deprecated key absent", func(t *testing.T) {
content := `---
safe-outputs:
dispatch-repository:
relay:
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
---
`
frontmatter := map[string]any{
"safe-outputs": map[string]any{
"dispatch-repository": map[string]any{
"relay": map[string]any{
"workflow": "router.yml",
"event_type": "dispatch",
"repository": "github/gh-aw",
},
},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})

t.Run("no-op when both keys already exist", func(t *testing.T) {
content := `---
safe-outputs:
dispatch-repository:
canonical:
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
dispatch_repository:
alias:
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
---
`
frontmatter := map[string]any{
"safe-outputs": map[string]any{
"dispatch-repository": map[string]any{},
"dispatch_repository": map[string]any{},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})

t.Run("no-op when dispatch_repository appears only in a description value", func(t *testing.T) {
content := `---
safe-outputs:
some-tool:
description: "Triggers via dispatch_repository: mechanism"
workflow: router.yml
event_type: dispatch
repository: github/gh-aw
---
`
frontmatter := map[string]any{
"safe-outputs": map[string]any{
"some-tool": map[string]any{
"description": "Triggers via dispatch_repository: mechanism",
},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err)
assert.False(t, applied)
assert.Equal(t, content, result)
})
}

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 four test cases are a solid set, but there is a gap: the codemod skips migration when safe-outputs is absent from the frontmatter (handled by the early return), but there is no test for a file that has safe-outputs but dispatch_repository is not a direct child — e.g. it appears nested deeper or as a plain text value inside a description field. A false-positive rename in YAML value content would be a silent data corruption.

💡 Suggested additional test
t.Run("no-op when dispatch_repository appears only in a description value", func(t *testing.T) {
    content := `---
safe-outputs:
  some-tool:
    description: "Triggers via dispatch_repository: mechanism"
    workflow: router.yml
    event_type: dispatch
    repository: github/gh-aw
---
`
    frontmatter := map[string]any{
        "safe-outputs": map[string]any{
            "some-tool": map[string]any{
                "description": "Triggers via dispatch_repository: mechanism",
            },
        },
    }
    result, applied, err := codemod.Apply(content, frontmatter)
    require.NoError(t, err)
    assert.False(t, applied)
    assert.Equal(t, content, result)
})

@copilot please address this.

1 change: 1 addition & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func GetAllCodemods() []Codemod {
getSafeOutputRequireTitlePrefixCodemod(), // Rename deprecated safe-outputs title-prefix constraint fields
getSafeOutputMergePRConstraintsCodemod(), // Rename deprecated merge-pull-request allowed-labels/allowed-branches
getSafeOutputAddReviewerAllowlistsCodemod(), // Rename deprecated add-reviewer reviewers/team-reviewers
getSafeOutputDispatchRepositoryKeyCodemod(), // Rename deprecated safe-outputs.dispatch_repository key
getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts
getRateLimitToUserRateLimitCodemod(), // Rename rate-limit to user-rate-limit with max key migration
getEffectiveTokensToAICreditsCodemod(), // Migrate obsolete effective-token budget keys to AI credits keys
Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/fix_codemods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) {
"safe-output-title-prefix-to-required-title-prefix",
"safe-output-merge-pr-constraints",
"safe-output-add-reviewer-allowlists",
"safe-output-dispatch-repository-key",
"safe-inputs-to-mcp-scripts",
"rate-limit-to-user-rate-limit",
"effective-tokens-to-ai-credits",
Expand Down Expand Up @@ -200,6 +201,7 @@ func expectedCodemodOrder() []string {
"safe-output-title-prefix-to-required-title-prefix",
"safe-output-merge-pr-constraints",
"safe-output-add-reviewer-allowlists",
"safe-output-dispatch-repository-key",
"safe-inputs-to-mcp-scripts",
"rate-limit-to-user-rate-limit",
"effective-tokens-to-ai-credits",
Expand Down
Loading
Loading