As root on a box with a git user, which is how forge-admin restore runs in the container, forge restore chowns the restored forge to git and finishes it (hooks, sites, releases, pages) as git. In scenarios that is the path the container takes, and it failed: git couldn't get into the scenario's directory (mktemp -d makes it 0700, root's) or write the caches root's builds had filled. lib.sh opens the directory to git when it runs as root and git exists, and restore_into restores into a new forge with its pages and caches inside it, as a new host has, so git owns them once forge restore has chowned it. The seven scenarios that restore use it. Nothing changes for a normal user: restore finishes as them, as before. forge restore itself does the right thing here; the scenarios' setup was what assumed it ran as the user who owns everything.
- commit
- 79c9c92efac95599e87b6605a600b9b2b4ee0e3c
- parent
- f1bc047964acd1cfe96116a004b776fb797d0824
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 71s, signed by cat
- kind
- fix risk score 0.9 of 3, Jev's estimate
- review
- #5 Pass the scenarios in the container, as root
ci attestation
forge-attestation 1 type ci commit 79c9c92efac95599e87b6605a600b9b2b4ee0e3c tree 7a7619742e68bf5ebcce24393c7c65abf266da3b script .forge/ci e6f038ae810b87f90a4be115a762a9c91dd76a01 result pass exit 0 started 2026-10-18T23:02:31Z duration 71s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:0987a7a9aa556f26eabd0b5706e7646cc22f834271ff57423aee7be94504506a 240 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |6d05816 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |6d05816 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |fcf05d6 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |fcf05d6 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |2113433 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |2113433 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 8c1875b -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |3898fb1 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |3898fb1 pass in 0s, signed |pushed main |4ecd38d running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |4ecd38d pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed 973f9ee -> /sites/dami/forge/guide/ |(node:68291) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:68421) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:68558) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:68694) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:69359) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
@@ -17,6 +17,7 @@
1717 # site_commit w "text" a commit of a one-page site, to push to refs/sites/...1818 # release_commit w v1 dir [key] a release commit of dir's files, to push to refs/releases/v11919 # aside dami/secret ... back dami/secret move a repo out of the forge and back20+# restore_into dir --from stage forge restore into a new forge at dir2021 # sync_builds hooks and ssh commands build pages before returning2122 # lq "<sql>" forge lake query: the rows as CSV, no header2223 # private_server lib/private-server.ts for this forge, at $PRIV@@ -34,6 +35,10 @@ T_HOME=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
3435 FORGE=$T_HOME/bin/forge3536 T=$(mktemp -d "${TMPDIR:-/tmp}/forge-scenario.XXXXXX")3637 T=$(CDPATH= cd -- "$T" && pwd -P)38+# As root where there is a git user (the container), forge restore hands39+# the restored forge to git and finishes as git, as it does in production,40+# so git must be able to get to it in here.41+if [ "$(id -u)" = 0 ] && id -u git >/dev/null 2>&1; then chmod 755 "$T"; fi3742 # Processes a scenario starts in the background go in T_PIDS, to be stopped.3843 T_PIDS=3944 trap 'st=$?; [ -z "$T_PIDS" ] || kill $T_PIDS 2>/dev/null || true; [ -n "${FORGE_TEST_KEEP:-}" ] && echo "kept $T" >&2 || rm -rf "$T"; exit $st' EXIT@@ -231,6 +236,15 @@ release_commit() {
231236 aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }232237 back() { mv "$T/aside.git" "$FORGE_ROOT/$1.git"; }233238 239+# restore_into dir args...: forge restore into a new forge at dir, with240+# its pages and caches inside it, as on a new host. (As root, git finishes241+# the restore there, and couldn't write this forge's caches.)242+restore_into() {243+ rd=$1244+ shift245+ env FORGE_ROOT="$rd" FORGE_WWW="$rd/.forge/www" FORGE_CACHE="$rd/.forge/cache" "$FORGE" restore "$@"246+}247+234248 # From here on, what hooks and ssh commands rebuild is built before they return.235249 sync_builds() { export FORGE_NO_BUILD=0 FORGE_SYNC_BUILD=1; }236250 @@ -14,7 +14,7 @@ echo "looks fine" | ssh_as dami comment dami/greet 1 >"$T_LOG" 2>&1 || fail "com
1414 exists "$FORGE_ROOT/dami/greet.git/forge/comments/1/1"1515 ok forge backup --dry-run1616 r=$T/restored17-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" "$FORGE" restore --from "$FORGE_ROOT/.forge/backup/stage"17+ok restore_into "$r" --from "$FORGE_ROOT/.forge/backup/stage"1818 exists "$r/dami/greet.git/hooks/pre-receive"1919 has "$r/.ssh/authorized_keys" 'shell ana"'2020 ok diff -r "$FORGE_ROOT/dami/greet.git/forge" "$r/dami/greet.git/forge"@@ -33,7 +33,7 @@ eq "$(mode "$st/.forge/identity.db")" 600
3333 ok node "$T_HOME/lib/identity.ts" check "$st/.forge/identity.db"3434 3535 r=$T/restored36-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"36+ok restore_into "$r" --from "$st"3737 exists "$r/.forge/identity.db"3838 exists "$r/.forge/private/www/dami/secret/index.html"3939 export FORGE_ROOT=$r FORGE_WWW=$r/.forge/www@@ -45,7 +45,7 @@ eq "$(output)" ""
4545 4646 # forge restore: the replica changed after the backup's copy (a, b > a)...4747 r=$T/r148-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$T/stage-a"48+ok restore_into "$r" --from "$T/stage-a"4949 has "$T_LOG" "identity.db is litestream's, which changed last"5050 eq "$(tokens "$r")" "a b "5151 # ...and when replication had stopped, the backup's copy wins.@@ -53,7 +53,7 @@ export FORGE_NOW=$((T_NOW + 120))
5353 ok ssh_as dami token c5454 stage "$T/stage-c"5555 r=$T/r256-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache3" "$FORGE" restore --from "$T/stage-c"56+ok restore_into "$r" --from "$T/stage-c"5757 has "$T_LOG" "identity.db is the backup's, which changed last"5858 eq "$(tokens "$r")" "a b c "5959 @@ -32,7 +32,7 @@ missing "$FORGE_ROOT/.forge/lake.lock"
3232 # A copy, not the live file.3333 [ "$(ls -i "$st/lake/catalog.sqlite" | awk '{ print $1 }')" != "$(ls -i "$FORGE_ROOT/.forge/lake/catalog.sqlite" | awk '{ print $1 }')" ] || fail "the staged catalog is the live file"3434 r=$T/restored35-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" "$FORGE" restore --from "$FORGE_ROOT/.forge/backup/stage"35+ok restore_into "$r" --from "$FORGE_ROOT/.forge/backup/stage"3636 exists "$r/.forge/lake/catalog.sqlite"3737 eq "$(FORGE_ROOT=$r FORGE_WWW=$r/.forge/www lq "$facts")" "$want"3838 # The restored lake reads its own data, not the old forge's.@@ -20,7 +20,7 @@ exists "$st/dami/open.git/git-daemon-export-ok"
2020 eq "$(cat "$st/dami/secret.git/forge/readers")" ana2121 2222 r=$T/restored23-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"23+ok restore_into "$r" --from "$st"2424 missing "$r/dami/secret.git/git-daemon-export-ok"2525 exists "$r/dami/open.git/git-daemon-export-ok"2626 eq "$(cat "$r/dami/secret.git/forge/readers")" ana@@ -28,7 +28,7 @@ missing "$st/.forge/store"
2828 exists "$st/dami/secret.git/HEAD"2929 3030 r=$T/restored31-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"31+ok restore_into "$r" --from "$st"3232 inode() { ls -i "$1" | awk '{ print $1 }'; }3333 dl=dami/secret/releases/download3434 ok cmp "$T/f/tool" "$r/.forge/private/store/$secret/tool"@@ -25,7 +25,7 @@ ok forge backup --dry-run
2525 st=$FORGE_ROOT/.forge/backup/stage2626 missing "$st/.forge/store"2727 r=$T/restored28-ok env FORGE_ROOT="$r" FORGE_WWW="$r/.forge/www" FORGE_CACHE="$T/cache2" "$FORGE" restore --from "$st"28+ok restore_into "$r" --from "$st"2929 ok cmp "$T/f/tool" "$r/.forge/www/dami/open/$dl/tool"3030 ok cmp "$FORGE_WWW/dami/open/$dl/SHA256SUMS.sig" "$r/.forge/www/dami/open/$dl/SHA256SUMS.sig"3131 exists "$r/.forge/www/dami/open/releases/download/latest/tool"