From 19e0e439d0ac450627b8c7addad2f7d501bd75c2 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:18:01 -0700 Subject: [PATCH 1/8] feat(org-migration): add the move list and the read-only snapshot script move-list.txt is the checked-in source of truth (31 repos; cleanroom targets nightowlstudiollc). snapshot.sh records owner, default branch, visibility, archived, topics, pages, secret names, branch protection, and rulesets per repo, looking every repo up through repos/smartwatermelon/ so the same path works before the rename, after it (redirect), and after the transfer. The test stubs gh on PATH and unsets BASH_ENV: this machine's profile defines a gh shell function there, and a function beats PATH, so without that the stub is bypassed and the suite hits the network. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/lib.sh | 31 +++++++ scripts/org-migration/move-list.txt | 35 ++++++++ scripts/org-migration/snapshot.sh | 65 ++++++++++++++ scripts/org-migration/tests/run-tests.sh | 11 +++ scripts/org-migration/tests/test-snapshot.sh | 90 ++++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100755 scripts/org-migration/lib.sh create mode 100644 scripts/org-migration/move-list.txt create mode 100755 scripts/org-migration/snapshot.sh create mode 100755 scripts/org-migration/tests/run-tests.sh create mode 100755 scripts/org-migration/tests/test-snapshot.sh diff --git a/scripts/org-migration/lib.sh b/scripts/org-migration/lib.sh new file mode 100755 index 0000000..59fd4bf --- /dev/null +++ b/scripts/org-migration/lib.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Shared helpers for the 2026-09 org migration scripts. Sourced, not run. +# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md + +# Every lookup goes through repos/smartwatermelon/. Before the rename +# that is the real path; after the rename GitHub redirects it to +# twistedmelonman/; after the transfer it is the real path again (or a +# redirect to nightowlstudiollc/cleanroom). One path, every state. +OM_LOOKUP_OWNER="smartwatermelon" + +# Print "repo target" per non-comment line. Fail on a line that is not +# exactly two tab-separated fields. +om_read_move_list() { + local file="$1" line repo target extra n=0 + while IFS= read -r line || [[ -n "${line}" ]]; do + n=$((n + 1)) + [[ -z "${line}" || "${line}" == \#* ]] && continue + IFS=$'\t' read -r repo target extra <<<"${line}" + if [[ -z "${repo}" || -z "${target}" || -n "${extra}" || "${repo}" == *" "* ]]; then + echo "move-list: line ${n} is not 'repotarget': ${line}" >&2 + return 1 + fi + printf '%s %s\n' "${repo}" "${target}" + done <"${file}" +} + +# Print "login type" for the repo's current owner. Non-zero on any failure. +om_lookup_owner() { + local repo="$1" + gh api "repos/${OM_LOOKUP_OWNER}/${repo}" --jq '.owner.login + " " + .owner.type' +} diff --git a/scripts/org-migration/move-list.txt b/scripts/org-migration/move-list.txt new file mode 100644 index 0000000..e086894 --- /dev/null +++ b/scripts/org-migration/move-list.txt @@ -0,0 +1,35 @@ +# repotarget-org — reviewed by hand in the PR that added it. +# Source of truth for transfer.sh; never derived at run time. +# github-workflows goes first, alone (design Step 3); transfer.sh --only. +# cleanroom is the one repo that targets nightowlstudiollc. +.github smartwatermelon +archive-resolver smartwatermelon +claude-code-workflows-agents smartwatermelon +claude-config smartwatermelon +claude-config-backup smartwatermelon +claude-wrapper smartwatermelon +cleanroom nightowlstudiollc +crazy-larry smartwatermelon +dev-env smartwatermelon +dotfiles smartwatermelon +dumbify smartwatermelon +github-workflows smartwatermelon +gmail-newsletter-filter smartwatermelon +homebrew-tap smartwatermelon +huddle-transcribe smartwatermelon +lock-sync smartwatermelon +mac-dev-server-setup smartwatermelon +mac-server-setup smartwatermelon +personify smartwatermelon +pr-review smartwatermelon +projectinsomnia smartwatermelon +qwen-sidebar smartwatermelon +repo-template smartwatermelon +scripts smartwatermelon +slack-mcp smartwatermelon +smartwatermelon-marketplace smartwatermelon +spokane-snow smartwatermelon +superpowers smartwatermelon +superpowers-marketplace smartwatermelon +swift-progress-indicator smartwatermelon +x-thread-reader smartwatermelon diff --git a/scripts/org-migration/snapshot.sh b/scripts/org-migration/snapshot.sh new file mode 100755 index 0000000..4a66bb4 --- /dev/null +++ b/scripts/org-migration/snapshot.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Snapshot the settings of every repo on the move list, one JSON per repo. +# Read-only. Usage: snapshot.sh +# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md +set -uo pipefail +unset CDPATH +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/org-migration/lib.sh +source "${HERE}/lib.sh" + +if [[ $# -ne 2 ]]; then + echo "usage: snapshot.sh " >&2 + exit 2 +fi +list="$1" +outdir="$2" +mkdir -p "${outdir}" + +# Fetch a sub-resource; print `null` when GitHub answers 404 (not configured). +_optional() { + local out + if out="$(gh api "$1" 2>/dev/null)"; then + printf '%s\n' "${out}" + else + echo null + fi +} + +pairs="$(om_read_move_list "${list}")" || exit 1 +failed=0 +while read -r repo _target; do + base="repos/${OM_LOOKUP_OWNER}/${repo}" + if ! core="$(gh api "${base}")"; then + echo "snapshot: FAILED to read ${repo}" >&2 + failed=$((failed + 1)) + continue + fi + default_branch="$(jq -r '.default_branch' <<<"${core}")" + topics="$(gh api "${base}/topics" --jq '.names' 2>/dev/null || echo '[]')" + secrets="$(gh api "${base}/actions/secrets" --jq '[.secrets[].name] | sort' 2>/dev/null || echo '[]')" + protection="$(_optional "${base}/branches/${default_branch}/protection")" + rulesets="$(_optional "${base}/rulesets")" + pages="$(_optional "${base}/pages")" + jq -n \ + --arg repo "${repo}" \ + --argjson core "${core}" \ + --argjson topics "${topics}" \ + --argjson secrets "${secrets}" \ + --argjson protection "${protection}" \ + --argjson rulesets "${rulesets}" \ + --argjson pages "${pages}" \ + '{repo: $repo, + owner: {login: $core.owner.login, type: $core.owner.type}, + default_branch: $core.default_branch, + visibility: $core.visibility, + archived: $core.archived, + topics: $topics, pages: $pages, secrets: $secrets, + protection: $protection, rulesets: $rulesets}' >"${outdir}/${repo}.json" + echo "snapshot: ${repo} -> ${outdir}/${repo}.json" +done <<<"${pairs}" + +if [[ "${failed}" -gt 0 ]]; then + echo "snapshot: ${failed} repo(s) failed" >&2 + exit 1 +fi diff --git a/scripts/org-migration/tests/run-tests.sh b/scripts/org-migration/tests/run-tests.sh new file mode 100755 index 0000000..5431346 --- /dev/null +++ b/scripts/org-migration/tests/run-tests.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run every test-*.sh in this directory; exit non-zero if any fails. +set -uo pipefail +unset CDPATH +dir="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fail=0 +for t in "${dir}"/test-*.sh; do + echo "== ${t##*/}" + if ! bash "${t}"; then fail=1; fi +done +exit "${fail}" diff --git a/scripts/org-migration/tests/test-snapshot.sh b/scripts/org-migration/tests/test-snapshot.sh new file mode 100755 index 0000000..a0a995f --- /dev/null +++ b/scripts/org-migration/tests/test-snapshot.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# snapshot.sh must write one JSON per repo with the documented shape, and +# must exit non-zero, naming the repo, when a repo cannot be read. +set -uo pipefail +unset CDPATH +# Hermetic: bash sources BASH_ENV in every non-interactive shell, and this +# machine's profile defines a `gh` shell function there. A function beats +# PATH, so without this the stub below is bypassed and the real gh runs +# against the network. Unset it (and GH_TOKEN/GH_HOST) for the whole test. +unset BASH_ENV GH_TOKEN GH_HOST GITHUB_TOKEN +export HOME="/tmp/om-snapshot-home-$$" +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SNAPSHOT="${HERE}/../snapshot.sh" +WORK="/tmp/om-snapshot-test-$$" +mkdir -p "${WORK}/bin" "${WORK}/out" "${HOME}" +trap 'rm -rf "${WORK}" "${HOME}"' EXIT +fail=0 +_pass() { echo " PASS: $1"; } +_fail() { echo " FAIL: $1" >&2; fail=1; } + +# Stub gh: answers `gh api [--jq ]` from canned responses and +# honors --jq by piping through the real jq, as gh does. +cat >"${WORK}/bin/gh" <<'STUB' +#!/usr/bin/env bash +path="$2" +jqexpr="" +shift 2 +while [[ $# -gt 0 ]]; do + case "$1" in + --jq) jqexpr="$2"; shift 2 ;; + *) shift ;; + esac +done +emit() { if [[ -n "${jqexpr}" ]]; then jq -c "${jqexpr}"; else cat; fi; } +case "${path}" in + repos/smartwatermelon/dotfiles) + echo '{"name":"dotfiles","owner":{"login":"smartwatermelon","type":"User"},"default_branch":"main","visibility":"public","archived":false,"has_pages":false}' | emit ;; + repos/smartwatermelon/dotfiles/topics) echo '{"names":["bash","dotfiles"]}' | emit ;; + repos/smartwatermelon/dotfiles/actions/secrets) echo '{"secrets":[{"name":"CLAUDE_CODE_OAUTH_TOKEN"},{"name":"ANOTHER"}]}' | emit ;; + repos/smartwatermelon/dotfiles/branches/main/protection) echo '{"message":"Branch not protected"}' >&2; exit 1 ;; + repos/smartwatermelon/dotfiles/rulesets) echo '[{"id":1,"name":"main"}]' | emit ;; + repos/smartwatermelon/dotfiles/pages) echo '{"message":"Not Found"}' >&2; exit 1 ;; + repos/smartwatermelon/ghost*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + *) echo "stub: unexpected path ${path}" >&2; exit 2 ;; +esac +STUB +chmod +x "${WORK}/bin/gh" + +printf 'dotfiles\tsmartwatermelon\n' >"${WORK}/list-good" +printf '# comment\ndotfiles\tsmartwatermelon\nghost\tsmartwatermelon\n' >"${WORK}/list-bad" + +# Case 1: good list -> JSON with the documented shape. +if PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-good" "${WORK}/out"; then + _pass "good list: exit 0" +else + _fail "good list: expected exit 0" +fi +f="${WORK}/out/dotfiles.json" +if [[ -f "${f}" ]] && jq -e '.repo=="dotfiles" and .owner.login=="smartwatermelon" and .owner.type=="User" and .default_branch=="main" and .visibility=="public" and .archived==false and .topics==["bash","dotfiles"] and .secrets==["ANOTHER","CLAUDE_CODE_OAUTH_TOKEN"] and .protection==null and (.rulesets|length)==1 and .pages==null' "${f}" >/dev/null; then + _pass "good list: JSON shape" +else + got="$(cat "${f}" 2>/dev/null)" || got="" + _fail "good list: JSON shape wrong: ${got}" +fi + +# Case 2 (known-bad): a repo that 404s must fail non-zero and name the repo, +# after still writing the good one. +rm -rf "${WORK}/out" && mkdir -p "${WORK}/out" +err="$(PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-bad" "${WORK}/out" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -ne 0 && "${err}" == *ghost* ]]; then + _pass "missing repo: non-zero and names ghost" +else + _fail "missing repo: expected non-zero naming ghost, got rc=${rc} err=${err}" +fi +if [[ -f "${WORK}/out/dotfiles.json" ]]; then + _pass "missing repo: the good repo was still snapshotted" +else + _fail "missing repo: good repo skipped" +fi + +# Case 3: malformed line rejected before any API call. +printf 'dotfiles smartwatermelon\n' >"${WORK}/list-malformed" +if PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-malformed" "${WORK}/out" 2>/dev/null; then + _fail "malformed line: should fail" +else + _pass "malformed line: rejected" +fi + +exit "${fail}" From 1d5519be2100b18fd663303b4bcc60c1fe018489 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:23:18 -0700 Subject: [PATCH 2/8] docs(org-migration): correct the move-list header on row ordering The header claimed "github-workflows goes first, alone", but the rows are alphabetized and github-workflows sits mid-file. A checked-in source of truth must not contradict itself. Rows stay alphabetical and order still carries no meaning; transfer.sh selects by name. The comment now says so and points at the actual mechanism, `transfer.sh --only github-workflows`, rather than implying a magic first-row dependency. Comments only: the 31 data rows are unchanged. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/move-list.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/org-migration/move-list.txt b/scripts/org-migration/move-list.txt index e086894..f92ac0c 100644 --- a/scripts/org-migration/move-list.txt +++ b/scripts/org-migration/move-list.txt @@ -1,6 +1,8 @@ # repotarget-org — reviewed by hand in the PR that added it. # Source of truth for transfer.sh; never derived at run time. -# github-workflows goes first, alone (design Step 3); transfer.sh --only. +# Rows are alphabetical; order carries no meaning. github-workflows is +# transferred first and alone via `transfer.sh --only github-workflows` +# (design Step 3). # cleanroom is the one repo that targets nightowlstudiollc. .github smartwatermelon archive-resolver smartwatermelon From c9732fe4bdb0f6f80091bcebd4a4e951f3d8e174 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:31:01 -0700 Subject: [PATCH 3/8] fix(org-migration): only map an HTTP 404 to null in the snapshot _optional treated any gh failure as "not configured", so a transient error or an expired token recorded protection: null. verify.sh compares the after-snapshot against the baseline, so that null would read as expected state and a real loss of branch protection could pass verification silently. Now a 404 still means absent; every other failure marks the repo's snapshot as failed through the path snapshot.sh already uses, with gh's message on stderr. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/snapshot.sh | 30 +++++++++++++----- scripts/org-migration/tests/test-snapshot.sh | 33 ++++++++++++++++++-- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/scripts/org-migration/snapshot.sh b/scripts/org-migration/snapshot.sh index 4a66bb4..2f96988 100755 --- a/scripts/org-migration/snapshot.sh +++ b/scripts/org-migration/snapshot.sh @@ -16,14 +16,26 @@ list="$1" outdir="$2" mkdir -p "${outdir}" -# Fetch a sub-resource; print `null` when GitHub answers 404 (not configured). +# Fetch a sub-resource. A 404 means "not configured" and prints `null`. Any +# other failure — a transient error, an expired token — is a failure, not an +# absence: mapping it to `null` would let verify.sh read it as expected state. +# Non-zero on such a failure, with gh's message on stderr. _optional() { - local out - if out="$(gh api "$1" 2>/dev/null)"; then + local out err why rc=0 + err="$(mktemp)" || return 1 + # Cleanup on every exit path, including an early return. + trap 'rm -f "${err}"' RETURN + out="$(gh api "$1" 2>"${err}")" || rc=$? + if [[ "${rc}" -eq 0 ]]; then printf '%s\n' "${out}" - else + elif grep -q '(HTTP 404)' "${err}"; then echo null + rc=0 + else + why="$(tr '\n' ' ' <"${err}" || true)" + printf 'snapshot: %s: %s\n' "$1" "${why}" >&2 fi + return "${rc}" } pairs="$(om_read_move_list "${list}")" || exit 1 @@ -38,9 +50,13 @@ while read -r repo _target; do default_branch="$(jq -r '.default_branch' <<<"${core}")" topics="$(gh api "${base}/topics" --jq '.names' 2>/dev/null || echo '[]')" secrets="$(gh api "${base}/actions/secrets" --jq '[.secrets[].name] | sort' 2>/dev/null || echo '[]')" - protection="$(_optional "${base}/branches/${default_branch}/protection")" - rulesets="$(_optional "${base}/rulesets")" - pages="$(_optional "${base}/pages")" + if ! protection="$(_optional "${base}/branches/${default_branch}/protection")" || + ! rulesets="$(_optional "${base}/rulesets")" || + ! pages="$(_optional "${base}/pages")"; then + echo "snapshot: FAILED to read ${repo}" >&2 + failed=$((failed + 1)) + continue + fi jq -n \ --arg repo "${repo}" \ --argjson core "${core}" \ diff --git a/scripts/org-migration/tests/test-snapshot.sh b/scripts/org-migration/tests/test-snapshot.sh index a0a995f..0c224bd 100755 --- a/scripts/org-migration/tests/test-snapshot.sh +++ b/scripts/org-migration/tests/test-snapshot.sh @@ -37,9 +37,17 @@ case "${path}" in echo '{"name":"dotfiles","owner":{"login":"smartwatermelon","type":"User"},"default_branch":"main","visibility":"public","archived":false,"has_pages":false}' | emit ;; repos/smartwatermelon/dotfiles/topics) echo '{"names":["bash","dotfiles"]}' | emit ;; repos/smartwatermelon/dotfiles/actions/secrets) echo '{"secrets":[{"name":"CLAUDE_CODE_OAUTH_TOKEN"},{"name":"ANOTHER"}]}' | emit ;; - repos/smartwatermelon/dotfiles/branches/main/protection) echo '{"message":"Branch not protected"}' >&2; exit 1 ;; + repos/smartwatermelon/dotfiles/branches/main/protection) echo 'gh: Branch not protected (HTTP 404)' >&2; exit 1 ;; repos/smartwatermelon/dotfiles/rulesets) echo '[{"id":1,"name":"main"}]' | emit ;; - repos/smartwatermelon/dotfiles/pages) echo '{"message":"Not Found"}' >&2; exit 1 ;; + repos/smartwatermelon/dotfiles/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + # flaky: the core repo reads fine, but a sub-resource fails with something + # that is NOT a 404 — a transient error or an expired token. + repos/smartwatermelon/flaky) echo '{"name":"flaky","owner":{"login":"smartwatermelon","type":"User"},"default_branch":"main","visibility":"public","archived":false}' | emit ;; + repos/smartwatermelon/flaky/topics) echo '{"names":[]}' | emit ;; + repos/smartwatermelon/flaky/actions/secrets) echo '{"secrets":[]}' | emit ;; + repos/smartwatermelon/flaky/rulesets) echo '[]' | emit ;; + repos/smartwatermelon/flaky/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + repos/smartwatermelon/flaky/branches/main/protection) echo 'gh: Bad credentials (HTTP 401)' >&2; exit 1 ;; repos/smartwatermelon/ghost*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; *) echo "stub: unexpected path ${path}" >&2; exit 2 ;; esac @@ -79,7 +87,26 @@ else _fail "missing repo: good repo skipped" fi -# Case 3: malformed line rejected before any API call. +# Case 3: a sub-resource that fails with something other than 404 must fail +# the snapshot. Recording `null` there would make verify.sh read a transient +# error or an expired token as "no branch protection configured". +rm -rf "${WORK}/out" && mkdir -p "${WORK}/out" +printf 'flaky\tsmartwatermelon\n' >"${WORK}/list-flaky" +err="$(PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-flaky" "${WORK}/out" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -ne 0 && "${err}" == *flaky* ]]; then + _pass "non-404 sub-resource failure: non-zero and names flaky" +else + _fail "non-404 sub-resource failure: expected non-zero naming flaky, got rc=${rc} err=${err}" +fi +if [[ ! -f "${WORK}/out/flaky.json" ]]; then + _pass "non-404 sub-resource failure: no snapshot written" +else + wrote="$(cat "${WORK}/out/flaky.json" || true)" + _fail "non-404 sub-resource failure: wrote a snapshot anyway: ${wrote}" +fi + +# Case 4: malformed line rejected before any API call. printf 'dotfiles smartwatermelon\n' >"${WORK}/list-malformed" if PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-malformed" "${WORK}/out" 2>/dev/null; then _fail "malformed line: should fail" From 0722283d0d93a58cc7609e7f67758a77efdd7b42 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:32:29 -0700 Subject: [PATCH 4/8] feat(org-migration): add the idempotent transfer script and the verifier transfer.sh POSTs /repos/{owner}/{repo}/transfer for each move-list repo not already under its target org, polls until it resolves as an Organization, reports and skips failures, and supports --only and --dry-run. Re-running is safe: a repo already under its target is skipped, so a partial run is resumed by running it again. verify.sh re-snapshots into a second directory, allows owner as the only diff against the baseline, checks each owner is the target org, and ls-remotes every local clone whose origin still points at smartwatermelon, so a broken redirect is caught before it bites. Both tests are hermetic: they stub gh on PATH, unset BASH_ENV (this machine's profile defines a gh shell function that would otherwise beat the stub and hit the network), and sandbox HOME. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/tests/test-transfer.sh | 124 +++++++++++++++++++ scripts/org-migration/tests/test-verify.sh | 110 ++++++++++++++++ scripts/org-migration/transfer.sh | 100 +++++++++++++++ scripts/org-migration/verify.sh | 80 ++++++++++++ 4 files changed, 414 insertions(+) create mode 100755 scripts/org-migration/tests/test-transfer.sh create mode 100755 scripts/org-migration/tests/test-verify.sh create mode 100755 scripts/org-migration/transfer.sh create mode 100755 scripts/org-migration/verify.sh diff --git a/scripts/org-migration/tests/test-transfer.sh b/scripts/org-migration/tests/test-transfer.sh new file mode 100755 index 0000000..e3f40af --- /dev/null +++ b/scripts/org-migration/tests/test-transfer.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# transfer.sh: idempotent, one POST per repo that needs it, keeps going after +# a failure, exit 1 if anything failed, --dry-run makes no POST. +set -uo pipefail +unset CDPATH +# Hermetic: bash sources BASH_ENV in every non-interactive shell, and this +# machine's profile defines a `gh` shell function there. A function beats +# PATH, so without this the stub below is bypassed and the real gh runs +# against the network. Unset it (and the token/host vars) for the whole test. +unset BASH_ENV GH_TOKEN GH_HOST GITHUB_TOKEN +export HOME="/tmp/om-transfer-home-$$" +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRANSFER="${HERE}/../transfer.sh" +WORK="/tmp/om-transfer-test-$$" +mkdir -p "${WORK}/bin" "${WORK}/state" "${HOME}" +trap 'rm -rf "${WORK}" "${HOME}"' EXIT +fail=0 +_pass() { echo " PASS: $1"; } +_fail() { echo " FAIL: $1" >&2; fail=1; } + +# Stub gh with per-repo state files: / holds "login type". +# A POST .../transfer flips the state to " Organization" unless the +# repo is named "stuck", which never flips. Every POST is logged. GET answers +# are JSON piped through the real jq when --jq is given, as gh does. +cat >"${WORK}/bin/gh" <<'STUB' +#!/usr/bin/env bash +STATE="${OM_TEST_STATE:?}" +args=("$@") +path=""; method="GET"; new_owner=""; jqexpr="" +i=0 +while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + api) ;; + -X) i=$((i + 1)); method="${args[$i]}" ;; + -f) i=$((i + 1)); [[ "${args[$i]}" == new_owner=* ]] && new_owner="${args[$i]#new_owner=}" ;; + --jq) i=$((i + 1)); jqexpr="${args[$i]}" ;; + -*) ;; + *) [[ -z "${path}" ]] && path="${args[$i]}" ;; + esac + i=$((i + 1)) +done +if [[ "${method}" == "POST" && "${path}" == */transfer ]]; then + repo="${path#repos/*/}"; repo="${repo%/transfer}" + echo "POST ${path} new_owner=${new_owner}" >>"${STATE}/posts" + if [[ "${repo}" != "stuck" ]]; then + echo "${new_owner} Organization" >"${STATE}/${repo}" + fi + echo '{}' + exit 0 +fi +repo="${path#repos/*/}" +if [[ -f "${STATE}/${repo}" ]]; then + read -r login type <"${STATE}/${repo}" + # repos/smartwatermelon/ always resolves (the redirect path). A lookup + # under any other owner resolves only once that owner actually has it. + if [[ "${path}" == "repos/smartwatermelon/${repo}" || "${path}" == "repos/${login}/${repo}" ]]; then + json="$(printf '{"name":"%s","owner":{"login":"%s","type":"%s"}}' "${repo}" "${login}" "${type}")" + if [[ -n "${jqexpr}" ]]; then jq -r "${jqexpr}" <<<"${json}"; else echo "${json}"; fi + exit 0 + fi +fi +echo "gh: Not Found (HTTP 404)" >&2 +exit 1 +STUB +chmod +x "${WORK}/bin/gh" + +export OM_TEST_STATE="${WORK}/state" +echo "twistedmelonman User" >"${WORK}/state/alpha" +echo "twistedmelonman User" >"${WORK}/state/beta" +echo "nightowlstudiollc Organization" >"${WORK}/state/done" +echo "twistedmelonman User" >"${WORK}/state/stuck" +printf 'alpha\tsmartwatermelon\ndone\tnightowlstudiollc\nbeta\tsmartwatermelon\n' >"${WORK}/list" +printf 'stuck\tsmartwatermelon\nbeta\tsmartwatermelon\n' >"${WORK}/list-stuck" + +run() { PATH="${WORK}/bin:${PATH}" OM_POLL_SECONDS=0 OM_POLL_MAX=2 bash "${TRANSFER}" "$@"; } +# Read the POST log into ${log} (empty when no POST was made) and the number +# of POST lines into ${n}. +read_posts() { + log="$(cat "${WORK}/state/posts" 2>/dev/null || true)" + n=0 + [[ -n "${log}" ]] && n="$(grep -c '^POST' <<<"${log}" || true)" +} + +# Case 1: dry run makes no POST. +run "${WORK}/list" --dry-run >/dev/null 2>&1 +if [[ ! -f "${WORK}/state/posts" ]]; then _pass "dry-run: no POST"; else _fail "dry-run: POSTed"; fi + +# Case 2: real run POSTs alpha and beta, skips done, exits 0. +if run "${WORK}/list" >/dev/null 2>&1; then _pass "run: exit 0"; else _fail "run: expected exit 0"; fi +read_posts +if [[ "${n}" -eq 2 && "${log}" == *"POST repos/twistedmelonman/alpha/transfer new_owner=smartwatermelon"* && "${log}" == *"POST repos/twistedmelonman/beta/transfer new_owner=smartwatermelon"* && "${log}" != *"/done/"* ]]; then + _pass "run: exactly alpha and beta POSTed, done skipped" +else + _fail "run: wrong POSTs: ${log}" +fi + +# Case 3: idempotent second run makes no new POST. +run "${WORK}/list" >/dev/null 2>&1 +read_posts +if [[ "${n}" -eq 2 ]]; then _pass "rerun: no new POST"; else _fail "rerun: POSTed again: ${log}"; fi + +# Case 4: a repo that never resolves under the target is reported, the loop +# continues to beta, and the exit code is 1. +echo "twistedmelonman User" >"${WORK}/state/beta" +rm -f "${WORK}/state/posts" +err="$(run "${WORK}/list-stuck" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *stuck* ]]; then _pass "stuck: reported, exit 1"; else _fail "stuck: rc=${rc} err=${err}"; fi +read_posts +if [[ "${log}" == *"/beta/transfer"* ]]; then _pass "stuck: loop continued to beta"; else _fail "stuck: beta not attempted"; fi + +# Case 5: --only restricts to one repo. +echo "twistedmelonman User" >"${WORK}/state/alpha" +echo "twistedmelonman User" >"${WORK}/state/beta" +rm -f "${WORK}/state/posts" +run "${WORK}/list" --only beta >/dev/null 2>&1 +read_posts +if [[ "${log}" == "POST repos/twistedmelonman/beta/transfer new_owner=smartwatermelon" ]]; then + _pass "--only: exactly beta POSTed" +else + _fail "--only: got ${log}" +fi + +exit "${fail}" diff --git a/scripts/org-migration/tests/test-verify.sh b/scripts/org-migration/tests/test-verify.sh new file mode 100755 index 0000000..d418907 --- /dev/null +++ b/scripts/org-migration/tests/test-verify.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# verify.sh: owner must be the only change between baseline and after; owner +# must be the target org; every smartwatermelon clone must still ls-remote. +set -uo pipefail +unset CDPATH +# Hermetic: bash sources BASH_ENV in every non-interactive shell, and this +# machine's profile defines a `gh` shell function there. A function beats +# PATH, so without this the stub below is bypassed and the real gh runs +# against the network. Unset it (and the token/host vars) for the whole test. +unset BASH_ENV GH_TOKEN GH_HOST GITHUB_TOKEN +export HOME="/tmp/om-verify-home-$$" +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${HERE}/../verify.sh" +WORK="/tmp/om-verify-test-$$" +mkdir -p "${WORK}/bin" "${WORK}/base" "${WORK}/clones" "${HOME}" +trap 'rm -rf "${WORK}" "${HOME}"' EXIT +fail=0 +_pass() { echo " PASS: $1"; } +_fail() { echo " FAIL: $1" >&2; fail=1; } + +# verify.sh calls snapshot.sh by path, so stub gh (not snapshot.sh): it serves +# whatever JSON the test puts in ${OM_TEST_CORE}/.json for +# repos/smartwatermelon/, and honors --jq through the real jq. +cat >"${WORK}/bin/gh" <<'STUB' +#!/usr/bin/env bash +path="$2" +jqexpr="" +shift 2 +while [[ $# -gt 0 ]]; do + case "$1" in + --jq) jqexpr="$2"; shift 2 ;; + *) shift ;; + esac +done +emit() { if [[ -n "${jqexpr}" ]]; then jq -c "${jqexpr}"; else cat; fi; } +repo="${path#repos/smartwatermelon/}" +case "${path}" in + repos/smartwatermelon/*/topics) echo '{"names":[]}' | emit ;; + repos/smartwatermelon/*/actions/secrets) echo '{"secrets":[]}' | emit ;; + repos/smartwatermelon/*/branches/*/protection | repos/smartwatermelon/*/pages) + echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + repos/smartwatermelon/*/rulesets) echo '[]' | emit ;; + repos/smartwatermelon/*) emit <"${OM_TEST_CORE:?}/${repo}.json" ;; + *) exit 1 ;; +esac +STUB +chmod +x "${WORK}/bin/gh" +# git stub: ls-remote succeeds unless the remote names "broken". +cat >"${WORK}/bin/git" <<'STUB' +#!/usr/bin/env bash +if [[ "$*" == *"remote get-url origin"* ]]; then + dir="$2"; cat "${dir}/.git/ORIGIN"; exit 0 +fi +if [[ "$*" == *"ls-remote"* ]]; then + dir="$2" + if grep -q broken "${dir}/.git/ORIGIN"; then exit 128; fi + exit 0 +fi +exit 0 +STUB +chmod +x "${WORK}/bin/git" + +mk_core() { # repo login type visibility + printf '{"name":"%s","owner":{"login":"%s","type":"%s"},"default_branch":"main","visibility":"%s","archived":false}\n' "$1" "$2" "$3" "$4" +} +printf 'alpha\tsmartwatermelon\ncleanroom\tnightowlstudiollc\n' >"${WORK}/list" + +# Baseline: both under the user. +mkdir -p "${WORK}/core-base" +mk_core alpha smartwatermelon User public >"${WORK}/core-base/alpha.json" +mk_core cleanroom smartwatermelon User private >"${WORK}/core-base/cleanroom.json" +OM_TEST_CORE="${WORK}/core-base" PATH="${WORK}/bin:${PATH}" bash "${HERE}/../snapshot.sh" "${WORK}/list" "${WORK}/base" >/dev/null + +# Clones: one good smartwatermelon remote, one unrelated, one broken. +for c in good other broken; do mkdir -p "${WORK}/clones/${c}/.git"; done +echo "git@github.com:smartwatermelon/alpha.git" >"${WORK}/clones/good/.git/ORIGIN" +echo "git@github.com:someoneelse/thing.git" >"${WORK}/clones/other/.git/ORIGIN" +echo "git@github.com:smartwatermelon/broken.git" >"${WORK}/clones/broken/.git/ORIGIN" + +run() { OM_TEST_CORE="$1" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" "${WORK}/list" "${WORK}/base" "${WORK}/after" --clones "$2"; } + +# Case 1: owner-only change, all clones fine -> exit 0. +mkdir -p "${WORK}/core-good" "${WORK}/clones-good" +mk_core alpha smartwatermelon Organization public >"${WORK}/core-good/alpha.json" +mk_core cleanroom nightowlstudiollc Organization private >"${WORK}/core-good/cleanroom.json" +cp -R "${WORK}/clones/good" "${WORK}/clones/other" "${WORK}/clones-good/" +if run "${WORK}/core-good" "${WORK}/clones-good" >/dev/null 2>&1; then _pass "owner-only diff: exit 0"; else _fail "owner-only diff: expected exit 0"; fi + +# Case 2: visibility changed too -> exit 1 naming the repo and the field. +mkdir -p "${WORK}/core-drift" +cp "${WORK}/core-good/alpha.json" "${WORK}/core-drift/" +mk_core cleanroom nightowlstudiollc Organization public >"${WORK}/core-drift/cleanroom.json" +err="$(run "${WORK}/core-drift" "${WORK}/clones-good" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *cleanroom* && "${err}" == *visibility* ]]; then _pass "drift: exit 1 names cleanroom and the field"; else _fail "drift: rc=${rc} err=${err}"; fi + +# Case 3: still a User -> exit 1. +mkdir -p "${WORK}/core-user" +cp "${WORK}/core-good/cleanroom.json" "${WORK}/core-user/" +mk_core alpha twistedmelonman User public >"${WORK}/core-user/alpha.json" +err="$(run "${WORK}/core-user" "${WORK}/clones-good" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *alpha* ]]; then _pass "not transferred: exit 1 names alpha"; else _fail "not transferred: rc=${rc} err=${err}"; fi + +# Case 4: a broken smartwatermelon clone -> exit 1; unrelated clone ignored. +err="$(run "${WORK}/core-good" "${WORK}/clones" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *broken* && "${err}" != *other* ]]; then _pass "clones: broken reported, other ignored"; else _fail "clones: rc=${rc} err=${err}"; fi + +exit "${fail}" diff --git a/scripts/org-migration/transfer.sh b/scripts/org-migration/transfer.sh new file mode 100755 index 0000000..f560ec9 --- /dev/null +++ b/scripts/org-migration/transfer.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Transfer every repo on the move list to its target org. Idempotent: a repo +# already owned by its target is skipped. A failed transfer is reported and +# the loop continues; exit 1 at the end if any failed. +# Usage: transfer.sh [--only ] [--dry-run] +# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md, Steps 3-4. +set -uo pipefail +unset CDPATH +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/org-migration/lib.sh +source "${HERE}/lib.sh" + +# Poll cadence; the tests shrink both. +OM_POLL_SECONDS="${OM_POLL_SECONDS:-2}" +OM_POLL_MAX="${OM_POLL_MAX:-30}" + +list="" +only="" +dry_run=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --only) + only="${2:?--only needs a repo}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + -*) + echo "transfer: unknown flag $1" >&2 + exit 2 + ;; + *) + list="$1" + shift + ;; + esac +done +if [[ -z "${list}" ]]; then + echo "usage: transfer.sh [--only ] [--dry-run]" >&2 + exit 2 +fi + +pairs="$(om_read_move_list "${list}")" || exit 1 +failed=0 +seen_only=0 +while read -r repo target; do + if [[ -n "${only}" && "${repo}" != "${only}" ]]; then + continue + fi + seen_only=1 + if ! current="$(om_lookup_owner "${repo}")"; then + echo "transfer: ${repo}: cannot resolve current owner; skipping" >&2 + failed=$((failed + 1)) + continue + fi + login="${current% *}" + type="${current#* }" + if [[ "${login,,}" == "${target,,}" && "${type}" == "Organization" ]]; then + echo "transfer: ${repo}: already under ${target}; skip" + continue + fi + if [[ "${dry_run}" -eq 1 ]]; then + echo "transfer: DRY RUN would POST repos/${login}/${repo}/transfer new_owner=${target}" + continue + fi + echo "transfer: ${repo}: ${login} (${type}) -> ${target}" + if ! gh api -X POST "repos/${login}/${repo}/transfer" -f "new_owner=${target}" >/dev/null; then + echo "transfer: ${repo}: POST failed; skipping" >&2 + failed=$((failed + 1)) + continue + fi + ok=0 + n=0 + while [[ "${n}" -lt "${OM_POLL_MAX}" ]]; do + now_type="$(gh api "repos/${target}/${repo}" --jq '.owner.type' 2>/dev/null || true)" + if [[ "${now_type}" == "Organization" ]]; then + ok=1 + break + fi + n=$((n + 1)) + sleep "${OM_POLL_SECONDS}" + done + if [[ "${ok}" -eq 1 ]]; then + echo "transfer: ${repo}: now ${target}/${repo} (Organization)" + else + echo "transfer: ${repo}: did not resolve under ${target} after ${OM_POLL_MAX} polls" >&2 + failed=$((failed + 1)) + fi +done <<<"${pairs}" + +if [[ -n "${only}" && "${seen_only}" -eq 0 ]]; then + echo "transfer: --only ${only}: not on the move list" >&2 + exit 1 +fi +if [[ "${failed}" -gt 0 ]]; then + echo "transfer: ${failed} repo(s) failed; re-run to retry (completed repos are skipped)" >&2 + exit 1 +fi diff --git a/scripts/org-migration/verify.sh b/scripts/org-migration/verify.sh new file mode 100755 index 0000000..ef96fa3 --- /dev/null +++ b/scripts/org-migration/verify.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Post-transfer verification: re-snapshot, diff against the baseline with +# owner as the only permitted change, confirm each owner is the target org, +# and ls-remote every local smartwatermelon clone. Exit 1 on any failure, +# after checking everything. +# Usage: verify.sh [--clones ] +# Design: docs/superpowers/specs/2026-09-03-org-migration-design.md, Step 4. +set -uo pipefail +unset CDPATH +HERE="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/org-migration/lib.sh +source "${HERE}/lib.sh" + +clones="${HOME}/Developer" +positional=() +while [[ $# -gt 0 ]]; do + case "$1" in + --clones) + clones="${2:?--clones needs a directory}" + shift 2 + ;; + *) + positional+=("$1") + shift + ;; + esac +done +if [[ "${#positional[@]}" -ne 3 ]]; then + echo "usage: verify.sh [--clones ]" >&2 + exit 2 +fi +list="${positional[0]}" +base="${positional[1]}" +after="${positional[2]}" +fail=0 + +bash "${HERE}/snapshot.sh" "${list}" "${after}" >/dev/null || fail=1 + +pairs="$(om_read_move_list "${list}")" || exit 1 +while read -r repo target; do + b="${base}/${repo}.json" + a="${after}/${repo}.json" + if [[ ! -f "${b}" || ! -f "${a}" ]]; then + echo "verify: ${repo}: missing snapshot (baseline=${b} after=${a})" >&2 + fail=1 + continue + fi + # Every top-level field except owner must be byte-identical. + changed="$(jq -r -n --slurpfile b "${b}" --slurpfile a "${a}" \ + '($b[0] | del(.owner)) as $x | ($a[0] | del(.owner)) as $y + | [($x + $y | keys[]) | select($x[.] != $y[.])] | join(",")' || true)" + if [[ -n "${changed}" ]]; then + echo "verify: ${repo}: fields changed besides owner: ${changed}" >&2 + fail=1 + fi + login="$(jq -r '.owner.login' "${a}" || true)" + type="$(jq -r '.owner.type' "${a}" || true)" + if [[ "${login,,}" != "${target,,}" || "${type}" != "Organization" ]]; then + echo "verify: ${repo}: owner is ${login} (${type}), expected ${target} (Organization)" >&2 + fail=1 + else + echo "verify: ${repo}: ok" + fi +done <<<"${pairs}" + +# Every local clone whose origin is smartwatermelon/* must still resolve. +for gitdir in "${clones}"/*/.git; do + [[ -e "${gitdir}" ]] || continue + dir="${gitdir%/.git}" + url="$(git -C "${dir}" remote get-url origin 2>/dev/null || true)" + [[ "${url}" =~ github\.com[:/]smartwatermelon/ ]] || continue + if git -C "${dir}" ls-remote --exit-code origin HEAD >/dev/null 2>&1; then + echo "verify: clone ${dir##*/}: ls-remote ok" + else + echo "verify: clone ${dir##*/}: ls-remote FAILED (${url})" >&2 + fail=1 + fi +done + +exit "${fail}" From 8c79aa61132f9f995b54ba6284bd505f2f770027 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:39:01 -0700 Subject: [PATCH 5/8] fix(org-migration): harden verify.sh state handling and the owner parse Three defects found in review. verify.sh compared whatever the after-dir held. If it already held JSON from an earlier run and this run's snapshot failed for some repos, the loop compared stale files and printed "verify: : ok" beside a snapshot error. It now refuses a non-empty after-dir, and exits on a failed snapshot before the comparison loop rather than after it. transfer.sh split the owner lookup with ${current% *} / ${current#* }, which silently yields an empty login on unexpected gh output -- the test now shows the old code POSTing repos//malformed/transfer. It parses with read and fails the repo unless the result is exactly two space-free fields. ~/Developer is not flat: clients/ and netlify/crazy-larry sit one level deeper, so the clone scan missed them entirely. It now scans both depths and names each clone relative to the clones root so a nested one is unambiguous. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/tests/test-transfer.sh | 22 ++++++- scripts/org-migration/tests/test-verify.sh | 65 +++++++++++++++++++- scripts/org-migration/transfer.sh | 10 ++- scripts/org-migration/verify.sh | 25 ++++++-- 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/scripts/org-migration/tests/test-transfer.sh b/scripts/org-migration/tests/test-transfer.sh index e3f40af..73369ab 100755 --- a/scripts/org-migration/tests/test-transfer.sh +++ b/scripts/org-migration/tests/test-transfer.sh @@ -49,6 +49,13 @@ if [[ "${method}" == "POST" && "${path}" == */transfer ]]; then exit 0 fi repo="${path#repos/*/}" +# "malformed" answers a lookup with a single field, standing in for any +# unexpected gh output (an empty owner, a truncated body). +if [[ "${repo}" == "malformed" ]]; then + json='{"name":"malformed","owner":{"login":"","type":""}}' + if [[ -n "${jqexpr}" ]]; then jq -r "${jqexpr}" <<<"${json}"; else echo "${json}"; fi + exit 0 +fi if [[ -f "${STATE}/${repo}" ]]; then read -r login type <"${STATE}/${repo}" # repos/smartwatermelon/ always resolves (the redirect path). A lookup @@ -109,7 +116,20 @@ if [[ "${rc}" -eq 1 && "${err}" == *stuck* ]]; then _pass "stuck: reported, exit read_posts if [[ "${log}" == *"/beta/transfer"* ]]; then _pass "stuck: loop continued to beta"; else _fail "stuck: beta not attempted"; fi -# Case 5: --only restricts to one repo. +# Case 5: an unexpected lookup result must fail that repo, not POST garbage. +# The old split produced an empty login and POSTed repos//malformed/transfer. +rm -f "${WORK}/state/posts" +printf 'malformed\tsmartwatermelon\n' >"${WORK}/list-malformed" +err="$(run "${WORK}/list-malformed" 2>&1 >/dev/null)" +rc=$? +read_posts +if [[ "${rc}" -eq 1 && "${err}" == *malformed* && -z "${log}" ]]; then + _pass "malformed lookup: reported, exit 1, no POST" +else + _fail "malformed lookup: rc=${rc} err=${err} posts=${log}" +fi + +# Case 6: --only restricts to one repo. echo "twistedmelonman User" >"${WORK}/state/alpha" echo "twistedmelonman User" >"${WORK}/state/beta" rm -f "${WORK}/state/posts" diff --git a/scripts/org-migration/tests/test-verify.sh b/scripts/org-migration/tests/test-verify.sh index d418907..41161cc 100755 --- a/scripts/org-migration/tests/test-verify.sh +++ b/scripts/org-migration/tests/test-verify.sh @@ -40,7 +40,11 @@ case "${path}" in repos/smartwatermelon/*/branches/*/protection | repos/smartwatermelon/*/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; repos/smartwatermelon/*/rulesets) echo '[]' | emit ;; - repos/smartwatermelon/*) emit <"${OM_TEST_CORE:?}/${repo}.json" ;; + repos/smartwatermelon/*) + if [[ ! -f "${OM_TEST_CORE:?}/${repo}.json" ]]; then + echo 'gh: Not Found (HTTP 404)' >&2; exit 1 + fi + emit <"${OM_TEST_CORE}/${repo}.json" ;; *) exit 1 ;; esac STUB @@ -77,7 +81,16 @@ echo "git@github.com:smartwatermelon/alpha.git" >"${WORK}/clones/good/.git/ORIGI echo "git@github.com:someoneelse/thing.git" >"${WORK}/clones/other/.git/ORIGIN" echo "git@github.com:smartwatermelon/broken.git" >"${WORK}/clones/broken/.git/ORIGIN" -run() { OM_TEST_CORE="$1" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" "${WORK}/list" "${WORK}/base" "${WORK}/after" --clones "$2"; } +# verify.sh refuses a non-empty after-dir, so every run gets a fresh one. run() +# is often called inside $(...), where an incremented counter would not survive +# the subshell, so derive the directory from a mktemp -d instead. +run() { + local out + out="$(mktemp -d "${WORK}/after-XXXXXX")" + rmdir "${out}" + OM_TEST_CORE="$1" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" \ + "${WORK}/list" "${WORK}/base" "${out}" --clones "$2" +} # Case 1: owner-only change, all clones fine -> exit 0. mkdir -p "${WORK}/core-good" "${WORK}/clones-good" @@ -107,4 +120,52 @@ err="$(run "${WORK}/core-good" "${WORK}/clones" 2>&1 >/dev/null)" rc=$? if [[ "${rc}" -eq 1 && "${err}" == *broken* && "${err}" != *other* ]]; then _pass "clones: broken reported, other ignored"; else _fail "clones: rc=${rc} err=${err}"; fi +# Case 5: a non-empty after-dir is refused. Otherwise a stale JSON from an +# earlier run would be compared as if this run had just written it. +mkdir -p "${WORK}/after-stale" +cp "${WORK}/base/alpha.json" "${WORK}/after-stale/alpha.json" +err="$(OM_TEST_CORE="${WORK}/core-good" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" \ + "${WORK}/list" "${WORK}/base" "${WORK}/after-stale" --clones "${WORK}/clones-good" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *after-stale* ]]; then + _pass "stale after-dir: refused, exit 1" +else + _fail "stale after-dir: rc=${rc} err=${err}" +fi + +# Case 6: when the snapshot fails, stop before the comparison loop -- never +# print a per-repo ok line based on files this run did not write. +printf 'alpha\tsmartwatermelon\nghost\tsmartwatermelon\n' >"${WORK}/list-ghost" +out="$(OM_TEST_CORE="${WORK}/core-good" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" \ + "${WORK}/list-ghost" "${WORK}/base" "${WORK}/after-ghost" --clones "${WORK}/clones-good" 2>&1)" +rc=$? +if [[ "${rc}" -eq 1 && "${out}" != *": ok"* ]]; then + _pass "snapshot failure: exit 1 with no per-repo ok line" +else + _fail "snapshot failure: rc=${rc} out=${out}" +fi + +# Case 7: ~/Developer is not flat -- clients/ and netlify/crazy-larry sit +# one level deeper, so a nested clone must be checked too. +mkdir -p "${WORK}/clones-nested/netlify/crazy-larry/.git" +echo "git@github.com:smartwatermelon/crazy-larry.git" >"${WORK}/clones-nested/netlify/crazy-larry/.git/ORIGIN" +out="$(run "${WORK}/core-good" "${WORK}/clones-nested" 2>&1)" +rc=$? +if [[ "${rc}" -eq 0 && "${out}" == *crazy-larry* ]]; then + _pass "nested clone: checked one level deeper" +else + _fail "nested clone: rc=${rc} out=${out}" +fi + +# Case 8: a broken nested clone fails the run. +mkdir -p "${WORK}/clones-nested-bad/clients/broken/.git" +echo "git@github.com:smartwatermelon/broken.git" >"${WORK}/clones-nested-bad/clients/broken/.git/ORIGIN" +err="$(run "${WORK}/core-good" "${WORK}/clones-nested-bad" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *broken* ]]; then + _pass "nested clone: broken one reported, exit 1" +else + _fail "nested clone broken: rc=${rc} err=${err}" +fi + exit "${fail}" diff --git a/scripts/org-migration/transfer.sh b/scripts/org-migration/transfer.sh index f560ec9..e4bcb17 100755 --- a/scripts/org-migration/transfer.sh +++ b/scripts/org-migration/transfer.sh @@ -55,8 +55,14 @@ while read -r repo target; do failed=$((failed + 1)) continue fi - login="${current% *}" - type="${current#* }" + # "login type", exactly two space-free fields. Anything else is unexpected + # gh output; treat it as a lookup failure rather than POSTing garbage. + read -r login type <<<"${current}" + if [[ ! "${current}" =~ ^[^[:space:]]+[[:space:]][^[:space:]]+$ || -z "${login}" || -z "${type}" ]]; then + echo "transfer: ${repo}: unexpected owner lookup result '${current}'; skipping" >&2 + failed=$((failed + 1)) + continue + fi if [[ "${login,,}" == "${target,,}" && "${type}" == "Organization" ]]; then echo "transfer: ${repo}: already under ${target}; skip" continue diff --git a/scripts/org-migration/verify.sh b/scripts/org-migration/verify.sh index ef96fa3..16bd164 100755 --- a/scripts/org-migration/verify.sh +++ b/scripts/org-migration/verify.sh @@ -34,7 +34,20 @@ base="${positional[1]}" after="${positional[2]}" fail=0 -bash "${HERE}/snapshot.sh" "${list}" "${after}" >/dev/null || fail=1 +# The after-dir must be ours alone. A leftover JSON from an earlier run would +# be compared as though this run had just written it, so a repo whose snapshot +# failed now could still be reported ok from stale state. +if [[ -e "${after}" ]] && [[ -n "$(ls -A "${after}" 2>/dev/null || true)" ]]; then + echo "verify: after-dir ${after} is not empty; use a fresh directory" >&2 + exit 1 +fi + +# A failed snapshot means the after-dir is incomplete. Stop here: comparing a +# partial snapshot would print per-repo ok lines that describe nothing. +if ! bash "${HERE}/snapshot.sh" "${list}" "${after}" >/dev/null; then + echo "verify: snapshot failed; not comparing against the baseline" >&2 + exit 1 +fi pairs="$(om_read_move_list "${list}")" || exit 1 while read -r repo target; do @@ -64,15 +77,19 @@ while read -r repo target; do done <<<"${pairs}" # Every local clone whose origin is smartwatermelon/* must still resolve. -for gitdir in "${clones}"/*/.git; do +# ~/Developer is not flat: clients/ and netlify/crazy-larry sit one +# level deeper, so scan both depths. +for gitdir in "${clones}"/*/.git "${clones}"/*/*/.git; do [[ -e "${gitdir}" ]] || continue dir="${gitdir%/.git}" url="$(git -C "${dir}" remote get-url origin 2>/dev/null || true)" [[ "${url}" =~ github\.com[:/]smartwatermelon/ ]] || continue + # Name it relative to the clones root, so a nested clone is unambiguous. + name="${dir#"${clones}"/}" if git -C "${dir}" ls-remote --exit-code origin HEAD >/dev/null 2>&1; then - echo "verify: clone ${dir##*/}: ls-remote ok" + echo "verify: clone ${name}: ls-remote ok" else - echo "verify: clone ${dir##*/}: ls-remote FAILED (${url})" >&2 + echo "verify: clone ${name}: ls-remote FAILED (${url})" >&2 fail=1 fi done From 8d92cdab4d49075bef9573caee07a543b68ded8d Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:42:30 -0700 Subject: [PATCH 6/8] docs(org-migration): add the rename runbook and the token-rotation doc The runbook is the exact click path for renaming the user and creating the org in one sitting, plus the keyring re-login and shell checks. The rotation doc holds dates only, never token material. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- CLAUDE.md | 3 + docs/runbooks/org-migration-rename.md | 85 +++++++++++++++++++++++++++ docs/token-rotation.md | 55 +++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 docs/runbooks/org-migration-rename.md create mode 100644 docs/token-rotation.md diff --git a/CLAUDE.md b/CLAUDE.md index c2806d4..da71ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,9 @@ This repo is the **dev-env infrastructure repository** — it contains documenta - `docs/plans/` — Implementation plans (e.g., infrastructure consolidation) - `docs/WORKFLOW-DEEP-DIVE.md` — Comprehensive reference for all enforcement layers (hooks, wrappers, CI/CD) - `docs/local-code-review-options.md` — Research on local review tooling (Semgrep, Sentry/Seer, adversarial reviewer enhancements) + - `docs/runbooks/` — Step-by-step manual procedures (UI actions the agent cannot perform) + - `docs/token-rotation.md` — Where each `CLAUDE_CODE_OAUTH_TOKEN` lives and when it expires; never contains a token +- `scripts/org-migration/` — Snapshot/transfer/verify tooling for the 2026-09 org migration; tests in `scripts/org-migration/tests/run-tests.sh` - `.claude/` — Project-specific Claude Code configuration templates - `.claude/config.sh.template` — Template for project configuration (Node version, required tools, deployment secrets, build/deploy hooks) - `.claude/hooks/extensions/` — Project-specific git hook extensions (discovered and run by global hooks at `~/.config/git/hooks/`) diff --git a/docs/runbooks/org-migration-rename.md b/docs/runbooks/org-migration-rename.md new file mode 100644 index 0000000..9299da2 --- /dev/null +++ b/docs/runbooks/org-migration-rename.md @@ -0,0 +1,85 @@ +# Runbook: rename `smartwatermelon` → `twistedmelonman`, create org `smartwatermelon` + +Design: `docs/superpowers/specs/2026-09-03-org-migration-design.md`, Step 2. +One sitting, one browser session. The window between A4 and B3 is the only +moment the name `smartwatermelon` is claimable by someone else. Do A and B +back to back. + +## Before you start + +- [ ] dotfiles PR "twistedmelonman owner table, login alias, F4 scope hint" is + merged and `~/Developer/dotfiles` is on `main` at or after it. + Check: `grep -c twistedmelonman ~/Developer/dotfiles/bash/gh-wrapper.sh` + prints a number ≥ 4. +- [ ] Baseline snapshot committed under `docs/data/org-migration/`. +- [ ] You are signed in to github.com as `smartwatermelon` in the browser. +- [ ] Nothing is pushing or running CI right now (check + `gh run list -R smartwatermelon/github-workflows --limit 3`). + +## A. Rename the user + +1. Open ("Account" settings). +2. Under **Change username**, click **Change username**. +3. Read the warning dialog, click **I understand, let's change my username**. +4. Type `twistedmelonman`, click **Change my username**. +5. Confirm the page header shows `twistedmelonman`. + +## B. Create the org (immediately) + +1. Open . +2. Organization name: `smartwatermelon`. If the form says the name is taken, + **stop**: go back to A and rename to `smartwatermelon` again. The design's + failure table covers what happens next (a new spec). +3. Contact email: your gmail. "This organization belongs to": **My personal + account**. Complete the verification, click **Next**. +4. Skip "Add organization members" (**Skip this step**). Skip the survey. +5. Confirm shows the org page with you + as owner. + +## C. Org settings + +1. : + **Actions permissions** must be "Allow all actions and reusable workflows" + (the Free default). Under **Workflow permissions**, leave "Read + repository contents and packages permissions" (repos carry their own + setting across the transfer). +2. : + leave defaults. You are the only member. + +## D. Re-login the keyring on THIS machine + +The keyring token still works after the rename, but `hosts.yml` records the +login name and the wrapper compares it. Re-login rewrites it. + +```bash +env -u GH_TOKEN gh auth login -h github.com --web --scopes admin:org,repo,workflow,delete_repo +env -u GH_TOKEN gh auth logout -h github.com -u smartwatermelon # stale entry, if listed +env -u GH_TOKEN gh auth status +``` + +Expected: the active github.com account is `twistedmelonman`, scopes include +`admin:org`. `delete_repo` is for Step 6's repo deletions; drop it from the +list if you would rather add it later with `gh auth refresh -s delete_repo`. + +## E. Verify from a shell (paste the output back to the agent) + +```bash +gh api user --jq .login # GH_TOKEN: twistedmelonman +env -u GH_TOKEN gh api user --jq .login # keyring: twistedmelonman +gh api orgs/smartwatermelon --jq '.login + " " + .type' # smartwatermelon Organization +gh api orgs/smartwatermelon/memberships/twistedmelonman --jq .role # admin +gh api repos/smartwatermelon/dotfiles --jq '.owner.login + " " + .owner.type' # twistedmelonman User (redirect) +cd ~/Developer/dotfiles && gh pr list --limit 1 # identity guard passes, no error +``` + +## F. The other two machines (TILSIT, MIMOLETTE) + +Run section D on each, before the alias-removal PR (plan Task 12) merges. +Until then the alias keeps the old `hosts.yml` name working. + +## Undo + +Rename back at → `smartwatermelon`. If +the org was created, delete it first at + (bottom, +**Delete this organization**), because the name must be free. diff --git a/docs/token-rotation.md b/docs/token-rotation.md new file mode 100644 index 0000000..694f9f5 --- /dev/null +++ b/docs/token-rotation.md @@ -0,0 +1,55 @@ +# CLAUDE_CODE_OAUTH_TOKEN rotation + +**This file never contains a token, a token prefix, or `claude setup-token` +output.** Dates and locations only. If a token appears here, treat it as +leaked: revoke and rotate. + +Org-level secrets (design: +`docs/superpowers/specs/2026-09-03-org-migration-design.md`, Step 5). Free-plan +org secrets do not reach private repos, so `scripts` keeps a repo-level +token until it goes public. + +| Scope | Minted | Expires | Minted on | +| --- | --- | --- | --- | +| org `smartwatermelon` | | | | +| org `nightowlstudiollc` | | | | +| repo `smartwatermelon/scripts` | | | | + +Each row has a Google Calendar event "Rotate CLAUDE_CODE_OAUTH_TOKEN ()" +two weeks before the expiry date, pointing here. + +## Rotation runbook + +Rotate **before** expiry. Do not revoke-then-mint: revocation can take days +to propagate (`dev-env#54` findings) and every Claude workflow fails in +between. + +1. Mint, on your own machine: + + ```bash + claude setup-token + ``` + + Copy the token from the terminal. Do not paste it anywhere but step 2. + +2. Set it (the wrapper prints this exact line if you forget `env -u`): + + ```bash + env -u GH_TOKEN gh secret set CLAUDE_CODE_OAUTH_TOKEN --org smartwatermelon --visibility all + # or --org nightowlstudiollc + # or, for scripts: gh secret set CLAUDE_CODE_OAUTH_TOKEN -R smartwatermelon/scripts + ``` + + Paste when prompted. + +3. Re-run one Claude workflow on a repo in that scope and read the log: the + `claude-code-action` step must authenticate, not skip. + + ```bash + gh run list -R smartwatermelon/dev-env --workflow claude-blocking-review.yml --limit 1 --json databaseId --jq '.[0].databaseId' | xargs -I{} gh run rerun {} -R smartwatermelon/dev-env + ``` + +4. Update the table row (minted date, expiry = minted + the lifetime + `setup-token` printed, machine). + +5. Move the calendar event to two weeks before the new expiry. From cc1c92f1974a3bfb9f5b87064ab6634292473303 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:52:57 -0700 Subject: [PATCH 7/8] fix(org-migration): fail loudly on snapshot/verify errors and guard transfer targets Four defects of the same class: the tooling reported success while doing nothing, or while recording a failure as expected state. - verify.sh swallowed a jq failure with `|| true`. An unparseable baseline left ${changed} empty, which the loop read as "no fields changed besides owner" and printed `verify: : ok`. Check jq's status on the diff and on both owner reads; print `verify: : cannot diff snapshots` and fail the repo instead. - snapshot.sh left the final `jq -n` assembly unchecked. When it failed the script wrote an empty .json and printed the success line anyway. Remove the partial file, print `snapshot: FAILED to assemble `, count the failure, continue. - snapshot.sh read topics and secrets with `2>/dev/null || echo '[]'`, mapping every failure to an empty list. A 403 on the secrets endpoint is a permission problem, not a repo without secrets, and verify.sh would read the recorded [] as the expected state. Both now go through _optional (404 -> null only; any other failure fails the repo) with the projection applied in the assembly step. - transfer.sh POSTed without checking the target existed. During the rename window the name `smartwatermelon` is claimable by a stranger, and a transfer to a *user* is an invitation we cannot take back. Add a pre-flight that requires `gh api orgs/ --jq .type` to print `Organization` for every distinct target before any POST. It is read-only, so --dry-run runs it too. Tests: 23 -> 34 cases, each written to fail against the old code first. The transfer stub now serves orgs/ from a state file, and --only with a repo that is not on the move list is covered. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- scripts/org-migration/snapshot.sh | 30 ++++++--- scripts/org-migration/tests/test-snapshot.sh | 61 ++++++++++++++++- scripts/org-migration/tests/test-transfer.sh | 70 ++++++++++++++++++++ scripts/org-migration/tests/test-verify.sh | 28 +++++++- scripts/org-migration/transfer.sh | 31 ++++++++- scripts/org-migration/verify.sh | 19 ++++-- 6 files changed, 221 insertions(+), 18 deletions(-) diff --git a/scripts/org-migration/snapshot.sh b/scripts/org-migration/snapshot.sh index 2f96988..2fc1847 100755 --- a/scripts/org-migration/snapshot.sh +++ b/scripts/org-migration/snapshot.sh @@ -48,20 +48,27 @@ while read -r repo _target; do continue fi default_branch="$(jq -r '.default_branch' <<<"${core}")" - topics="$(gh api "${base}/topics" --jq '.names' 2>/dev/null || echo '[]')" - secrets="$(gh api "${base}/actions/secrets" --jq '[.secrets[].name] | sort' 2>/dev/null || echo '[]')" - if ! protection="$(_optional "${base}/branches/${default_branch}/protection")" || + # topics and secrets go through _optional too: a 403 on secrets is a + # permission problem, not an empty secret list, and recording [] would make + # verify.sh read the failure as the expected state. Project the payload + # afterwards so a non-404 failure still fails the repo. + if ! topics_raw="$(_optional "${base}/topics")" || + ! secrets_raw="$(_optional "${base}/actions/secrets")" || + ! protection="$(_optional "${base}/branches/${default_branch}/protection")" || ! rulesets="$(_optional "${base}/rulesets")" || ! pages="$(_optional "${base}/pages")"; then echo "snapshot: FAILED to read ${repo}" >&2 failed=$((failed + 1)) continue fi - jq -n \ + # The final assembly can still fail on a body that parsed as an argument but + # is not the shape we project. An unchecked jq here wrote an empty file and + # printed the success line anyway. + if ! jq -n \ --arg repo "${repo}" \ --argjson core "${core}" \ - --argjson topics "${topics}" \ - --argjson secrets "${secrets}" \ + --argjson topics_raw "${topics_raw}" \ + --argjson secrets_raw "${secrets_raw}" \ --argjson protection "${protection}" \ --argjson rulesets "${rulesets}" \ --argjson pages "${pages}" \ @@ -70,8 +77,15 @@ while read -r repo _target; do default_branch: $core.default_branch, visibility: $core.visibility, archived: $core.archived, - topics: $topics, pages: $pages, secrets: $secrets, - protection: $protection, rulesets: $rulesets}' >"${outdir}/${repo}.json" + topics: ($topics_raw.names // []), + pages: $pages, + secrets: ([($secrets_raw.secrets // [])[].name] | sort), + protection: $protection, rulesets: $rulesets}' >"${outdir}/${repo}.json"; then + rm -f "${outdir}/${repo}.json" + echo "snapshot: FAILED to assemble ${repo}" >&2 + failed=$((failed + 1)) + continue + fi echo "snapshot: ${repo} -> ${outdir}/${repo}.json" done <<<"${pairs}" diff --git a/scripts/org-migration/tests/test-snapshot.sh b/scripts/org-migration/tests/test-snapshot.sh index 0c224bd..9f025d1 100755 --- a/scripts/org-migration/tests/test-snapshot.sh +++ b/scripts/org-migration/tests/test-snapshot.sh @@ -48,6 +48,22 @@ case "${path}" in repos/smartwatermelon/flaky/rulesets) echo '[]' | emit ;; repos/smartwatermelon/flaky/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; repos/smartwatermelon/flaky/branches/main/protection) echo 'gh: Bad credentials (HTTP 401)' >&2; exit 1 ;; + # garbage: every sub-resource answers, but topics returns a body that is not + # JSON, so the final jq -n assembly cannot run. + repos/smartwatermelon/garbage) echo '{"name":"garbage","owner":{"login":"smartwatermelon","type":"User"},"default_branch":"main","visibility":"public","archived":false}' | emit ;; + repos/smartwatermelon/garbage/topics) echo '502 Bad Gateway' ;; + repos/smartwatermelon/garbage/actions/secrets) echo '{"secrets":[]}' | emit ;; + repos/smartwatermelon/garbage/rulesets) echo '[]' | emit ;; + repos/smartwatermelon/garbage/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + repos/smartwatermelon/garbage/branches/main/protection) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + # forbidden: the secrets endpoint fails with a 403, not a 404. That is a + # permission problem, not "this repo has no secrets". + repos/smartwatermelon/forbidden) echo '{"name":"forbidden","owner":{"login":"smartwatermelon","type":"User"},"default_branch":"main","visibility":"public","archived":false}' | emit ;; + repos/smartwatermelon/forbidden/topics) echo '{"names":[]}' | emit ;; + repos/smartwatermelon/forbidden/actions/secrets) echo 'gh: Resource not accessible by integration (HTTP 403)' >&2; exit 1 ;; + repos/smartwatermelon/forbidden/rulesets) echo '[]' | emit ;; + repos/smartwatermelon/forbidden/pages) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + repos/smartwatermelon/forbidden/branches/main/protection) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; repos/smartwatermelon/ghost*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; *) echo "stub: unexpected path ${path}" >&2; exit 2 ;; esac @@ -106,7 +122,50 @@ else _fail "non-404 sub-resource failure: wrote a snapshot anyway: ${wrote}" fi -# Case 4: malformed line rejected before any API call. +# Case 4: when the final jq assembly cannot run, no partial file may be left +# behind and the run must fail. Unchecked, jq wrote an empty .json and +# the script still printed the success line. +rm -rf "${WORK}/out" && mkdir -p "${WORK}/out" +printf 'garbage\tsmartwatermelon\n' >"${WORK}/list-garbage" +out="$(PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-garbage" "${WORK}/out" 2>&1)" +rc=$? +if [[ "${rc}" -ne 0 && "${out}" == *"FAILED to assemble garbage"* ]]; then + _pass "unassemblable snapshot: non-zero, names garbage" +else + _fail "unassemblable snapshot: expected non-zero naming garbage, got rc=${rc} out=${out}" +fi +if [[ ! -e "${WORK}/out/garbage.json" ]]; then + _pass "unassemblable snapshot: no partial file left" +else + wrote="$(cat "${WORK}/out/garbage.json" || true)" + _fail "unassemblable snapshot: left a file: '${wrote}'" +fi +if [[ "${out}" != *"snapshot: garbage -> "* ]]; then + _pass "unassemblable snapshot: no success line" +else + _fail "unassemblable snapshot: printed a success line: ${out}" +fi + +# Case 5: a 403 on secrets is a permission failure, not an empty secret list. +# `2>/dev/null || echo '[]'` recorded [] and verify.sh would read that as the +# expected state. +rm -rf "${WORK}/out" && mkdir -p "${WORK}/out" +printf 'forbidden\tsmartwatermelon\n' >"${WORK}/list-forbidden" +out="$(PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-forbidden" "${WORK}/out" 2>&1)" +rc=$? +if [[ "${rc}" -ne 0 && "${out}" == *forbidden* ]]; then + _pass "403 on secrets: non-zero, names forbidden" +else + _fail "403 on secrets: expected non-zero naming forbidden, got rc=${rc} out=${out}" +fi +if [[ ! -e "${WORK}/out/forbidden.json" ]]; then + _pass "403 on secrets: no snapshot written" +else + wrote="$(cat "${WORK}/out/forbidden.json" || true)" + _fail "403 on secrets: wrote a snapshot anyway: ${wrote}" +fi + +# Case 6: malformed line rejected before any API call. printf 'dotfiles smartwatermelon\n' >"${WORK}/list-malformed" if PATH="${WORK}/bin:${PATH}" bash "${SNAPSHOT}" "${WORK}/list-malformed" "${WORK}/out" 2>/dev/null; then _fail "malformed line: should fail" diff --git a/scripts/org-migration/tests/test-transfer.sh b/scripts/org-migration/tests/test-transfer.sh index 73369ab..fec1260 100755 --- a/scripts/org-migration/tests/test-transfer.sh +++ b/scripts/org-migration/tests/test-transfer.sh @@ -48,6 +48,20 @@ if [[ "${method}" == "POST" && "${path}" == */transfer ]]; then echo '{}' exit 0 fi +# orgs/: an entry in ${STATE}/orgs names one account type per line as +# " ". A name absent from that file 404s, as GitHub does. +if [[ "${path}" == orgs/* ]]; then + want="${path#orgs/}" + while read -r oname otype; do + if [[ "${oname}" == "${want}" ]]; then + json="$(printf '{"login":"%s","type":"%s"}' "${oname}" "${otype}")" + if [[ -n "${jqexpr}" ]]; then jq -r "${jqexpr}" <<<"${json}"; else echo "${json}"; fi + exit 0 + fi + done <"${STATE}/orgs" 2>/dev/null + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 +fi repo="${path#repos/*/}" # "malformed" answers a lookup with a single field, standing in for any # unexpected gh output (an empty owner, a truncated body). @@ -72,6 +86,8 @@ STUB chmod +x "${WORK}/bin/gh" export OM_TEST_STATE="${WORK}/state" +# Both transfer targets exist as real organizations. +printf 'smartwatermelon Organization\nnightowlstudiollc Organization\n' >"${WORK}/state/orgs" echo "twistedmelonman User" >"${WORK}/state/alpha" echo "twistedmelonman User" >"${WORK}/state/beta" echo "nightowlstudiollc Organization" >"${WORK}/state/done" @@ -141,4 +157,58 @@ else _fail "--only: got ${log}" fi +# Case 7: a target that is not an organization must stop the run before any +# POST. During the rename window a stranger can hold the user name +# `smartwatermelon`; a user-to-user transfer would send them an invitation. +echo "twistedmelonman User" >"${WORK}/state/alpha" +echo "twistedmelonman User" >"${WORK}/state/beta" +rm -f "${WORK}/state/posts" +printf 'nightowlstudiollc Organization\n' >"${WORK}/state/orgs" +err="$(run "${WORK}/list" 2>&1 >/dev/null)" +rc=$? +read_posts +if [[ "${rc}" -eq 1 && "${err}" == *smartwatermelon* && -z "${log}" ]]; then + _pass "missing target org: exit 1, zero POSTs" +else + _fail "missing target org: rc=${rc} err=${err} posts=${log}" +fi + +# Case 8: a target that resolves but is a User (the squatter case) is refused +# the same way. +printf 'smartwatermelon User\nnightowlstudiollc Organization\n' >"${WORK}/state/orgs" +rm -f "${WORK}/state/posts" +err="$(run "${WORK}/list" 2>&1 >/dev/null)" +rc=$? +read_posts +if [[ "${rc}" -eq 1 && "${err}" == *smartwatermelon* && -z "${log}" ]]; then + _pass "target is a User: exit 1, zero POSTs" +else + _fail "target is a User: rc=${rc} err=${err} posts=${log}" +fi + +# Case 9: --dry-run runs the pre-flight too. It is read-only and cheap, and a +# dry run that skips it would report a plan that cannot execute. +rm -f "${WORK}/state/posts" +err="$(run "${WORK}/list" --dry-run 2>&1 >/dev/null)" +rc=$? +read_posts +if [[ "${rc}" -eq 1 && "${err}" == *smartwatermelon* && -z "${log}" ]]; then + _pass "dry-run: pre-flight still enforced" +else + _fail "dry-run: pre-flight skipped: rc=${rc} err=${err} posts=${log}" +fi + +# Case 10: --only with a repo that is not on the move list exits 1 and POSTs +# nothing. A typo must not look like a successful no-op run. +printf 'smartwatermelon Organization\nnightowlstudiollc Organization\n' >"${WORK}/state/orgs" +rm -f "${WORK}/state/posts" +err="$(run "${WORK}/list" --only nosuchrepo 2>&1 >/dev/null)" +rc=$? +read_posts +if [[ "${rc}" -eq 1 && "${err}" == *nosuchrepo* && -z "${log}" ]]; then + _pass "--only off-list: exit 1, no POST" +else + _fail "--only off-list: rc=${rc} err=${err} posts=${log}" +fi + exit "${fail}" diff --git a/scripts/org-migration/tests/test-verify.sh b/scripts/org-migration/tests/test-verify.sh index 41161cc..b563c18 100755 --- a/scripts/org-migration/tests/test-verify.sh +++ b/scripts/org-migration/tests/test-verify.sh @@ -157,7 +157,33 @@ else _fail "nested clone: rc=${rc} out=${out}" fi -# Case 8: a broken nested clone fails the run. +# Case 8a: an unparseable baseline JSON must fail the repo, not read as "ok". +# jq's failure used to be swallowed by `|| true`, leaving ${changed} empty -- +# which the loop read as "no fields changed besides owner". +mkdir -p "${WORK}/base-truncated" +cp "${WORK}/base/cleanroom.json" "${WORK}/base-truncated/cleanroom.json" +head -c 20 "${WORK}/base/alpha.json" >"${WORK}/base-truncated/alpha.json" +after_bad="$(mktemp -d "${WORK}/after-XXXXXX")" && rmdir "${after_bad}" +err="$(OM_TEST_CORE="${WORK}/core-good" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" \ + "${WORK}/list" "${WORK}/base-truncated" "${after_bad}" --clones "${WORK}/clones-good" 2>&1 >/dev/null)" +rc=$? +if [[ "${rc}" -eq 1 && "${err}" == *"alpha: cannot diff snapshots"* ]]; then + _pass "unparseable baseline: exit 1, reports cannot diff snapshots" +else + _fail "unparseable baseline: rc=${rc} err=${err}" +fi + +# Case 8b: and it must not print a per-repo ok line for that repo. +after_bad2="$(mktemp -d "${WORK}/after-XXXXXX")" && rmdir "${after_bad2}" +out="$(OM_TEST_CORE="${WORK}/core-good" PATH="${WORK}/bin:${PATH}" bash "${VERIFY}" \ + "${WORK}/list" "${WORK}/base-truncated" "${after_bad2}" --clones "${WORK}/clones-good" 2>&1)" +if [[ "${out}" != *"verify: alpha: ok"* ]]; then + _pass "unparseable baseline: no ok line for alpha" +else + _fail "unparseable baseline: printed an ok line: ${out}" +fi + +# Case 9: a broken nested clone fails the run. mkdir -p "${WORK}/clones-nested-bad/clients/broken/.git" echo "git@github.com:smartwatermelon/broken.git" >"${WORK}/clones-nested-bad/clients/broken/.git/ORIGIN" err="$(run "${WORK}/core-good" "${WORK}/clones-nested-bad" 2>&1 >/dev/null)" diff --git a/scripts/org-migration/transfer.sh b/scripts/org-migration/transfer.sh index e4bcb17..23c9830 100755 --- a/scripts/org-migration/transfer.sh +++ b/scripts/org-migration/transfer.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Transfer every repo on the move list to its target org. Idempotent: a repo -# already owned by its target is skipped. A failed transfer is reported and -# the loop continues; exit 1 at the end if any failed. +# Transfer every repo on the move list to its target org. A pre-flight checks +# that every distinct target is a real organization before any POST. +# Idempotent: a repo already owned by its target is skipped. A failed transfer +# is reported and the loop continues; exit 1 at the end if any failed. # Usage: transfer.sh [--only ] [--dry-run] # Design: docs/superpowers/specs/2026-09-03-org-migration-design.md, Steps 3-4. set -uo pipefail @@ -43,6 +44,30 @@ if [[ -z "${list}" ]]; then fi pairs="$(om_read_move_list "${list}")" || exit 1 + +# Pre-flight: every distinct target must already be a real organization. +# During the rename window the name `smartwatermelon` is claimable, and a +# transfer to a *user* is an invitation sent to whoever holds the name — not a +# move we could take back. Read-only and cheap, so --dry-run runs it too. +preflight_failed=0 +targets="$(awk '{print $2}' <<<"${pairs}")" || exit 1 +targets="$(sort -u <<<"${targets}")" || exit 1 +while read -r target; do + [[ -n "${target}" ]] || continue + if ! target_type="$(gh api "orgs/${target}" --jq '.type' 2>/dev/null)"; then + echo "transfer: target ${target} does not resolve as an organization; aborting" >&2 + preflight_failed=1 + continue + fi + if [[ "${target_type}" != "Organization" ]]; then + echo "transfer: target ${target} is a ${target_type}, not an Organization; aborting" >&2 + preflight_failed=1 + fi +done <<<"${targets}" +if [[ "${preflight_failed}" -ne 0 ]]; then + exit 1 +fi + failed=0 seen_only=0 while read -r repo target; do diff --git a/scripts/org-migration/verify.sh b/scripts/org-migration/verify.sh index 16bd164..3f4c413 100755 --- a/scripts/org-migration/verify.sh +++ b/scripts/org-migration/verify.sh @@ -58,16 +58,25 @@ while read -r repo target; do fail=1 continue fi - # Every top-level field except owner must be byte-identical. - changed="$(jq -r -n --slurpfile b "${b}" --slurpfile a "${a}" \ + # Every top-level field except owner must be byte-identical. A jq failure + # here means a snapshot is unreadable, not that nothing changed: swallowing + # it would leave ${changed} empty and report the repo as ok. + if ! changed="$(jq -r -n --slurpfile b "${b}" --slurpfile a "${a}" \ '($b[0] | del(.owner)) as $x | ($a[0] | del(.owner)) as $y - | [($x + $y | keys[]) | select($x[.] != $y[.])] | join(",")' || true)" + | [($x + $y | keys[]) | select($x[.] != $y[.])] | join(",")')"; then + echo "verify: ${repo}: cannot diff snapshots" >&2 + fail=1 + continue + fi if [[ -n "${changed}" ]]; then echo "verify: ${repo}: fields changed besides owner: ${changed}" >&2 fail=1 fi - login="$(jq -r '.owner.login' "${a}" || true)" - type="$(jq -r '.owner.type' "${a}" || true)" + if ! login="$(jq -r '.owner.login' "${a}")" || ! type="$(jq -r '.owner.type' "${a}")"; then + echo "verify: ${repo}: cannot diff snapshots" >&2 + fail=1 + continue + fi if [[ "${login,,}" != "${target,,}" || "${type}" != "Organization" ]]; then echo "verify: ${repo}: owner is ${login} (${type}), expected ${target} (Organization)" >&2 fail=1 From 72d84c18c7a2abf3617f9d895fc3b0e8706c58da Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Thu, 3 Sep 2026 13:53:16 -0700 Subject: [PATCH 8/8] docs(org-migration): record the test/lint commands and the verify-step caveats CLAUDE.md claimed there are no build, test, or lint commands. There is no build, but the org-migration tooling has a hermetic test suite and shellcheck applies to every shell script here. Name both. The rename runbook had no step for running verify.sh. Add one, with the two things that otherwise read as failures: - verify.sh must always get a fresh, empty after-dir. It refuses a non-empty one, because a leftover JSON would be compared as though this run wrote it. - cleanroom is the one repo moving to a different owner name, so its URL-bearing fields (protection.url, ruleset source/_links, pages.html_url) legitimately differ. Inspect such a diff with jq -S rather than "restoring" it; the same report for any other repo is real drift. Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6 --- CLAUDE.md | 5 ++++- docs/runbooks/org-migration-rename.md | 31 ++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da71ae0..ec36c13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Purpose -This repo is the **dev-env infrastructure repository** — it contains documentation, design plans, templates, and hook extensions for Andrew's Claude Code development environment. It is not an application codebase; there are no build, test, or lint commands. +This repo is the **dev-env infrastructure repository** — it contains documentation, design plans, templates, and hook extensions for Andrew's Claude Code development environment. It is not an application codebase and there is no build step, but it is not command-free either: + +- **Tests**: `bash scripts/org-migration/tests/run-tests.sh` runs the hermetic stub-`gh` suite for the org-migration tooling. Run it after any change under `scripts/org-migration/`. +- **Lint**: `shellcheck -S info