Skip to content
Draft
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
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
91 changes: 91 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,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
}
142 changes: 142 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,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)
})
}
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
17 changes: 17 additions & 0 deletions pkg/parser/schema_location_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,23 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti
wantErr: true,
errContains: "requird",
},
{
name: "dispatch-repository key is accepted 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: false,
},
{
name: "valid workflow_call input still compiles",
frontmatter: map[string]any{
Expand Down
4 changes: 2 additions & 2 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<tool>.inputs.<id>",
path: []any{"properties", "safe-outputs", "properties", "dispatch_repository", "additionalProperties", "properties", "inputs", "additionalProperties"},
name: "safe-outputs.dispatch-repository.<tool>.inputs.<id>",
path: []any{"properties", "safe-outputs", "properties", "dispatch-repository", "additionalProperties", "properties", "inputs", "additionalProperties"},
},
}

Expand Down
6 changes: 5 additions & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -9292,6 +9292,10 @@
"additionalProperties": false
}
},
"dispatch_repository": {
"$ref": "#/properties/safe-outputs/properties/dispatch-repository",
"description": "Deprecated alias for dispatch-repository."
},
"call-workflow": {
"oneOf": [
{
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
11 changes: 5 additions & 6 deletions pkg/workflow/dispatch_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,16 @@ type DispatchRepositoryConfig struct {
Tools map[string]*DispatchRepositoryToolConfig // Map of tool name to tool config
}

// parseDispatchRepositoryConfig parses dispatch_repository configuration from the safe-outputs map.
// Accepts both "dispatch_repository" (underscore, preferred) and "dispatch-repository" (dash, alias).
// 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

// Support both underscore and dash variants
if configData, exists = outputMap["dispatch_repository"]; !exists {
if configData, exists = outputMap["dispatch-repository"]; !exists {
// 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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading