| 1/3 | Write the stand-in rclone's listing whole in scenarios | +6 | -3 | ✓ |
| 2/3 | Give each removal of a repo its own place in the trash | +22 | -2 | ✓ |
| 3/3 | Deploy a commit, not the working tree, and only with a CI pass | +33 | -3 | ✓ |
revisions3r3: message only
| r3 | cat | 4383583 | what changed since r2 message only | |
| r2 | cat | e6d3bd1 | what changed since r1 tests only | |
| r1 | cat | 5903f32 |
No comments yet: forge comment 2 adds one.
1/3 Write the stand-in rclone's listing whole in scenarios
Three scenarios wait for the stand-in rclone's file to appear, then read it; the shell creates it empty before ls or find writes, so under a full parallel run private-flip read an empty listing and failed. The stand-in now writes to a temporary file and renames it, or appends each run's listing at once.
| M | test/ | 3 | ++- |
| M | test/ | 3 | ++- |
| M | test/ | 3 | ++- |
@@ -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$'2/3 Give each removal of a repo its own place in the trash
The trash name has the time to the second, so two removals of one repo in the same second gave the same name, and mv put the second repo inside the first one's directory, where restore and gc never saw it. A name taken gets .2, .3 and so on, and restore picks the newest by the time and that suffix alone, since repo names may hold dots.
| M | bin/ | 11 | +++++++++-- |
| M | test/ | 13 | +++++++++++++ |
@@ -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"@@ -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)" second3/3 Deploy a commit, not the working tree, and only with a CI pass
deploy.sh rsynced the checkout as it was, so uncommitted edits went to slate with it; with two sessions in one checkout that nearly shipped another's unfinished work. It now exports a commit with git archive, HEAD by default, and wants a verified passing CI attestation for it, as main on the forge does with forge.requireci; --force ships one without. The commit deployed is written to /srv/forge/src/DEPLOYED.
| M | README.md | 8 | +++++++- |
| M | deploy/ | 28 | ++++++++++++++++++++++++++-- |
@@ -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@@ -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"