From eedbbc066ff526a39bb7f599fdb68664f92071f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:12:01 +0000 Subject: [PATCH 1/7] Initial plan From b90ac33e7b4c97defd2ee28f4b88156cf172bf82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:24:30 +0000 Subject: [PATCH 2/7] fix: require underscore dispatch_repository safe-output key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_location_test.go | 18 ++++++++++++++++++ pkg/workflow/dispatch_repository.go | 6 +----- pkg/workflow/dispatch_repository_test.go | 7 +++---- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pkg/parser/schema_location_test.go b/pkg/parser/schema_location_test.go index 6399e641968..e63941a612a 100644 --- a/pkg/parser/schema_location_test.go +++ b/pkg/parser/schema_location_test.go @@ -321,6 +321,24 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti wantErr: true, errContains: "requird", }, + { + name: "dispatch-repository key is rejected by schema", + 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", + }, + }, + }, + }, + filePath: "/test/workflow.md", + wantErr: true, + errContains: "dispatch-repository", + }, { name: "valid workflow_call input still compiles", frontmatter: map[string]any{ diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go index 60acd1d7008..8594020e9bd 100644 --- a/pkg/workflow/dispatch_repository.go +++ b/pkg/workflow/dispatch_repository.go @@ -27,18 +27,14 @@ type DispatchRepositoryConfig struct { } // parseDispatchRepositoryConfig parses dispatch_repository configuration from the safe-outputs map. -// Accepts both "dispatch_repository" (underscore, preferred) and "dispatch-repository" (dash, alias). func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *DispatchRepositoryConfig { dispatchRepositoryLog.Print("Parsing dispatch_repository configuration") var configData any var exists bool - // Support both underscore and dash variants if configData, exists = outputMap["dispatch_repository"]; !exists { - if configData, exists = outputMap["dispatch-repository"]; !exists { - return nil - } + return nil } configMap, ok := configData.(map[string]any) diff --git a/pkg/workflow/dispatch_repository_test.go b/pkg/workflow/dispatch_repository_test.go index 299cb7ab4a7..c9bf0233902 100644 --- a/pkg/workflow/dispatch_repository_test.go +++ b/pkg/workflow/dispatch_repository_test.go @@ -90,8 +90,8 @@ func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { assert.Equal(t, strPtr("2"), notifyService.Max) } -// TestParseDispatchRepositoryConfig_DashAlias tests that "dispatch-repository" (dash) also works -func TestParseDispatchRepositoryConfig_DashAlias(t *testing.T) { +// TestParseDispatchRepositoryConfig_DashAliasRejected tests that "dispatch-repository" (dash) is rejected. +func TestParseDispatchRepositoryConfig_DashAliasRejected(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ @@ -105,8 +105,7 @@ func TestParseDispatchRepositoryConfig_DashAlias(t *testing.T) { } config := compiler.parseDispatchRepositoryConfig(outputMap) - require.NotNil(t, config, "Config should be parsed from dash form") - require.Len(t, config.Tools, 1, "Should have 1 tool") + assert.Nil(t, config, "Config should be nil for dash form key") } // TestParseDispatchRepositoryConfig_Absent tests that nil is returned when key is absent From e35808a6a3d24811993248ec76b9e124b03cbcbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:22:46 +0000 Subject: [PATCH 3/7] fix: align dispatch repository safe-output to dashed key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/glossary.md | 4 ++-- docs/src/content/docs/reference/safe-outputs.md | 12 ++++++------ pkg/parser/schema_location_test.go | 7 +++---- pkg/parser/schema_test.go | 4 ++-- pkg/parser/schemas/main_workflow_schema.json | 6 +++++- pkg/workflow/dispatch_repository.go | 9 ++++++--- pkg/workflow/dispatch_repository_test.go | 13 +++++++------ 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index c628c5cf271..d3bf7bd32a3 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -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 diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index eca08ca2c89..da4a51987f3 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -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) | @@ -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 diff --git a/pkg/parser/schema_location_test.go b/pkg/parser/schema_location_test.go index e63941a612a..8acfddddccc 100644 --- a/pkg/parser/schema_location_test.go +++ b/pkg/parser/schema_location_test.go @@ -322,7 +322,7 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti errContains: "requird", }, { - name: "dispatch-repository key is rejected by schema", + name: "dispatch-repository key is accepted by schema", frontmatter: map[string]any{ "on": "workflow_dispatch", "safe-outputs": map[string]any{ @@ -335,9 +335,8 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti }, }, }, - filePath: "/test/workflow.md", - wantErr: true, - errContains: "dispatch-repository", + filePath: "/test/workflow.md", + wantErr: false, }, { name: "valid workflow_call input still compiles", diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index ef88f72c1f5..d76961a1abb 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -957,8 +957,8 @@ func TestMainWorkflowSchema_WorkflowCallAndDispatchInputDefsDisallowUnknownKeys( path: []any{"properties", "on", "oneOf", 1, "properties", "workflow_call", "oneOf", 1, "properties", "secrets", "additionalProperties"}, }, { - name: "safe-outputs.dispatch_repository..inputs.", - path: []any{"properties", "safe-outputs", "properties", "dispatch_repository", "additionalProperties", "properties", "inputs", "additionalProperties"}, + name: "safe-outputs.dispatch-repository..inputs.", + path: []any{"properties", "safe-outputs", "properties", "dispatch-repository", "additionalProperties", "properties", "inputs", "additionalProperties"}, }, } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d4ff6273c59..f1a7d7fd3f6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9194,7 +9194,7 @@ ], "description": "Dispatch workflow_dispatch events to other workflows. Used by orchestrators to delegate work to worker workflows with controlled maximum dispatch count." }, - "dispatch_repository": { + "dispatch-repository": { "type": "object", "description": "Dispatch repository_dispatch events to external repositories. Each sub-key defines a named dispatch tool with its own event_type, target repository, input schema, and execution limits.", "additionalProperties": { @@ -9292,6 +9292,10 @@ "additionalProperties": false } }, + "dispatch_repository": { + "$ref": "#/properties/safe-outputs/properties/dispatch-repository", + "description": "Deprecated alias for dispatch-repository." + }, "call-workflow": { "oneOf": [ { diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go index 8594020e9bd..928fa5d82b6 100644 --- a/pkg/workflow/dispatch_repository.go +++ b/pkg/workflow/dispatch_repository.go @@ -26,15 +26,18 @@ type DispatchRepositoryConfig struct { Tools map[string]*DispatchRepositoryToolConfig // Map of tool name to tool config } -// parseDispatchRepositoryConfig parses dispatch_repository configuration from the safe-outputs map. +// parseDispatchRepositoryConfig parses dispatch-repository configuration from the safe-outputs map. func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *DispatchRepositoryConfig { dispatchRepositoryLog.Print("Parsing dispatch_repository configuration") var configData any var exists bool - if configData, exists = outputMap["dispatch_repository"]; !exists { - return nil + // dispatch-repository is canonical; keep underscore form as a backward-compatible alias. + if configData, exists = outputMap["dispatch-repository"]; !exists { + if configData, exists = outputMap["dispatch_repository"]; !exists { + return nil + } } configMap, ok := configData.(map[string]any) diff --git a/pkg/workflow/dispatch_repository_test.go b/pkg/workflow/dispatch_repository_test.go index c9bf0233902..ca47eb815dc 100644 --- a/pkg/workflow/dispatch_repository_test.go +++ b/pkg/workflow/dispatch_repository_test.go @@ -17,7 +17,7 @@ func TestParseDispatchRepositoryConfig_SingleTool(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch_repository": map[string]any{ + "dispatch-repository": map[string]any{ "trigger_ci": map[string]any{ "description": "Trigger CI in another repository", "workflow": "ci.yml", @@ -46,7 +46,7 @@ func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch_repository": map[string]any{ + "dispatch-repository": map[string]any{ "trigger_ci": map[string]any{ "workflow": "ci.yml", "event_type": "ci_trigger", @@ -90,12 +90,12 @@ func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { assert.Equal(t, strPtr("2"), notifyService.Max) } -// TestParseDispatchRepositoryConfig_DashAliasRejected tests that "dispatch-repository" (dash) is rejected. -func TestParseDispatchRepositoryConfig_DashAliasRejected(t *testing.T) { +// TestParseDispatchRepositoryConfig_UnderscoreAlias tests that "dispatch_repository" (underscore) remains supported. +func TestParseDispatchRepositoryConfig_UnderscoreAlias(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch-repository": map[string]any{ + "dispatch_repository": map[string]any{ "trigger_ci": map[string]any{ "workflow": "ci.yml", "event_type": "ci_trigger", @@ -105,7 +105,8 @@ func TestParseDispatchRepositoryConfig_DashAliasRejected(t *testing.T) { } config := compiler.parseDispatchRepositoryConfig(outputMap) - assert.Nil(t, config, "Config should be nil for dash form key") + require.NotNil(t, config, "Config should be parsed from underscore alias") + require.Len(t, config.Tools, 1, "Should have 1 tool") } // TestParseDispatchRepositoryConfig_Absent tests that nil is returned when key is absent From f706b8fbce326054f9a095ca910090050caa7eee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:26:16 +0000 Subject: [PATCH 4/7] chore: update dispatch repository log key wording Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dispatch_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go index 928fa5d82b6..10a1e692034 100644 --- a/pkg/workflow/dispatch_repository.go +++ b/pkg/workflow/dispatch_repository.go @@ -28,7 +28,7 @@ type DispatchRepositoryConfig struct { // parseDispatchRepositoryConfig parses dispatch-repository configuration from the safe-outputs map. func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *DispatchRepositoryConfig { - dispatchRepositoryLog.Print("Parsing dispatch_repository configuration") + dispatchRepositoryLog.Print("Parsing dispatch-repository configuration") var configData any var exists bool From 453e85c2457033aac69bdb2ee10a110ee933b781 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:30:11 +0000 Subject: [PATCH 5/7] fix: use dashed dispatch-repository experimental warning text Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/glossary.md | 2 +- docs/src/content/docs/reference/safe-outputs.md | 2 +- pkg/workflow/compiler_validators.go | 2 +- pkg/workflow/dispatch_repository_experimental_warning_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index d3bf7bd32a3..1c31bb61541 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -307,7 +307,7 @@ An extension mechanism for safe outputs that enables integration with third-part ### 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 diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index da4a51987f3..abf236ea133 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1343,7 +1343,7 @@ Use `call-workflow` for deterministic fan-out where actor attribution and zero A ### 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. diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 29fa8f21f84..a06ea9dd629 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -299,7 +299,7 @@ func (c *Compiler) emitExperimentalFeatureWarnings(workflowData *WorkflowData) { message string }{ {enabled: workflowData.RateLimit != nil, message: "Using experimental feature: rate limiting"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.DispatchRepository != nil, message: "Using experimental feature: dispatch_repository"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.DispatchRepository != nil, message: "Using experimental feature: dispatch-repository"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.MergePullRequest != nil, message: "Using experimental feature: merge-pull-request"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"}, {enabled: workflowData.EngineConfig != nil && workflowData.EngineConfig.CopilotSDK, message: "Using experimental feature: engine.copilot-sdk"}, diff --git a/pkg/workflow/dispatch_repository_experimental_warning_test.go b/pkg/workflow/dispatch_repository_experimental_warning_test.go index 665163e781f..11243f7d1e6 100644 --- a/pkg/workflow/dispatch_repository_experimental_warning_test.go +++ b/pkg/workflow/dispatch_repository_experimental_warning_test.go @@ -107,7 +107,7 @@ safe-outputs: return } - expectedMessage := "Using experimental feature: dispatch_repository" + expectedMessage := "Using experimental feature: dispatch-repository" if tt.expectWarning { if !strings.Contains(stderrOutput, expectedMessage) { From b18f3a7d8634c4e1ccf3d342aab76caee74c7154 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:43:56 +0000 Subject: [PATCH 6/7] feat: add codemod for dispatch-repository safe-output key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...mod_safe_output_dispatch_repository_key.go | 91 +++++++++++ ...afe_output_dispatch_repository_key_test.go | 142 ++++++++++++++++++ pkg/cli/fix_codemods.go | 1 + pkg/cli/fix_codemods_test.go | 2 + 4 files changed, 236 insertions(+) create mode 100644 pkg/cli/codemod_safe_output_dispatch_repository_key.go create mode 100644 pkg/cli/codemod_safe_output_dispatch_repository_key_test.go diff --git a/pkg/cli/codemod_safe_output_dispatch_repository_key.go b/pkg/cli/codemod_safe_output_dispatch_repository_key.go new file mode 100644 index 00000000000..5833909562b --- /dev/null +++ b/pkg/cli/codemod_safe_output_dispatch_repository_key.go @@ -0,0 +1,91 @@ +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.0", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + 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 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 +} + +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 +} diff --git a/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go b/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go new file mode 100644 index 00000000000..30d1931f70a --- /dev/null +++ b/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go @@ -0,0 +1,142 @@ +//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.0", 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) + }) +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 9fb0a06cbba..7225d34c9e9 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -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 diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 0fa61fe6829..43d214bb299 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -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", @@ -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", From 44063500a43e637745f4b9340b12a4fc950b9c55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:32:14 +0000 Subject: [PATCH 7/7] docs(adr): add draft ADR-42150 for dispatch-repository canonical key decision Co-Authored-By: Claude Sonnet 4.6 --- ...ch-repository-canonical-safe-output-key.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md diff --git a/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md b/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md new file mode 100644 index 00000000000..3bfe7e794c7 --- /dev/null +++ b/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md @@ -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.*