From 2cda97354d574d0e0bccf5c0b1193da296bc1f19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:57:15 +0000 Subject: [PATCH 1/5] Initial plan From a4d79ac2a6215fe444f0ecfe675c2527b766b13b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:09:53 +0000 Subject: [PATCH 2/5] Cover top-level app migration and document imports.if removal Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .changeset/major-remove-imports-if.md | 21 ++++++- .changeset/minor-rename-app-to-github-app.md | 4 +- CHANGELOG.md | 16 ++++- pkg/cli/codemod_github_app.go | 30 +++++++-- pkg/cli/codemod_github_app_test.go | 25 ++++++++ pkg/cli/fix_command_test.go | 64 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 11 deletions(-) diff --git a/.changeset/major-remove-imports-if.md b/.changeset/major-remove-imports-if.md index 33b938e56e7..019ed2a6b7b 100644 --- a/.changeset/major-remove-imports-if.md +++ b/.changeset/major-remove-imports-if.md @@ -1,9 +1,26 @@ +--- "gh-aw": major +--- Remove `imports.if` support from workflow frontmatter. **⚠️ Breaking Change**: `imports:` entries no longer accept an `if` condition because conditional imports can change workflow setup and security posture at runtime. **Migration guide:** -- Keep security-relevant imports unconditional. -- For experiment-specific prompt variants, use `{{#if experiments. ...}}` with `{{#runtime-import ...}}` in the workflow body instead. +- Remove any `if:` keys from `imports:` entries; imported workflow fragments must now be unconditional. +- Keep security-relevant imports unconditional so the full workflow structure is visible at compile time. +- For experiment-specific prompt variants, move the condition into the workflow body and use `{{#if experiments. ...}}` together with `{{#runtime-import ...}}`. +- Example: + ```yaml + # Before + imports: + - uses: ./.github/workflows/shared/prompts/review.md + if: ${{ experiments.new_prompt }} + ``` + + ```md + + {{#if experiments.new_prompt}} + {{#runtime-import ./.github/workflows/shared/prompts/review.md}}{{/runtime-import}} + {{/if}} + ``` diff --git a/.changeset/minor-rename-app-to-github-app.md b/.changeset/minor-rename-app-to-github-app.md index dbaa9953c40..5987c498391 100644 --- a/.changeset/minor-rename-app-to-github-app.md +++ b/.changeset/minor-rename-app-to-github-app.md @@ -4,10 +4,10 @@ Renamed the deprecated `app:` workflow field to `github-app:` and added the codemod plus schema/Go updates to keep tooling in sync. -**⚠️ Breaking Change**: The `app:` workflow field has been renamed to `github-app:`. Workflows using `app:` will fail validation. +**⚠️ Breaking Change**: The `app:` workflow field has been renamed to `github-app:`. Workflows using top-level `app:` or nested `app:` auth blocks will fail validation. **Migration guide:** -- Replace `app:` with `github-app:` in your workflow frontmatter +- Replace `app:` with `github-app:` everywhere in your workflow frontmatter, including the top-level fallback and nested auth blocks - Example: ```yaml # Before diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2f2a4b2a1..21c83cd6279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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` diff --git a/pkg/cli/codemod_github_app.go b/pkg/cli/codemod_github_app.go index 762e211eefd..3ff174b2bff 100644 --- a/pkg/cli/codemod_github_app.go +++ b/pkg/cli/codemod_github_app.go @@ -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) { @@ -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 { @@ -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 @@ -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:") && getIndentation(line) == "" { + 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 diff --git a/pkg/cli/codemod_github_app_test.go b/pkg/cli/codemod_github_app_test.go index 63ffe143290..19e2806aa9f 100644 --- a/pkg/cli/codemod_github_app_test.go +++ b/pkg/cli/codemod_github_app_test.go @@ -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 diff --git a/pkg/cli/fix_command_test.go b/pkg/cli/fix_command_test.go index 117c48cd9ab..ec919397c03 100644 --- a/pkg/cli/fix_command_test.go +++ b/pkg/cli/fix_command_test.go @@ -942,6 +942,70 @@ This workflow has sandbox disabled. } } +func TestFixCommand_AppToGitHubAppMigration(t *testing.T) { + // Create a temporary directory for test files + tmpDir := t.TempDir() + workflowFile := filepath.Join(tmpDir, "test-workflow.md") + + content := `--- +on: + workflow_dispatch: + +engine: copilot +app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + +permissions: + contents: read +--- + +# Test Workflow + +This workflow uses top-level app auth. +` + + if err := os.WriteFile(workflowFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + appCodemod := getCodemodByID("app-to-github-app") + if appCodemod == nil { + t.Fatal("app-to-github-app codemod not found") + } + + fixed, _, err := processWorkflowFileWithInfo(workflowFile, []Codemod{*appCodemod}, true, false) + if err != nil { + t.Fatalf("Failed to process workflow file: %v", err) + } + + if !fixed { + t.Error("Expected file to be modified") + } + + updated, err := os.ReadFile(workflowFile) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + updatedStr := string(updated) + + if strings.Contains(updatedStr, "\napp:\n") { + t.Error("Expected top-level 'app:' to be removed") + } + + if !strings.Contains(updatedStr, "\ngithub-app:\n") { + t.Error("Expected top-level 'github-app:' to be added") + } + + if !strings.Contains(updatedStr, "# Test Workflow") { + t.Error("Expected markdown heading to be preserved") + } + + if !strings.Contains(updatedStr, "This workflow uses top-level app auth.") { + t.Error("Expected markdown body to be preserved") + } +} + func TestFixCommand_SandboxFalseToAgentFalseMigration_NoSandbox(t *testing.T) { // Create a temporary directory for test files tmpDir := t.TempDir() From 22c74b16673054a3d2ad5644affaa9d6d0cb4296 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:12:20 +0000 Subject: [PATCH 3/5] Use shared top-level key check in app codemod Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/codemod_github_app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/codemod_github_app.go b/pkg/cli/codemod_github_app.go index 3ff174b2bff..6057e70511f 100644 --- a/pkg/cli/codemod_github_app.go +++ b/pkg/cli/codemod_github_app.go @@ -153,7 +153,7 @@ func renameAppToGitHubApp(lines []string) ([]string, bool) { } // Rename a top-level 'app:' key. - if strings.HasPrefix(trimmed, "app:") && getIndentation(line) == "" { + if strings.HasPrefix(trimmed, "app:") && isTopLevelKey(line) { newLine, replaced := findAndReplaceInLine(line, "app", "github-app") if replaced { result = append(result, newLine) From 0bd963f66f22a3ee31b87f8bc12c645bc97237c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:58:09 +0000 Subject: [PATCH 4/5] docs(adr): add draft ADR-42794 for top-level app codemod extension Co-Authored-By: Claude Sonnet 4.6 --- ...-to-github-app-codemod-to-top-level-key.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/adr/42794-extend-app-to-github-app-codemod-to-top-level-key.md diff --git a/docs/adr/42794-extend-app-to-github-app-codemod-to-top-level-key.md b/docs/adr/42794-extend-app-to-github-app-codemod-to-top-level-key.md new file mode 100644 index 00000000000..cd9110fe284 --- /dev/null +++ b/docs/adr/42794-extend-app-to-github-app-codemod-to-top-level-key.md @@ -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.* From c4decf664b1ed8660e1cce01b364ebe78d71a81c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:38:48 +0000 Subject: [PATCH 5/5] test: add combined top-level + nested app codemod scenario coverage Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/cli/codemod_github_app_test.go | 51 +++++++++++++++++++++ pkg/cli/fix_command_test.go | 71 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/pkg/cli/codemod_github_app_test.go b/pkg/cli/codemod_github_app_test.go index 19e2806aa9f..0f2167368ca 100644 --- a/pkg/cli/codemod_github_app_test.go +++ b/pkg/cli/codemod_github_app_test.go @@ -335,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") + }) } diff --git a/pkg/cli/fix_command_test.go b/pkg/cli/fix_command_test.go index ec919397c03..934f136d0b1 100644 --- a/pkg/cli/fix_command_test.go +++ b/pkg/cli/fix_command_test.go @@ -1006,6 +1006,77 @@ This workflow uses top-level app auth. } } +func TestFixCommand_AppToGitHubAppMigration_Combined(t *testing.T) { + // Combined scenario: top-level app: and nested app: under tools.github both renamed in one pass. + tmpDir := t.TempDir() + workflowFile := filepath.Join(tmpDir, "test-workflow.md") + + content := `--- +on: + workflow_dispatch: + +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 }} + +permissions: + contents: read +--- + +# Test Workflow + +This workflow uses both top-level app auth and nested app auth. +` + + if err := os.WriteFile(workflowFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + appCodemod := getCodemodByID("app-to-github-app") + if appCodemod == nil { + t.Fatal("app-to-github-app codemod not found") + } + + fixed, _, err := processWorkflowFileWithInfo(workflowFile, []Codemod{*appCodemod}, true, false) + if err != nil { + t.Fatalf("Failed to process workflow file: %v", err) + } + + if !fixed { + t.Error("Expected file to be modified") + } + + updated, err := os.ReadFile(workflowFile) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + updatedStr := string(updated) + + if strings.Contains(updatedStr, "\napp:\n") { + t.Error("Expected all 'app:' occurrences to be removed") + } + + if strings.Count(updatedStr, "github-app:") != 2 { + t.Errorf("Expected two 'github-app:' occurrences, got %d", strings.Count(updatedStr, "github-app:")) + } + + if !strings.Contains(updatedStr, "# Test Workflow") { + t.Error("Expected markdown heading to be preserved") + } + + if !strings.Contains(updatedStr, "This workflow uses both top-level app auth and nested app auth.") { + t.Error("Expected markdown body to be preserved") + } +} + func TestFixCommand_SandboxFalseToAgentFalseMigration_NoSandbox(t *testing.T) { // Create a temporary directory for test files tmpDir := t.TempDir()