diff --git a/actions/setup/sh/restore_base_github_folders.sh b/actions/setup/sh/restore_base_github_folders.sh index 3c86ce0393c..315a042863f 100644 --- a/actions/setup/sh/restore_base_github_folders.sh +++ b/actions/setup/sh/restore_base_github_folders.sh @@ -24,6 +24,8 @@ set +o histexpand # (e.g. ".agents .claude .codex .gemini .github") # GH_AW_AGENT_FILES - space-separated list of root files to restore # (e.g. "AGENTS.md CLAUDE.md GEMINI.md") +# DEFAULT_BRANCH - trusted repository default branch supplied by the +# compiler for workflows with safe outputs # # Exit codes: # 0 - Success @@ -38,6 +40,19 @@ SRC="/tmp/gh-aw/base" IFS=' ' read -ra FOLDERS <<< "${GH_AW_AGENT_FOLDERS:-}" IFS=' ' read -ra ROOT_FILES <<< "${GH_AW_AGENT_FILES:-}" +# A trusted base checkout may have advanced since activation. Replacing its config +# with the old snapshot would turn concurrent base changes into agent-authored +# reverts. Only an exact commit match is sufficient; a PR head can also descend +# from the base. Missing refs (including checkout-disabled runs) retain restoration. +if [ -n "${DEFAULT_BRANCH:-}" ] && + HEAD_SHA=$(git -C "${WORKSPACE}" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) && + BASE_SHA=$(git -C "${WORKSPACE}" rev-parse --verify "refs/remotes/origin/${DEFAULT_BRANCH}^{commit}" 2>/dev/null) && + [ "${HEAD_SHA}" = "${BASE_SHA}" ]; then + echo "Keeping agent config folders from the checked-out trusted base commit ${HEAD_SHA}" + FOLDERS=() + ROOT_FILES=() +fi + for FOLDER in "${FOLDERS[@]+"${FOLDERS[@]}"}"; do SNAPSHOT="${SRC}/${FOLDER}" DEST="${WORKSPACE}/${FOLDER}" diff --git a/docs/src/content/docs/engines/copilot.md b/docs/src/content/docs/engines/copilot.md index 27f0ccf2501..61b7021c8b6 100644 --- a/docs/src/content/docs/engines/copilot.md +++ b/docs/src/content/docs/engines/copilot.md @@ -64,7 +64,7 @@ engine: gh-aw enables the CLI's `EXTENSIONS` feature flag, loads project extensions in prompt mode, and pre-approves workflow runs. SDK sessions honor the same switch through their tool catalog and permission handler, without granting additional shell or MCP access. Save reusable definitions and supporting files under `.github/extensions//extension.mjs`, and explicitly request the registered workflow by name in the prompt. Their agents remain subject to the configured tool permissions and sandbox policies. -gh-aw's existing engine-config restoration snapshots the engine-declared folders, including Copilot's `.github/`, recursively from the activation checkout and restores them after agent checkouts, including when `checkout.pull-request: false` is set. Restoring the entire configuration directory prevents PR-head extension code, settings, and hooks from replacing trusted definitions. Use `ambient-folders` for supporting files elsewhere in the repository. Dynamic workflow availability also depends on the Copilot account; enabling extensions does not make workflows available with a BYOK provider. +gh-aw's existing engine-config restoration snapshots the engine-declared folders, including Copilot's `.github/`, recursively from the activation checkout and restores them after agent checkouts, including when `checkout.pull-request: false` is set. For workflows with safe outputs, a checkout whose HEAD exactly matches the local default-branch remote-tracking ref keeps its current configuration instead, preserving base-branch updates that landed after activation. PR heads that merely descend from the base still receive snapshot restoration. Restoring the entire configuration directory prevents PR-head extension code, settings, and hooks from replacing trusted definitions. Use `ambient-folders` for supporting files elsewhere in the repository. Dynamic workflow availability also depends on the Copilot account; enabling extensions does not make workflows available with a BYOK provider. The repository's [Copilot dynamic workflow smoke test](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-copilot-dynamic-workflow.md) invokes the packaged `smoke-copilot-dynamic-workflow` extension by name. It verifies a relative module import, a nested hidden support file, durable steps, and one structured subagent result. Run it with `gh aw run smoke-copilot-dynamic-workflow`, or dispatch **Smoke Trigger** with `dynamic-workflow: true` to exercise a feature branch before the new workflow is registered on the default branch. diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index e64e63cf1d2..60638e9e031 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -149,6 +149,8 @@ The agent's commits are packaged as a **git bundle** and uploaded as an Actions If the base branch advances between agent start and `safe_outputs` apply, the PR is created slightly behind the current base — normal behavior the author can address with a rebase. If a non-fast-forward race occurs during the push itself, the job creates a fallback PR from a temporary branch so no changes are lost. +If the default branch advances between activation and the agent checkout, configuration restoration keeps the newer checkout's config folders and root instruction files when HEAD exactly matches its local default-branch remote-tracking ref. It does not overwrite them with the older activation snapshot. PR or feature-branch checkouts and checkouts without that local ref retain snapshot restoration; no additional fetch is required. + An older **patch transport** (`git format-patch` / `git am --3way`) is used when bundle data is unavailable. `--3way` resolves cleanly against an updated base when there are no conflicts; if it cannot, the patch is applied at the agent's original base commit and the PR UI shows the conflicts for manual resolution. :::caution[Shallow checkout and large monorepos] diff --git a/pkg/workflow/pr_test.go b/pkg/workflow/pr_test.go index 62fe95f9415..aa6a2acedd4 100644 --- a/pkg/workflow/pr_test.go +++ b/pkg/workflow/pr_test.go @@ -425,6 +425,104 @@ func TestCanRestoreAgentConfigFolders(t *testing.T) { } } +func TestRestoreConfigWhenBaseAdvances(t *testing.T) { + restoreScript, err := os.ReadFile(filepath.Join("..", "..", "actions", "setup", "sh", "restore_base_github_folders.sh")) + require.NoError(t, err) + for _, mode := range []string{"trusted base", "PR head", "missing base ref", "missing default branch input"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + base := filepath.Join(root, "base") + require.NoError(t, os.MkdirAll(workspace, 0755)) + git := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = workspace + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + return strings.TrimSpace(string(output)) + } + write := func(name, content string) { + t.Helper() + dest := filepath.Join(workspace, name) + require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0755)) + require.NoError(t, os.WriteFile(dest, []byte(content), 0644)) + } + git("init", "-q") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.com") + git("config", "commit.gpgsign", "false") + write(".github/workflows/publish.yml", "original workflow\n") + write(".claude/removed.md", "original config\n") + write("AGENTS.md", "original instructions\n") + write("result.txt", "before\n") + git("add", ".") + git("commit", "-qm", "Activation base A") + require.NoError(t, os.MkdirAll(filepath.Join(base, ".github", "workflows"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(base, ".claude"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(base, ".github", "workflows", "publish.yml"), []byte("original workflow\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(base, ".claude", "removed.md"), []byte("original config\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "AGENTS.md"), []byte("original instructions\n"), 0644)) + + write(".github/workflows/publish.yml", "original workflow\nconcurrent base step\n") + write(".claude/new.md", "concurrent config\n") + require.NoError(t, os.Remove(filepath.Join(workspace, ".claude", "removed.md"))) + require.NoError(t, os.Remove(filepath.Join(workspace, "AGENTS.md"))) + git("add", "-A") + git("commit", "-qm", "Concurrent base B") + baseSHA := git("rev-parse", "HEAD") + defaultBranch := "main" + if mode != "missing base ref" { + git("update-ref", "refs/remotes/origin/"+defaultBranch, baseSHA) + } + if mode == "PR head" { + // An ancestor match would incorrectly trust this PR's config. + git("checkout", "-qb", "untrusted-pr") + write(".github/workflows/publish.yml", "untrusted workflow\n") + write(".claude/injected.md", "untrusted config\n") + write("AGENTS.md", "untrusted instructions\n") + git("add", "-A") + git("commit", "-qm", "Untrusted PR") + } + write(".mcp.json", `{"mcpServers":{"untrusted":{}}}`) + scriptPath := filepath.Join(root, "restore.sh") + isolatedScript := strings.ReplaceAll(string(restoreScript), `SRC="/tmp/gh-aw/base"`, "SRC="+shellEscapeArg(base)) + require.NoError(t, os.WriteFile(scriptPath, []byte(isolatedScript), 0644)) + if mode == "missing default branch input" { + defaultBranch = "" + } + cmd := exec.Command("bash", scriptPath) + cmd.Env = append(os.Environ(), + "GITHUB_WORKSPACE="+workspace, + "GH_AW_AGENT_FOLDERS=.github .claude", + "GH_AW_AGENT_FILES=AGENTS.md", + "DEFAULT_BRANCH="+defaultBranch, + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + assert.NoFileExists(t, filepath.Join(workspace, ".mcp.json")) + if mode == "trusted base" { + assert.Contains(t, string(output), "checked-out trusted base commit") + assert.Empty(t, git("status", "--porcelain")) + write("result.txt", "agent result\n") + git("add", "-A") + git("commit", "-qm", "Agent change") + assert.Equal(t, baseSHA, git("rev-parse", "HEAD^")) + assert.Equal(t, "result.txt", git("diff", "--name-only", baseSHA, "HEAD")) + } else { + content, err := os.ReadFile(filepath.Join(workspace, ".github", "workflows", "publish.yml")) + require.NoError(t, err) + assert.Equal(t, "original workflow\n", string(content)) + assert.FileExists(t, filepath.Join(workspace, ".claude", "removed.md")) + assert.NoFileExists(t, filepath.Join(workspace, ".claude", "new.md")) + assert.NoFileExists(t, filepath.Join(workspace, ".claude", "injected.md")) + assert.FileExists(t, filepath.Join(workspace, "AGENTS.md")) + } + }) + } +} + func TestEngineConfigRestorePrecedesAgentSteps(t *testing.T) { enabled, disabled := true, false for _, engineID := range []string{"claude", "copilot"} {