| M | README.md | 8 | +++++++- |
| M | bin/ | 11 | +++++++++-- |
| M | deploy/ | 28 | ++++++++++++++++++++++++++-- |
| M | test/ | 3 | ++- |
| M | test/ | 3 | ++- |
| M | test/ | 3 | ++- |
| M | test/ | 13 | +++++++++++++ |
@@ -759,10 +759,16 @@ without it there is no fallback.
759759 On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):760760 761761 ```sh762-deploy/slate/deploy.sh # rsync this checkout, docker compose up -d --build762+deploy/slate/deploy.sh # ship HEAD (git archive), docker compose up -d --build763763 ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'764764 ```765765 766+`deploy.sh [--force] [<commit>]` ships a commit, `HEAD` by default, exported767+with `git archive`: uncommitted edits in the checkout stay behind. It wants768+a verified passing CI attestation for it (`forge verify`), as `main` on the769+forge does with `forge.requireci`, and `--force` ships one without. The770+commit running on slate is in `/srv/forge/src/DEPLOYED`.771+766772 The edge (`deploy/edge`) needs a dedicated IPv4 on Fly, because raw TCP on767773 port 22 doesn't work on shared addresses. DNS for `src.rest` and768774 `pages.src.rest` points at the edge's addresses, DNS-only on Cloudflare@@ -1288,6 +1288,10 @@ repo_rm() {
12881288 trash=$FORGE_STATE/trash/$owner12891289 mkdir -p "$trash"12901290 t=$(date -u +%Y%m%dT%H%M%SZ)1291+ # Two removals in one second get .2, .3, ...: mv onto an existing1292+ # directory would put the repo inside it.1293+ n=11294+ while [ -e "$trash/$name.git.$t" ]; do n=$((n + 1)) t=${t%%.*}.$n; done12911295 mv "$FORGE_ROOT/$1.git" "$trash/$name.git.$t"12921296 # The time it was removed, which gc counts from.12931297 touch "$trash/$name.git.$t"@@ -1308,8 +1312,11 @@ repo_restore() {
13081312 owner=${1%%/*} name=${1#*/}13091313 valid_name "$owner" && valid_name "$name" && [ "$owner/$name" = "$1" ] || die "invalid repo: $1"13101314 [ ! -e "$FORGE_ROOT/$1.git" ] || die "$1 exists; remove or rename it first"1311- from=$(ls -d "$FORGE_STATE/trash/$owner/$name.git."* 2>/dev/null | sort | tail -n 1)1312- [ -n "$from" ] || die "no removed $1 in the trash"1315+ # Newest last: by the time after <name>.git., then by the .N of a second1316+ # removal within that second (names may hold dots, times don't).1317+ when=$(cd "$FORGE_STATE/trash/$owner" 2>/dev/null && for x in "$name.git."*; do [ -e "$x" ] && printf '%s\n' "${x#"$name.git."}"; done | sort -t . -k 1,1 -k 2,2n | tail -n 1)1318+ [ -n "$when" ] || die "no removed $1 in the trash"1319+ from=$FORGE_STATE/trash/$owner/$name.git.$when13131320 mkdir -p "$FORGE_ROOT/$owner"13141321 mv "$from" "$FORGE_ROOT/$1.git"13151322 repo_setup "$FORGE_ROOT/$1.git"@@ -1,9 +1,33 @@
11 2-# Ship this checkout to slate and rebuild the forge container there.2+# Ship a commit of this repo to slate and rebuild the forge container there.33 # Data in /srv/forge/data is untouched.4+#5+# deploy/slate/deploy.sh [--force] [<commit>] HEAD by default6+#7+# What ships is the commit, exported with git archive, never the working8+# tree: uncommitted edits, whoever made them, stay here. It needs a9+# verified passing CI attestation (forge verify), as main on the forge10+# does; --force ships it anyway. The commit deployed is written to11+# /srv/forge/src/DEPLOYED.412 set -eu513 here=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)614 host=${FORGE_HOST:-slate}15+force=016+[ "${1:-}" = --force ] && { force=1; shift; }17+rev=$(git -C "$here" rev-parse --verify "${1:-HEAD}^{commit}")18+short=$(git -C "$here" rev-parse --short "$rev")19+if ! git -C "$here" diff --quiet HEAD 2>/dev/null; then20+ echo "deploy: the working tree has uncommitted changes; they stay here, $short ships" >&221+fi22+if ! "$here/bin/forge" verify "$rev" >/dev/null 2>&1; then23+ [ "$force" = 1 ] || { echo "deploy: $short has no verified passing CI attestation (forge attest $short), or pass --force" >&2; exit 1; }24+ echo "deploy: shipping $short without a verified CI pass (--force)" >&225+fi26+tmp=$(mktemp -d "${TMPDIR:-/tmp}/forge-deploy.XXXXXX")27+trap 'rm -rf "$tmp"' EXIT28+git -C "$here" archive "$rev" | tar -x -C "$tmp"29+printf '%s\n' "$rev" >"$tmp/DEPLOYED"730 rsync -a --delete --exclude node_modules --exclude demo/root --exclude demo/work \8- --exclude .git --exclude .env --exclude _site --exclude .cache "$here/" "$host:/srv/forge/src/"31+ --exclude .env --exclude _site --exclude .cache "$tmp/" "$host:/srv/forge/src/"932 ssh "$host" 'cd /srv/forge/src/deploy/slate && docker compose up -d --build'33+echo "deploy: $short is on $host"@@ -44,7 +44,8 @@ done
4444 4545 # The mirror (a stand-in rclone that lists what it was given) sees none of it.4646 mkdir "$T/bin"47-printf '#!/bin/sh\nfind "$2" >>"%s/rclone.saw"\n' "$T" >"$T/bin/rclone"47+# Each run's listing whole, appended at once: the scenario waits for the file.48+printf '#!/bin/sh\nfind "$2" >"%s/rclone.$$" && cat "%s/rclone.$$" >>"%s/rclone.saw" && rm "%s/rclone.$$"\n' "$T" "$T" "$T" "$T" >"$T/bin/rclone"4849 chmod +x "$T/bin/rclone"4950 PATH="$T/bin:$PATH" FORGE_MIRROR_S3=https://r2.test/pages ok forge mirror5051 has "$T/rclone.saw" '/dami/open/insights/insights\.json$'@@ -31,7 +31,8 @@ eq "$(ls "$w/hosts" | sort | tr '\n' ' ')" "greet-docs-dami greet-docs-dami--dam
3131 3232 # The mirror's sync (a stand-in rclone) runs after the flip, and sees what is left.3333 mkdir "$T/bin"34-printf '#!/bin/sh\nls "$2/dami" >"%s/rclone.saw"\n' "$T" >"$T/bin/rclone"34+# Written whole, then renamed: the scenario waits for the file to appear.35+printf '#!/bin/sh\nls "$2/dami" >"%s/rclone.tmp" && mv "%s/rclone.tmp" "%s/rclone.saw"\n' "$T" "$T" "$T" >"$T/bin/rclone"3536 chmod +x "$T/bin/rclone"3637 export PATH="$T/bin:$PATH" FORGE_MIRROR_S3=https://r2.test/pages FORGE_MIRROR_DELAY=03738 sync_builds@@ -75,7 +75,8 @@ same -H "Authorization: Bearer $elsewhere"
7575 7676 # The mirror (a stand-in rclone that lists what it was given) sees none of it.7777 mkdir "$T/bin"78-printf '#!/bin/sh\nfind "$2" >>"%s/rclone.saw"\n' "$T" >"$T/bin/rclone"78+# Each run's listing whole, appended at once: the scenario waits for the file.79+printf '#!/bin/sh\nfind "$2" >"%s/rclone.$$" && cat "%s/rclone.$$" >>"%s/rclone.saw" && rm "%s/rclone.$$"\n' "$T" "$T" "$T" "$T" >"$T/bin/rclone"7980 chmod +x "$T/bin/rclone"8081 PATH="$T/bin:$PATH" FORGE_MIRROR_S3=https://r2.test/pages ok forge mirror8182 has "$T/rclone.saw" '/index\.html$'@@ -55,3 +55,16 @@ eq "$(ls "$FORGE_ROOT/.forge/trash/dami" | grep -c '^greet\.git\.')" 1
5555 ok env FORGE_TRASH_DAYS=0 "$FORGE" gc --age 05656 output | grep -q '^removed trash dami/greet\.git\.' || fail "gc removed no trash"5757 eq "$(ls "$FORGE_ROOT/.forge/trash/dami" | grep -c '^greet\.git\.' || true)" 058+59+# A dotted name removed twice, likely within one second: two entries side60+# by side, never one inside the other, and restore takes the newer.61+repo dami/klot.ski62+git --git-dir="$FORGE_ROOT/dami/klot.ski.git" config forge.mark first63+ok forge repo rm dami/klot.ski64+repo dami/klot.ski65+git --git-dir="$FORGE_ROOT/dami/klot.ski.git" config forge.mark second66+ok forge repo rm dami/klot.ski67+eq "$(ls "$FORGE_ROOT/.forge/trash/dami" | grep -c '^klot\.ski\.git\.')" 268+eq "$(find "$FORGE_ROOT/.forge/trash" -mindepth 3 -maxdepth 3 -name '*.git.*' | wc -l | tr -d ' ')" 069+ok forge repo restore dami/klot.ski70+eq "$(git --git-dir="$FORGE_ROOT/dami/klot.ski.git" config forge.mark)" second