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-<user> that does not exist
  but gstack-brain-<user> 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 <aibpaem@gmail.com>
This commit is contained in:
garrytanandBrandon Pugsley committed 2026-10-03 21:32:42 +00:00
1 parent a05965fb0a
commit c612f28339
5 files changed
+177 -14

No files matched your search

+21
View File
@@ -490,6 +490,27 @@ gstack-gbrain-install # upgrade gbrain
---
## `artifacts remote: restored <url> because <owner>/gstack-artifacts-<user> does not exist and <owner>/gstack-brain-<user> 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-<user> --repo <owner>/gstack-brain-<user> --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:
+54 -11
View File
@@ -95,6 +95,34 @@ mark_done() {
echo "$step" >> "$JOURNAL"
}
# The GitHub owner of the brain repo, from the recorded remote URL
# (https://github.com/<owner>/<repo> or git@github.com:<owner>/<repo>), 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
+26 -1
View File
@@ -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-<user> anyway. When that repository does not exist but the
# old gstack-brain-<user> 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
@@ -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);
});
});
+15 -1
View File
@@ -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/);
});
});