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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ADR-26903: Frontmatter Redirect Support for Workflow Updates

**Date**: 2026-04-17
**Status**: Draft
**Deciders**: pelikhan, Copilot

---

## Part 1 — Narrative (Human-Friendly)

### Context

Agentic workflows installed via `gh aw update` track their upstream source using a `source` frontmatter field. When a workflow is renamed, relocated, or replaced by a newer version in the upstream repository, consumers have no automated way to discover the new location — the next `gh aw update` continues fetching the old (potentially stale or deleted) path. The system needs a lightweight mechanism for upstream workflow authors to declare that a workflow has moved, and for the update command to transparently follow that declaration without requiring manual consumer intervention.

### Decision

We will introduce a `redirect` frontmatter field in the workflow schema that points to the new canonical location of a workflow. When `gh aw update` encounters a workflow whose upstream content declares a `redirect`, it will follow the redirect chain (up to a depth of 20), rewrite the local `source` field to the resolved destination, and disable 3-way merge for the hop to avoid spurious conflicts. A `--no-redirect` flag is added so operators who require explicit control over source changes can refuse any update that would silently relocate a workflow.

### Alternatives Considered

#### Alternative 1: Out-of-band redirect registry

Maintain a central registry (e.g., a JSON file in the upstream repo or a service endpoint) that maps old workflow paths to new ones. The update command would consult this registry before fetching. This was not chosen because it requires additional infrastructure, creates a coupling to a registry format that all upstream repos must adopt, and does not compose well with forks or private repositories.

#### Alternative 2: HTTP-style redirect at the content-hosting layer

Rely on GitHub repository redirects (e.g., redirecting the raw content URL via a GitHub redirect when a file is moved). This was not chosen because GitHub does not provide automatic content-level HTTP redirects for individual file paths within a repository, and even if it did, the redirect would lose the semantic information needed to update the `source` field in the local workflow file.

#### Alternative 3: Require manual consumer re-pinning

Document that when a workflow moves, consumers must manually run `gh aw add` with the new location and delete the old file. This was not chosen because it places the burden on every consumer rather than the upstream author, and silent staleness is a worse outcome than a transparent automated redirect.

### Consequences

#### Positive
- Upstream workflow authors can declare a move once, and all consumers transparently follow it on the next `gh aw update` run.
- The `source` field in consumer files is automatically rewritten to the resolved canonical location, keeping provenance accurate.
- Redirect loops (A → B → A) are detected and reported rather than spinning indefinitely.
- The `--no-redirect` flag gives security- or stability-conscious operators an explicit opt-out with a clear error message.
- The compiler emits an informational message when compiling a workflow that has a `redirect` configured, making the stub status visible during local development.

#### Negative
- Redirect chains are followed silently at update time; consumers may not notice that the source of their workflow has changed unless they inspect the diff.
- Disabling 3-way merge on redirect hops means local customizations to a redirected workflow will be overwritten on the first update after a redirect is followed.
- The maximum redirect depth (20) is an arbitrary constant; very long chains will fail rather than succeed.
- Adding `noRedirect bool` to the already-long parameter list of `UpdateWorkflows` / `updateWorkflow` / `RunUpdateWorkflows` increases function arity and slightly complicates call sites.

#### Neutral
- The `redirect` field is defined in the JSON schema for workflow frontmatter, so existing schema validation tooling will recognize it without special-casing.
- The `FrontmatterConfig` struct gains a `Redirect` field that is serialized/deserialized symmetrically with the existing `Source` field.
- No changes are required to lock file format or compilation output; the redirect field is used only at update time and during compiler validation messages.

---

## Part 2 — Normative Specification (RFC 2119)

> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

### Redirect Field

1. The `redirect` field **MUST** be a string when present in workflow frontmatter; non-string values **MUST** cause the update to fail with a descriptive error.
2. The `redirect` field value **MUST** be either a workflow source spec (`owner/repo/path@ref`) or a GitHub blob URL (`https://github.com/owner/repo/blob/ref/path`); other formats **MUST** be rejected with a parse error.
3. The `redirect` field **MUST NOT** point to a local path or a non-remote location; redirect targets **MUST** resolve to a remote repository slug.

### Redirect Chain Resolution

1. Implementations **MUST** resolve redirect chains iteratively, following each `redirect` field until a workflow without a redirect is reached.
2. Implementations **MUST** detect redirect loops using a visited-location set and **MUST** return an error when a previously visited location is encountered.
3. Implementations **MUST NOT** follow more than 20 redirect hops; exceeding this limit **MUST** result in an error.
4. Implementations **MUST** rewrite the local `source` field to the fully resolved final location after following a redirect chain.
5. When a redirect is followed, implementations **MUST** disable 3-way merge for that update and override the local file with the redirected content.
6. Implementations **SHOULD** emit a warning message to stderr for each redirect hop followed, naming the source and destination locations.

### `--no-redirect` Flag

1. When `--no-redirect` is specified, implementations **MUST** refuse any update where the upstream workflow declares a `redirect` field, and **MUST** return a non-zero exit code with an explanatory error message.
2. The error message **MUST** identify the workflow name, the current upstream location, and the redirect target so the operator understands what redirect was refused.
3. When `--no-redirect` is not specified, redirect following **MUST** be the default behavior.

### Compiler Behavior

1. The compiler **MUST** emit an informational message to stderr when compiling a workflow whose frontmatter contains a non-empty `redirect` field.
2. The informational message **MUST** include the redirect target value so developers are aware the compiled file is a redirect stub.
3. The presence of a `redirect` field **MUST NOT** cause compilation to fail; it is advisory metadata only.

### Conformance

An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24575079707) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
43 changes: 43 additions & 0 deletions pkg/cli/compile_safe_update_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ engine: copilot
Test workflow that uses only GITHUB_TOKEN.
`

const safeUpdateWorkflowWithRedirect = `---
name: Safe Update Redirect Test
on:
workflow_dispatch:
permissions:
contents: read
engine: copilot
redirect: owner/repo/workflows/new-location.md@main
---

# Safe Update Redirect Test

Test workflow that declares frontmatter redirect.
`

// manifestWithAPISecret is a minimal lock file content containing a gh-aw-manifest
// that pre-approves MY_API_SECRET. Writing this to the lock file path
// before compilation simulates a workflow that was previously compiled and approved.
Expand Down Expand Up @@ -154,6 +169,34 @@ func TestSafeUpdateFirstCompileCreatesBaselineForActions(t *testing.T) {
t.Logf("First compile correctly emitted warnings for new action.\nOutput:\n%s", outputStr)
}

// TestSafeUpdateFirstCompileCreatesBaselineForRedirect verifies that adding a
// frontmatter redirect is surfaced by safe update enforcement so it is reviewed.
func TestSafeUpdateFirstCompileCreatesBaselineForRedirect(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

workflowPath := filepath.Join(setup.workflowsDir, "safe-update-redirect.md")
require.NoError(t, os.WriteFile(workflowPath, []byte(safeUpdateWorkflowWithRedirect), 0o644),
"should write workflow file")

cmd := exec.Command(setup.binaryPath, "compile", workflowPath)
cmd.Env = append(os.Environ(), "GH_AW_ACTION_MODE=release")
output, err := cmd.CombinedOutput()
outputStr := string(output)

assert.NoError(t, err, "first compile should succeed (warnings don't fail the build)\nOutput:\n%s", outputStr)
assert.Contains(t, outputStr, "SECURITY REVIEW REQUIRED",
"first compile should emit safe update warnings for redirect changes")
assert.Contains(t, outputStr, "New redirect configured",
"warning should include redirect additions for security review")

lockFilePath := filepath.Join(setup.workflowsDir, "safe-update-redirect.lock.yml")
lockContent, readErr := os.ReadFile(lockFilePath)
require.NoError(t, readErr, "should read lock file after first compile")
assert.Contains(t, string(lockContent), `"redirect":"owner/repo/workflows/new-location.md@main"`,
"manifest should include redirect for future safe-update comparisons")
}

// TestSafeUpdateAllowsKnownSecretWithPriorManifest verifies that safe update
// enforcement allows a compilation when the secret is already recorded in the
// prior manifest embedded in the existing lock file.
Expand Down
11 changes: 7 additions & 4 deletions pkg/cli/update_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Examples:
` + string(constants.CLIExtensionPrefix) + ` update --force # Force update even if no changes
` + string(constants.CLIExtensionPrefix) + ` update --disable-release-bump # Update without force-bumping all action versions
` + string(constants.CLIExtensionPrefix) + ` update --no-compile # Update without regenerating lock files
` + string(constants.CLIExtensionPrefix) + ` update --no-redirect # Refuse workflows that use redirect frontmatter
` + string(constants.CLIExtensionPrefix) + ` update --dir custom/workflows # Update workflows in custom directory
` + string(constants.CLIExtensionPrefix) + ` update --create-pull-request # Update and open a pull request`,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -59,6 +60,7 @@ Examples:
noMergeFlag, _ := cmd.Flags().GetBool("no-merge")
disableReleaseBump, _ := cmd.Flags().GetBool("disable-release-bump")
noCompile, _ := cmd.Flags().GetBool("no-compile")
noRedirect, _ := cmd.Flags().GetBool("no-redirect")
createPRFlag, _ := cmd.Flags().GetBool("create-pull-request")
prFlagAlias, _ := cmd.Flags().GetBool("pr")
createPR := createPRFlag || prFlagAlias
Expand All @@ -73,7 +75,7 @@ Examples:
}
}

if err := RunUpdateWorkflows(cmd.Context(), args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag, disableReleaseBump, noCompile); err != nil {
if err := RunUpdateWorkflows(cmd.Context(), args, majorFlag, forceFlag, verbose, engineOverride, workflowDir, noStopAfter, stopAfter, noMergeFlag, disableReleaseBump, noCompile, noRedirect); err != nil {
return err
}

Expand All @@ -96,6 +98,7 @@ Examples:
cmd.Flags().Bool("no-merge", false, "Override local changes with upstream version instead of merging")
cmd.Flags().Bool("disable-release-bump", false, "Disable automatic major version bumps for all actions (only core actions/* are force-updated)")
cmd.Flags().Bool("no-compile", false, "Skip recompiling workflows (do not modify lock files)")
cmd.Flags().Bool("no-redirect", false, "Refuse updates when redirect frontmatter is present")
cmd.Flags().Bool("create-pull-request", false, "Create a pull request with the update changes")
cmd.Flags().Bool("pr", false, "Alias for --create-pull-request")
_ = cmd.Flags().MarkHidden("pr") // Hide the short alias from help output
Expand All @@ -110,12 +113,12 @@ Examples:

// RunUpdateWorkflows updates workflows from their source repositories.
// Each workflow is compiled immediately after update.
func RunUpdateWorkflows(ctx context.Context, workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool, disableReleaseBump bool, noCompile bool) error {
updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v, disableReleaseBump=%v, noCompile=%v", workflowNames, allowMajor, force, noMerge, disableReleaseBump, noCompile)
func RunUpdateWorkflows(ctx context.Context, workflowNames []string, allowMajor, force, verbose bool, engineOverride string, workflowsDir string, noStopAfter bool, stopAfter string, noMerge bool, disableReleaseBump bool, noCompile bool, noRedirect bool) error {
updateLog.Printf("Starting update process: workflows=%v, allowMajor=%v, force=%v, noMerge=%v, disableReleaseBump=%v, noCompile=%v, noRedirect=%v", workflowNames, allowMajor, force, noMerge, disableReleaseBump, noCompile, noRedirect)

var firstErr error

if err := UpdateWorkflows(ctx, workflowNames, allowMajor, force, verbose, engineOverride, workflowsDir, noStopAfter, stopAfter, noMerge, noCompile); err != nil {
if err := UpdateWorkflows(ctx, workflowNames, allowMajor, force, verbose, engineOverride, workflowsDir, noStopAfter, stopAfter, noMerge, noCompile, noRedirect); err != nil {
firstErr = fmt.Errorf("workflow update failed: %w", err)
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/update_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@ func TestRunUpdateWorkflows_NoSourceWorkflows(t *testing.T) {
os.Chdir(tmpDir)

// Running update with no source workflows should succeed with an info message, not an error
err := RunUpdateWorkflows(context.Background(), nil, false, false, false, "", "", false, "", false, false, false)
err := RunUpdateWorkflows(context.Background(), nil, false, false, false, "", "", false, "", false, false, false, false)
assert.NoError(t, err, "Should not error when no workflows with source field exist")
}

Expand All @@ -996,7 +996,7 @@ func TestRunUpdateWorkflows_SpecificWorkflowNotFound(t *testing.T) {
os.Chdir(tmpDir)

// Running update with a specific name that doesn't exist should fail
err := RunUpdateWorkflows(context.Background(), []string{"nonexistent"}, false, false, false, "", "", false, "", false, false, false)
err := RunUpdateWorkflows(context.Background(), []string{"nonexistent"}, false, false, false, "", "", false, "", false, false, false, false)
require.Error(t, err, "Should error when specified workflow not found")
assert.Contains(t, err.Error(), "no workflows found matching the specified names")
}
16 changes: 16 additions & 0 deletions pkg/cli/update_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ func TestUpdateCommand_NoMergeFlag(t *testing.T) {
assert.Contains(t, outputStr, "no workflows found", "Should report no workflows found")
}

// TestUpdateCommand_NoRedirectFlag verifies that --no-redirect flag is recognized.
func TestUpdateCommand_NoRedirectFlag(t *testing.T) {
setup := setupUpdateIntegrationTest(t)
defer setup.cleanup()

cmd := exec.Command(setup.binaryPath, "update", "--no-redirect", "--verbose")
cmd.Dir = setup.tempDir
output, err := cmd.CombinedOutput()
outputStr := string(output)

assert.NoError(t, err, "Should succeed (no source workflows = info message, not error), output: %s", outputStr)
assert.NotContains(t, outputStr, "unknown flag", "The --no-redirect flag should be recognized")
assert.Contains(t, outputStr, "no workflows found", "Should report no workflows found")
}

// TestUpdateCommand_RemovedFlags verifies that old flags are no longer accepted.
func TestUpdateCommand_RemovedFlags(t *testing.T) {
setup := setupUpdateIntegrationTest(t)
Expand Down Expand Up @@ -208,6 +223,7 @@ func TestUpdateCommand_HelpText(t *testing.T) {

// Should mention merge behavior
assert.Contains(t, outputStr, "no-merge", "Help should document --no-merge flag")
assert.Contains(t, outputStr, "no-redirect", "Help should document --no-redirect flag")
assert.Contains(t, outputStr, "3-way merge", "Help should explain merge behavior")

// Should reference upgrade for other features
Expand Down
Loading
Loading