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
21 changes: 19 additions & 2 deletions .changeset/major-remove-imports-if.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .changeset/minor-rename-app-to-github-app.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ Run `gh aw fix --write` to apply automatic updates across your repository.

### Breaking Changes

#### Remove `imports.if` from workflow frontmatter

`imports:` entries no longer accept an `if` condition. Conditional imports are
now rejected so the compiled workflow structure stays explicit and stable at
compile time.

**Migration:**
- Remove `if:` from `imports:` entries and keep imports unconditional
- For experiment-specific prompt variants, move the condition into the workflow
body and use `{{#if ...}}` with `{{#runtime-import ...}}`

#### Terminology Change: "Agent Task" → "Agent Session"

The terminology for creating Copilot coding agent work items has been updated from "agent task" to "agent session" to better reflect their purpose and avoid confusion with other task concepts.
Expand All @@ -455,10 +466,11 @@ Run `gh aw fix` to automatically update your workflow files to use the new termi
#### Replace removed `app:` with `github-app:`

The deprecated `app:` workflow frontmatter field was removed and replaced with
`github-app:`. Workflows still using `app:` will now fail validation.
`github-app:`. Workflows still using top-level `app:` or nested `app:` auth
blocks will now fail validation.

**Migration:**
- Replace `app:` with `github-app:`
- Replace `app:` with `github-app:` everywhere in workflow frontmatter
- Run `gh aw fix` to apply the codemod automatically

#### Replace `supportsLLMGateway` flag with `llmGatewayPort`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR-42794: Extend `app-to-github-app` Codemod to Cover Top-Level `app:` Key

**Date**: 2026-07-01
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `gh aw fix` command provides automated codemods to help users migrate their workflow frontmatter across breaking changes. A previous breaking change renamed the `app:` frontmatter field to `github-app:`, and a codemod was implemented to automate that rename. However, the codemod only handled `app:` when it appeared nested under `tools.github`, `safe-outputs`, or `checkout` blocks — it did not handle the top-level `app:` key. Users whose workflows used the top-level `app:` auth configuration would run `gh aw fix` and see no changes applied, then encounter validation failures when the field was rejected by the tooling. This gap was surfaced through a daily breaking-change audit.

### Decision

We will extend the `hasDeprecatedAppField` detection function and the `renameAppToGitHubApp` rewrite function in `pkg/cli/codemod_github_app.go` to also detect and rename a top-level `app:` key in workflow YAML frontmatter. The top-level check uses the existing `isTopLevelKey` helper (shared frontmatter key detection logic) and is evaluated before the nested-section checks to avoid double-processing. This preserves the existing behavior for nested locations while closing the coverage gap.

### Alternatives Considered

#### Alternative 1: Document-only migration (no codemod for top-level)

Rather than extending the codemod, we could have documented the top-level `app:` rename in the migration guide and relied on users to make the change manually. This was rejected because the entire purpose of `gh aw fix` is to automate breaking-change migrations, and leaving one common configuration location uncovered would undermine user trust in the tool and create inconsistent migration outcomes.

#### Alternative 2: Generic "rename any `app:` key" approach

An alternative was to rename every occurrence of `app:` in any position within the frontmatter, without checking whether it is at the top level or inside a specific section. This was rejected because it would be too broad: it could incorrectly rename `app:` keys that appear inside unrelated nested structures, or inside the workflow body rather than the frontmatter, leading to false positives.

### Consequences

#### Positive
- `gh aw fix` now covers all known locations of the deprecated `app:` field, giving users a fully automated migration path for this breaking change.
- Regression tests (`codemod_github_app_test.go` and `fix_command_test.go`) were added for top-level rename coverage, increasing confidence that this behavior does not regress in future changes.

#### Negative
- The `hasDeprecatedAppField` and `renameAppToGitHubApp` functions are now more complex, with an explicit ordering dependency: top-level checks run before nested-section checks.
- Future contributors adding new `app:` rename locations must understand this ordering, which is not self-documenting.

#### Neutral
- The top-level check reuses the existing `isTopLevelKey` shared helper, avoiding any new YAML parsing logic.
- Changeset and CHANGELOG documentation was updated to reflect that both top-level and nested `app:` locations are affected by the breaking change.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
30 changes: 25 additions & 5 deletions pkg/cli/codemod_github_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import (
var githubAppCodemodLog = logger.New("cli:codemod_github_app")

// getGitHubAppCodemod creates a codemod for renaming 'app:' to 'github-app:' in workflow frontmatter.
// The 'app:' field under tools.github, safe-outputs, and checkout is deprecated in favour of 'github-app:'.
// The deprecated 'app:' field can appear at the top level and under tools.github,
// safe-outputs, and checkout.
func getGitHubAppCodemod() Codemod {
return Codemod{
ID: "app-to-github-app",
Name: "Rename 'app' to 'github-app'",
Description: "Renames the deprecated 'app:' field to 'github-app:' in tools.github, safe-outputs, and checkout configurations.",
Description: "Renames the deprecated 'app:' field to 'github-app:' at the top level and in tools.github, safe-outputs, and checkout configurations.",
IntroducedIn: "0.15.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
if !hasDeprecatedAppField(frontmatter) {
Expand All @@ -29,8 +30,15 @@ func getGitHubAppCodemod() Codemod {
}
}

// hasDeprecatedAppField returns true if any of the target sections contain a deprecated 'app:' field.
// hasDeprecatedAppField returns true if the deprecated 'app:' field is present at the
// top level or in one of the supported nested sections.
func hasDeprecatedAppField(frontmatter map[string]any) bool {
// Check top-level app
if _, hasApp := frontmatter["app"]; hasApp {
githubAppCodemodLog.Print("Deprecated 'app' field found at top level")
return true
}

// Check tools.github.app
if toolsAny, hasTools := frontmatter["tools"]; hasTools {
if toolsMap, ok := toolsAny.(map[string]any); ok {
Expand Down Expand Up @@ -78,7 +86,8 @@ func hasDeprecatedAppField(frontmatter map[string]any) bool {
return false
}

// renameAppToGitHubApp renames 'app:' to 'github-app:' within tools.github, safe-outputs, and checkout blocks.
// renameAppToGitHubApp renames top-level 'app:' keys and nested 'app:' keys within
// tools.github, safe-outputs, and checkout blocks.
func renameAppToGitHubApp(lines []string) ([]string, bool) {
var result []string
modified := false
Expand Down Expand Up @@ -143,7 +152,18 @@ func renameAppToGitHubApp(lines []string) ([]string, bool) {
continue
}

// Rename 'app:' to 'github-app:' when inside a target block
// Rename a top-level 'app:' key.
if strings.HasPrefix(trimmed, "app:") && isTopLevelKey(line) {

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.

The top-level app: rename path (line 156) calls continue after replacement, so the block-state flags (inTools, inSafeOutputs, inCheckout) are never set for a top-level app: key — which is correct since app: at the top level is never a block header for those nested sections. However, the current code first tries the top-level rename before checking for block entries (tools:, safe-outputs:, checkout:). If a YAML document somehow had an app: key before a tools: key, the tool works fine — but the ordering also means that a top-level app: line is renamed without first having set any block flags, which is the expected behaviour. The logic is correct but brittle; consider a brief comment clarifying why the top-level check comes before the nested block entry detection, to aid future maintainers.

newLine, replaced := findAndReplaceInLine(line, "app", "github-app")
if replaced {
result = append(result, newLine)
modified = true
githubAppCodemodLog.Printf("Renamed top-level 'app' to 'github-app' on line %d", i+1)
continue
}
}

// Rename nested 'app:' keys when inside a target block
if strings.HasPrefix(trimmed, "app:") {
lineIndent := getIndentation(line)
shouldRename := false
Expand Down
76 changes: 76 additions & 0 deletions pkg/cli/codemod_github_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,31 @@ checkout:
assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field")
})

t.Run("renames top-level app to github-app", func(t *testing.T) {
content := `---
engine: copilot
app:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"app": map[string]any{
"app-id": "${{ vars.APP_ID }}",
"private-key": "${{ secrets.APP_PRIVATE_KEY }}",
},
}

result, modified, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Should not error when applying codemod")
assert.True(t, modified, "Should modify content")
assert.Contains(t, result, "github-app:", "Should contain github-app field")
assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain old app field")
})

t.Run("does not modify workflows without app field", func(t *testing.T) {
content := `---
engine: copilot
Expand Down Expand Up @@ -310,4 +335,55 @@ tools:
assert.Contains(t, result, "# GitHub App for token minting", "Should preserve comment")
assert.Contains(t, result, "github-app: # Use a GitHub App", "Should preserve inline comment")
})

t.Run("renames top-level app and nested app in the same document", func(t *testing.T) {
content := `---
engine: copilot
app:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
tools:
github:
mode: remote
app:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
safe-outputs:
app:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
---

# Test Workflow
`
frontmatter := map[string]any{
"engine": "copilot",
"app": map[string]any{
"app-id": "${{ vars.APP_ID }}",
"private-key": "${{ secrets.APP_PRIVATE_KEY }}",
},
"tools": map[string]any{
"github": map[string]any{
"mode": "remote",
"app": map[string]any{
"app-id": "${{ vars.APP_ID }}",
"private-key": "${{ secrets.APP_PRIVATE_KEY }}",
},
},
},
"safe-outputs": map[string]any{
"app": map[string]any{
"app-id": "${{ vars.APP_ID }}",
"private-key": "${{ secrets.APP_PRIVATE_KEY }}",
},
},
}

result, modified, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "Should not error when applying codemod")
assert.True(t, modified, "Should modify content")
assert.False(t, hasDeprecatedAppFieldInContent(result), "Should not contain any old app fields")
// Expect all three app: occurrences replaced with github-app:
assert.Equal(t, 3, strings.Count(result, "github-app:"), "Should have three github-app: occurrences")
})
}
Loading