From c612f283399a3016057bb999f9ba99d15ea164a3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 21:32:42 +0000 Subject: [PATCH] A8: rename the artifacts repo with OWNER/REPO, rewrite the remote only after it The v1.27.0.0 migration passed a bare repo name to gh repo rename / gh repo edit, which always fails (gh wants [HOST/]OWNER/REPO), then step 2 rewrote ~/.gstack-artifacts-remote.txt to the never-renamed repository. - Qualify both names with the owner from the recorded remote URL (else the signed-in gh user) and surface gh's error. - Step 2 moves the remote file but rewrites the URL only once the rename is confirmed; a later successful retry rewrites it then. - A pending journal now counts as state to migrate, so the #1383 retry actually re-runs step 1 (it used to see no legacy file and declare the migration done). - The v1.91.17.0 migration (placeholder name) repairs installs already hit: when the remote names gstack-artifacts- that does not exist but gstack-brain- does, it restores the old URL in the remote file and ~/.gstack's origin and says so. It only calls gh for such a remote and never fails the upgrade. Co-authored-by: Brandon Pugsley --- docs/gbrain-sync-errors.md | 21 ++++++ gstack-upgrade/migrations/v1.27.0.0.sh | 65 +++++++++++++++---- gstack-upgrade/migrations/v1.91.17.0.sh | 27 +++++++- ...stack-upgrade-migration-v1_91_17_0.test.ts | 62 +++++++++++++++++- test/migrations-v1.27.0.0.test.ts | 16 ++++- 5 files changed, 177 insertions(+), 14 deletions(-) diff --git a/docs/gbrain-sync-errors.md b/docs/gbrain-sync-errors.md index f3d4aba20..cc9451889 100644 --- a/docs/gbrain-sync-errors.md +++ b/docs/gbrain-sync-errors.md @@ -490,6 +490,27 @@ gstack-gbrain-install # upgrade gbrain --- +## `artifacts remote: restored because /gstack-artifacts- does not exist and /gstack-brain- does` + +**Problem.** Printed once during upgrade. Artifact pushes had been failing +because `~/.gstack-artifacts-remote.txt` named a repository that was never +created. + +**Cause.** The v1.27.0.0 upgrade passed a bare repo name to +`gh repo rename`, which always fails, then rewrote the remote to the new name +anyway. This upgrade points the remote (and `~/.gstack`'s `origin`) back at +the repository that exists. + +**Fix.** Nothing is required; pushes work again. To finish the rename: +```bash +gh repo rename gstack-artifacts- --repo /gstack-brain- --yes +GSTACK_MIGRATE_ASSUME_YES=1 bash ~/.claude/skills/gstack/gstack-upgrade/migrations/v1.27.0.0.sh +``` +If it instead says it `could not confirm ... exists (gh is not installed or not +signed in)`, run `gh auth login` and check the repository name on GitHub. + +--- + ## Nothing is syncing but I expect it to **Not an error, but a common gotcha.** Check in order: diff --git a/gstack-upgrade/migrations/v1.27.0.0.sh b/gstack-upgrade/migrations/v1.27.0.0.sh index 182f6b654..e74536774 100755 --- a/gstack-upgrade/migrations/v1.27.0.0.sh +++ b/gstack-upgrade/migrations/v1.27.0.0.sh @@ -95,6 +95,34 @@ mark_done() { echo "$step" >> "$JOURNAL" } +# The GitHub owner of the brain repo, from the recorded remote URL +# (https://github.com// or git@github.com:/), else +# the authenticated gh user. `gh repo rename --repo` and `gh repo edit` need +# the "[HOST/]OWNER/REPO" form; a bare name always fails (#1437). +repo_owner() { + local url="" owner="" + for f in "$OLD_REMOTE_TXT" "$NEW_REMOTE_TXT"; do + [ -f "$f" ] && url=$(head -1 "$f" 2>/dev/null) && break + done + owner=$(printf '%s\n' "$url" | sed -n 's#^.*github\.com[:/]\([^/]*\)/.*$#\1#p') + [ -z "$owner" ] && owner=$(gh api user --jq .login 2>/dev/null || true) + printf '%s' "$owner" +} + +# Point ~/.gstack-artifacts-remote.txt at the renamed repo. Only called once +# the rename is confirmed: rewriting it earlier pointed the artifacts remote +# at a repository that did not exist (#1437). +rewrite_remote_to_new() { + [ -f "$NEW_REMOTE_TXT" ] || return 0 + local url new + url=$(head -1 "$NEW_REMOTE_TXT" 2>/dev/null) + new=$(echo "$url" | sed "s|/${OLD_REPO_NAME}|/${NEW_REPO_NAME}|; s|:${OLD_REPO_NAME}|:${NEW_REPO_NAME}|") + [ "$new" = "$url" ] && return 0 + echo "$new" > "$NEW_REMOTE_TXT" + chmod 600 "$NEW_REMOTE_TXT" + echo " remote URL rewritten: $url → $new" >&2 +} + # --------------------------------------------------------------------------- # Detect environment + ask once if there's anything to migrate # --------------------------------------------------------------------------- @@ -105,6 +133,9 @@ mark_done() { HAS_LEGACY_STATE=0 [ -f "$OLD_REMOTE_TXT" ] && HAS_LEGACY_STATE=1 [ -d "$GSTACK_HOME/.git" ] && HAS_LEGACY_STATE=1 +# A journal means an earlier run left a step pending (step 2 already moved the +# remote file), so the retry must run instead of declaring nothing to do. +[ -f "$JOURNAL" ] && HAS_LEGACY_STATE=1 # If nothing to migrate, finalize silently. if [ "$HAS_LEGACY_STATE" = "0" ]; then @@ -217,19 +248,27 @@ if ! journal_done "gh_repo_renamed"; then case "$HOST" in github) if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + GH_OWNER=$(repo_owner) + OLD_QUALIFIED="${GH_OWNER:+$GH_OWNER/}${OLD_REPO_NAME}" + NEW_QUALIFIED="${GH_OWNER:+$GH_OWNER/}${NEW_REPO_NAME}" # Idempotent: if new name already exists, treat as success. - if gh repo view "$NEW_REPO_NAME" >/dev/null 2>&1; then + if gh repo view "$NEW_QUALIFIED" >/dev/null 2>&1; then echo " repo already named $NEW_REPO_NAME on GitHub — no-op" >&2 mark_done "gh_repo_renamed" + mark_done "gh_repo_rename_verified" + rewrite_remote_to_new else - if gh repo rename "$NEW_REPO_NAME" --repo "$OLD_REPO_NAME" --yes 2>/dev/null \ - || gh repo edit "$OLD_REPO_NAME" --name "$NEW_REPO_NAME" 2>/dev/null; then - echo " renamed on GitHub" >&2 + RENAME_ERR="" + if RENAME_ERR=$(gh repo rename "$NEW_REPO_NAME" --repo "$OLD_QUALIFIED" --yes 2>&1) \ + || RENAME_ERR=$(gh repo edit "$OLD_QUALIFIED" --name "$NEW_REPO_NAME" 2>&1); then + echo " renamed $OLD_QUALIFIED → $NEW_REPO_NAME on GitHub" >&2 mark_done "gh_repo_renamed" + mark_done "gh_repo_rename_verified" + rewrite_remote_to_new else - echo " WARNING: gh rename failed (repo may not exist or permission denied)" >&2 + echo " WARNING: gh rename failed for $OLD_QUALIFIED: ${RENAME_ERR:-no output}" >&2 echo " step 1 stays PENDING and will retry on re-run; later steps still run (#1383)" >&2 - echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_REPO_NAME --yes" >&2 + echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_QUALIFIED --yes" >&2 fi fi else @@ -267,14 +306,18 @@ fi if ! journal_done "remote_txt_renamed"; then echo " [v1.27.0.0] step 2: rename ~/.gstack-brain-remote.txt → ~/.gstack-artifacts-remote.txt" >&2 if [ -f "$OLD_REMOTE_TXT" ] && [ ! -f "$NEW_REMOTE_TXT" ]; then - # Update the URL inside if the rename happened on the host: replace - # gstack-brain-$USER with gstack-artifacts-$USER in the URL. + # Move the file; rewrite the URL inside only when step 1 confirmed the + # repository was renamed. Otherwise the old URL is the one that exists. OLD_URL=$(head -1 "$OLD_REMOTE_TXT" 2>/dev/null) - NEW_URL=$(echo "$OLD_URL" | sed "s|/${OLD_REPO_NAME}|/${NEW_REPO_NAME}|; s|:${OLD_REPO_NAME}|:${NEW_REPO_NAME}|") - echo "$NEW_URL" > "$NEW_REMOTE_TXT" + echo "$OLD_URL" > "$NEW_REMOTE_TXT" chmod 600 "$NEW_REMOTE_TXT" rm -f "$OLD_REMOTE_TXT" - echo " moved + URL rewritten: $OLD_URL → $NEW_URL" >&2 + if journal_done "gh_repo_rename_verified"; then + echo " moved remote file" >&2 + rewrite_remote_to_new + else + echo " moved remote file; kept $OLD_URL because the repository was not renamed" >&2 + fi elif [ -f "$NEW_REMOTE_TXT" ]; then echo " new file already exists — no-op" >&2 rm -f "$OLD_REMOTE_TXT" 2>/dev/null || true diff --git a/gstack-upgrade/migrations/v1.91.17.0.sh b/gstack-upgrade/migrations/v1.91.17.0.sh index 3cf57e85d..ed1b63096 100755 --- a/gstack-upgrade/migrations/v1.91.17.0.sh +++ b/gstack-upgrade/migrations/v1.91.17.0.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Migration: v1.91.17.0 — severe fix wave (memory ingest). +# Migration: v1.91.17.0 — severe fix wave (memory ingest, artifacts remote). # Placeholder name: the release queue may rename this file at /ship. # # Each step is independent and guarded. Idempotent and non-fatal: every path @@ -25,4 +25,29 @@ if [ -f "$GH/.transcript-ingest-state.json" ]; then fi fi +# Step 2 (A8, #1437): the v1.27.0.0 migration passed a bare repo name to +# `gh repo rename`, which always failed, then pointed the artifacts remote at +# gstack-artifacts- anyway. When that repository does not exist but the +# old gstack-brain- does, point the remote back at the old one. Only a +# remote that names a gstack-artifacts-* GitHub repo is checked. +REMOTE_TXT="${HOME:-}/.gstack-artifacts-remote.txt" +if [ -n "${HOME:-}" ] && [ -f "$REMOTE_TXT" ]; then + URL=$(head -1 "$REMOTE_TXT" 2>/dev/null | tr -d '[:space:]') + OWNER=$(printf '%s\n' "$URL" | sed -n 's#^.*github\.com[:/]\([^/]*\)/gstack-artifacts-[^/]*$#\1#p') + NEW_NAME=$(printf '%s\n' "$URL" | sed -n 's#^.*/\(gstack-artifacts-[^/]*\)$#\1#p' | sed 's/\.git$//') + if [ -n "$OWNER" ] && [ -n "$NEW_NAME" ]; then + OLD_NAME="gstack-brain-${NEW_NAME#gstack-artifacts-}" + if ! command -v gh >/dev/null 2>&1 || ! gh auth status >/dev/null 2>&1; then + echo "artifacts remote: could not confirm $OWNER/$NEW_NAME exists (gh is not installed or not signed in). If artifact pushes fail, run: gh repo rename $NEW_NAME --repo $OWNER/$OLD_NAME --yes" + elif ! gh repo view "$OWNER/$NEW_NAME" >/dev/null 2>&1 && gh repo view "$OWNER/$OLD_NAME" >/dev/null 2>&1; then + OLD_URL=$(printf '%s\n' "$URL" | sed "s#/${NEW_NAME}#/${OLD_NAME}#") + echo "$OLD_URL" > "$REMOTE_TXT" && chmod 600 "$REMOTE_TXT" + if [ "$(git -C "$GH" remote get-url origin 2>/dev/null)" = "$URL" ]; then + git -C "$GH" remote set-url origin "$OLD_URL" 2>/dev/null || true + fi + echo "artifacts remote: restored $OLD_URL because $OWNER/$NEW_NAME does not exist and $OWNER/$OLD_NAME does (an earlier upgrade rewrote it before the rename ran). To finish the rename later: gh repo rename $NEW_NAME --repo $OWNER/$OLD_NAME --yes" + fi + fi +fi + exit 0 diff --git a/test/gstack-upgrade-migration-v1_91_17_0.test.ts b/test/gstack-upgrade-migration-v1_91_17_0.test.ts index 1320c5c1c..63a1f0ba2 100644 --- a/test/gstack-upgrade-migration-v1_91_17_0.test.ts +++ b/test/gstack-upgrade-migration-v1_91_17_0.test.ts @@ -24,9 +24,13 @@ beforeEach(() => { }); afterEach(() => fs.rmSync(home, { recursive: true, force: true })); +function gitIsolation() { + return { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: path.join(home, ".gitconfig") }; +} + function run() { const r = spawnSync("bash", [MIGRATION], { - env: { PATH: `${bin}:${process.env.PATH}`, HOME: home, GSTACK_HOME: gstackHome }, + env: { PATH: `${bin}:${process.env.PATH}`, HOME: home, GSTACK_HOME: gstackHome, ...gitIsolation() }, encoding: "utf-8", cwd: home, timeout: 30_000, @@ -58,3 +62,59 @@ describe("v1.91.17.0 migration: memory reconcile pending (A1)", () => { expect(fs.existsSync(path.join(gstackHome, ".transcript-ingest-state.json"))).toBe(false); }); }); + +describe("v1.91.17.0 migration: artifacts remote repair (A8, #1437)", () => { + const NEW = "https://github.com/acme/gstack-artifacts-dev"; + const OLD = "https://github.com/acme/gstack-brain-dev"; + function gh(views: string[]) { + fs.writeFileSync(path.join(bin, "gh"), `#!/bin/sh +echo "$@" >> "${home}/gh-calls.log" +case "$1 $2" in + "auth status") exit 0 ;; + "repo view") for r in ${views.join(" ")}; do [ "$3" = "$r" ] && exit 0; done; exit 1 ;; +esac +exit 1 +`, { mode: 0o755 }); + } + const remote = () => fs.readFileSync(path.join(home, ".gstack-artifacts-remote.txt"), "utf-8").trim(); + const ghCalls = () => (fs.existsSync(path.join(home, "gh-calls.log")) ? fs.readFileSync(path.join(home, "gh-calls.log"), "utf-8") : ""); + + test("restores the old URL (file and state-root origin) when only the old repo exists", () => { + fs.writeFileSync(path.join(home, ".gstack-artifacts-remote.txt"), NEW + "\n"); + fs.mkdirSync(gstackHome, { recursive: true }); + for (const a of [["init", "-q", gstackHome], ["-C", gstackHome, "remote", "add", "origin", NEW]]) { + expect(spawnSync("git", a, { timeout: 10_000, env: { ...process.env, ...gitIsolation() } }).status).toBe(0); + } + gh(["acme/gstack-brain-dev"]); + const r = run(); + expect(r.code).toBe(0); + expect(r.stdout).toContain(`artifacts remote: restored ${OLD}`); + expect(remote()).toBe(OLD); + expect(spawnSync("git", ["-C", gstackHome, "remote", "get-url", "origin"], { encoding: "utf-8", timeout: 10_000, env: { ...process.env, ...gitIsolation() } }).stdout.trim()).toBe(OLD); + expect(run().stdout).not.toContain("restored"); + }); + + test("leaves a remote alone when the renamed repo exists", () => { + fs.writeFileSync(path.join(home, ".gstack-artifacts-remote.txt"), NEW + "\n"); + gh(["acme/gstack-artifacts-dev", "acme/gstack-brain-dev"]); + expect(run().code).toBe(0); + expect(remote()).toBe(NEW); + }); + + test("without a signed-in gh, says so and changes nothing", () => { + fs.writeFileSync(path.join(home, ".gstack-artifacts-remote.txt"), NEW + "\n"); + fs.writeFileSync(path.join(bin, "gh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + const r = run(); + expect(r.code).toBe(0); + expect(r.stdout).toContain("could not confirm acme/gstack-artifacts-dev exists"); + expect(remote()).toBe(NEW); + }); + + test("never calls gh when the remote does not name a gstack-artifacts repo", () => { + fs.writeFileSync(path.join(home, ".gstack-artifacts-remote.txt"), OLD + "\n"); + gh([]); + expect(run().code).toBe(0); + expect(ghCalls()).toBe(""); + expect(remote()).toBe(OLD); + }); +}); diff --git a/test/migrations-v1.27.0.0.test.ts b/test/migrations-v1.27.0.0.test.ts index 12c5f24b1..8e1d0dd0f 100644 --- a/test/migrations-v1.27.0.0.test.ts +++ b/test/migrations-v1.27.0.0.test.ts @@ -226,11 +226,25 @@ describe('v1.27.0.0 migration — #1383 consent + failure-stays-pending contract expect(journal).not.toContain('gh_repo_renamed'); // Later steps DID run and are journaled (independent of step 1)... expect(journal).toContain('remote_txt_renamed'); - // ...so a retry with working gh only redoes step 1 and completes. + // ...but the remote keeps naming the repository that exists (#1437): + // rewriting it here pointed the artifacts remote at a repo never renamed. + const remote = () => fs.readFileSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'), 'utf-8').trim(); + expect(remote()).toBe('https://github.com/testuser/gstack-brain-testuser'); + // A retry with working gh only redoes step 1, completes, and only then + // points the remote at the renamed repository. makeFakeGh({}); const r2 = run(); expect(r2.code).toBe(0); expect(fs.existsSync(path.join(tmpHome, '.gstack/.migrations/v1.27.0.0.done'))).toBe(true); + expect(remote()).toBe('https://github.com/testuser/gstack-artifacts-testuser'); + }); + + test('A8 (#1437): gh rename and edit get the OWNER/REPO form gh requires', () => { + run(); + const ghLog = fs.readFileSync(path.join(fakeBinDir, 'gh-calls.log'), 'utf-8'); + expect(ghLog).toContain('gh repo view testuser/gstack-artifacts-testuser'); + expect(ghLog).toMatch(/gh repo rename gstack-artifacts-testuser --repo testuser\/gstack-brain-testuser --yes/); + expect(ghLog).not.toMatch(/--repo gstack-brain-testuser\b/); }); });