Skip to content

Commit b621f3d

Browse files
stevensacksclaude
andcommitted
fix(release): inline literal sibling-repo paths in gaia-release pushes
repo-scope.sh's cmd_targets_foreign_repo() reads tool_input.command verbatim and cannot expand shell variables. A `git -C "$CG" push` / `git -C "$WEB" push origin main` resolves the target to the literal string $CG/$WEB, the path lookup fails, the guard fails closed (enforce), and block-main-destructive-git.sh denies the legitimate sibling push as a home-repo push to main. Only literal paths resolve. Steps 13-14 now discover the sibling path, print its canonical absolute path, and instruct the runbook to inline that literal into every sibling `git -C … push` (using a portable /abs/path/to/<repo> placeholder) rather than passing $CG/$WEB. Corrects the prior claim that a `-C "$CG"` push is recognized as foreign — only the `gh pr merge -R owner/repo` slug form (basename compare) is var-safe. Adds a bats case documenting the limitation: a literal `$CG` token classifies as NOT foreign (return 1, enforce). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d270ad7 commit b621f3d

2 files changed

Lines changed: 32 additions & 9 deletions

File tree

.claude/commands/gaia-release.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ It lives in a sibling checkout (`../create-gaia` relative to the GAIA repo root)
156156
```bash
157157
CG="$(git rev-parse --show-toplevel)/../create-gaia"
158158
[ -d "$CG/.git" ] || { echo "create-gaia checkout not found at $CG — lockstep cannot complete here"; exit 1; }
159+
CG="$(git -C "$CG" rev-parse --show-toplevel)" # canonical absolute path
160+
echo "create-gaia resolved to: $CG" # ← note this literal path; inline it (NOT $CG) into the push commands below
159161
git -C "$CG" fetch origin --quiet && git -C "$CG" checkout main --quiet && git -C "$CG" pull --ff-only origin main --quiet
160162
```
161163

@@ -164,10 +166,14 @@ Set both version sites to `<NEW_VERSION>` (no `v` in `package.json`, `v`-prefixe
164166
- `$CG/package.json``"version": "<NEW_VERSION>"`
165167
- `$CG/bin/index.js``const FALLBACK_VERSION = 'v<NEW_VERSION>';`
166168

167-
Commit on a branch, open + merge a PR, then tag. The PR-merge and main-push guards are repo-scoped (`.claude/hooks/lib/repo-scope.sh`): a `gh pr merge` / `git push` carrying a `-C "$CG"` or `cd "$CG" &&` prefix targets the sibling repo, so this repo's audit gate and main-protection do **not** fire — no manual-UI detour needed. `create-gaia` has no audit infrastructure or branch protection of its own; a plain `--merge` (not `--auto`) is correct there.
169+
Commit on a branch, open + merge a PR, then tag. The PR-merge and main-push guards are repo-scoped (`.claude/hooks/lib/repo-scope.sh`), but the two surfaces resolve the sibling differently. `gh pr merge -R gaia-react/create-gaia` is recognized as foreign by repo-**name** (basename) comparison, so this repo's audit gate does **not** fire — no manual-UI detour needed. Raw-git operations (`git -C <path>`, `cd <path> &&`) are recognized as foreign only by resolving the **filesystem path** from the raw command string: `repo-scope.sh` reads `tool_input.command` verbatim and cannot expand shell variables, so a `$CG` form fails to resolve, the guard falls back to enforcing home-repo main-protection, and a legitimate sibling push is denied. Every sibling `git -C … push` below therefore inlines the **literal absolute path** the discovery step printed, never `$CG`. `create-gaia` has no audit infrastructure or branch protection of its own; a plain `--merge` (not `--auto`) is correct there.
168170

169171
> [!important] Sibling-repo push protocol
170-
> `repo-scope.sh` uses a single-capture regex and **fails closed (enforces home-repo policy) when it sees more than one `-C` in a single command string** — git's last-wins semantics defeat a single capture. A chain like `git -C "$CG" add ...; git -C "$CG" commit ...; git -C "$CG" push origin main` therefore triggers the local main-push deny even though the push targets the sibling repo. Each `git -C "$CG" push ...` (branch push **and** tag push) must run in **its own Bash tool invocation** — one `-C` per call. Non-push `-C` chains (add, commit, fetch, checkout, pull, tag without push) are fine to combine.
172+
> `repo-scope.sh` classifies a raw-git command as foreign only when it can resolve a concrete target path from the **raw command string**. Two things defeat that, and both make the guard fail closed and deny a legitimate sibling push:
173+
> 1. **Variables don't expand.** The hook reads `tool_input.command` verbatim. `git -C "$CG" push …` resolves the target to the literal string `$CG`, the path lookup fails, and the guard enforces home-repo policy. **Inline the literal absolute path** the discovery step printed (e.g. `git -C /abs/path/to/create-gaia push …`), never `$CG`.
174+
> 2. **One `-C` per push invocation.** The guard uses a single-capture regex and fails closed when it sees more than one `-C` in a single command string (git's last-wins semantics defeat a single capture). Each push (branch push **and** tag push) runs in **its own Bash tool invocation** — one `-C`, literal path.
175+
>
176+
> Non-push `-C` chains (add, commit, fetch, checkout, pull, tag without push) keep the `$CG`/`$WEB` variable and combine freely — only pushes (and force-pushes) need the literal-path, one-`-C` treatment.
171177
172178
```bash
173179
git -C "$CG" checkout -b "release/v<NEW_VERSION>"
@@ -177,10 +183,10 @@ git -C "$CG" add package.json bin/index.js
177183
git -C "$CG" commit -m "chore: release v<NEW_VERSION>"
178184
```
179185

180-
Branch push — own Bash invocation:
186+
Branch push — own Bash invocation, **literal path inlined** (substitute the path printed by the discovery step for the placeholder; do not pass `$CG`):
181187

182188
```bash
183-
git -C "$CG" push -u origin "release/v<NEW_VERSION>"
189+
git -C /abs/path/to/create-gaia push -u origin "release/v<NEW_VERSION>"
184190
```
185191

186192
```bash
@@ -196,10 +202,10 @@ git -C "$CG" fetch origin --quiet && git -C "$CG" checkout main --quiet && git -
196202
git -C "$CG" tag "v<NEW_VERSION>"
197203
```
198204

199-
Tag push — own Bash invocation:
205+
Tag push — own Bash invocation, **literal path inlined** (same substitution as the branch push; not `$CG`):
200206

201207
```bash
202-
git -C "$CG" push origin "v<NEW_VERSION>"
208+
git -C /abs/path/to/create-gaia push origin "v<NEW_VERSION>"
203209
```
204210

205211
The tag push triggers `create-gaia`'s `publish.yml` (npm publish with `--provenance`). Confirm it before considering the release complete:
@@ -216,6 +222,8 @@ The marketing/docs site (`../website` relative to this repo) embeds three versio
216222
```bash
217223
WEB="$(git rev-parse --show-toplevel)/../website"
218224
[ -d "$WEB" ] || { echo "website checkout not found at $WEB — lockstep cannot complete here"; exit 1; }
225+
WEB="$(git -C "$WEB" rev-parse --show-toplevel)" # canonical absolute path
226+
echo "website resolved to: $WEB" # ← note this literal path; inline it (NOT $WEB) into the push below
219227
```
220228

221229
**GetStarted page** — update the `GAIA_VERSION` constant:
@@ -237,7 +245,7 @@ Example: current is `installed=1.2.0, available=1.2.2`. On `1.3.0` → `installe
237245

238246
- `$WEB/index.html``"softwareVersion": "<NEW_VERSION>",`
239247

240-
Commit and push directly to `main` in the website repo (no branch protection on `website`). Apply the sibling-repo push protocol from Step 13the `git -C "$WEB" push` must run in its own Bash tool invocation, separate from the `add`/`commit` chain:
248+
Commit and push directly to `main` in the website repo (no branch protection on `website`). Apply the sibling-repo push protocol from Step 13: the `add`/`commit` chain keeps `$WEB` and combines freely, but the `git push` runs in its **own Bash tool invocation** with the **literal path inlined**. A `git -C "$WEB" push origin main` resolves the target to the literal string `$WEB`, fails closed, and is denied as a home-repo push to `main`.
241249

242250
```bash
243251
git -C "$WEB" add src/pages/get-started/sections/GetStarted.tsx \
@@ -246,10 +254,10 @@ git -C "$WEB" add src/pages/get-started/sections/GetStarted.tsx \
246254
git -C "$WEB" commit -m "chore: lockstep GAIA v<NEW_VERSION>"
247255
```
248256

249-
Push — own Bash invocation:
257+
Push — own Bash invocation, **literal path inlined** (substitute the path printed by the discovery step for the placeholder; not `$WEB`):
250258

251259
```bash
252-
git -C "$WEB" push origin main
260+
git -C /abs/path/to/website push origin main
253261
```
254262

255263
## Recovery: I tagged on the wrong commit

.github/audit/tests/repo-scope.bats

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# 3. unquoted `git -C <sibling>` push → foreign (guard)
2525
# 4. quoted `git -C "<home>"` push → home (quote-strip safe)
2626
# 5. plain home `git push origin main` → home (enforce)
27+
# 6. literal `$CG` token `git -C "$CG"` push → home (unexpandable var, enforce)
2728

2829
setup() {
2930
THIS_DIR="$( cd "$( dirname "$BATS_TEST_FILENAME" )" && pwd )"
@@ -101,3 +102,17 @@ in_home() {
101102
run in_home "git push origin main"
102103
[ "$status" -ne 0 ]
103104
}
105+
106+
# -----------------------------------------------------------------------------
107+
# 6. A literal `$CG` token is NOT a path. repo-scope.sh reads the raw command
108+
# string and never expands shell variables, so `git -C "$CG"` resolves the
109+
# target to the literal three characters `$CG`, the git lookup fails, and the
110+
# helper fails closed (return 1 = enforce). This is why the gaia-release
111+
# runbook inlines the literal absolute path into sibling pushes rather than
112+
# passing $CG/$WEB — a $VAR form would trip the home-repo main-push deny.
113+
# -----------------------------------------------------------------------------
114+
115+
@test "literal \$CG token (unexpandable variable): home (enforce)" {
116+
run in_home 'git -C "$CG" push origin main'
117+
[ "$status" -ne 0 ]
118+
}

0 commit comments

Comments
 (0)