clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / test / lib.sh
lib.sh 16.4K
1# Scenario helpers. Each scenario is a script in test/scenarios/ that sources2# this file, which gives it a throwaway forge of its own: a root in a temp3# directory, a git config that ignores yours, and a fixed clock. Pushes go4# over the local file transport, so the real hooks run; `ssh_as` and5# `clone_ssh` go through `forge shell`, the forced command every key gets.6# Nothing is shared between scenarios, so they run in any order or at once.7#8# user ana a user with a fresh ed25519 key9# repo dami/greet [desc] a bare repo, as forge repo new makes it10# clone dami dami/greet w a working clone as dami (file transport)11# at w commit "msg" [file] commit in w (a new file each time by default)12# push_as ana w origin x:y git push from w as ana; hooks see FORGE_USER13# ssh_as ana land dami/greet 2 what `ssh git@host land ...` runs for ana14# clone_ssh ana dami/greet w clone over the forced command, as ana15# build [dami/greet] write pages (11ty) into $FORGE_WWW16# repo --private dami/secret a private repo (no git-daemon-export-ok)17# site_commit w "text" a commit of a one-page site, to push to refs/sites/...18# release_commit w v1 dir [key] a release commit of dir's files, to push to refs/releases/v119# 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 dir21# sync_builds hooks and ssh commands build pages before returning22# fake_rclone a stand-in rclone with its bucket in $T/bucket, for forge mirror23# synced [n] wait until forge mirror has finished n syncs (1)24# lq "<sql>" forge lake query: the rows as CSV, no header25# private_server lib/private-server.ts for this forge, at $PRIV26# caddy_server Caddy as forge server caddyfile has it, at $C27# signin ana ana adds a passkey and signs in at $PRIV: the session cookie28# csrf_of <cookie> the CSRF token /login/whoami gives that session's pages29# http [curl options] url an HTTP request: curl's options (test/http.ts)30# response url [http options] a whole HTTP response less its Date, to compare31#32# Assertions print what failed and stop the scenario:33# ok cmd... cmd succeeds fails cmd... cmd fails34# fails_with re cmd cmd fails, its output matches the regex re35# has file re file matches re lacks file re it doesn't36# exists path missing path eq got want37set -eu3839T_HOME=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)40FORGE=$T_HOME/bin/forge41T=$(mktemp -d "${TMPDIR:-/tmp}/forge-scenario.XXXXXX")42T=$(CDPATH= cd -- "$T" && pwd -P)43# As root where there is a git user (the container), forge restore hands44# the restored forge to git and finishes as git, as it does in production,45# so git must be able to get to it in here.46if [ "$(id -u)" = 0 ] && id -u git >/dev/null 2>&1; then chmod 755 "$T"; fi47# Processes a scenario starts in the background go in T_PIDS, to be stopped.48T_PIDS=49trap 'st=$?; [ -z "$T_PIDS" ] || kill $T_PIDS 2>/dev/null || true; [ -n "${FORGE_TEST_KEEP:-}" ] && echo "kept $T" >&2 || rm -rf "$T"; exit $st' EXIT50# FORGE_TEST_NODE: another node binary to run everything with, e.g. a newer51# one like the container's.52if [ -n "${FORGE_TEST_NODE:-}" ]; then53 mkdir -p "$T/node"54 ln -s "$FORGE_TEST_NODE" "$T/node/node"55 PATH=$T/node:$PATH56fi5758# Days and times in UTC, whatever the machine's zone: the activity grid59# puts a repo's events on the reader's local day (site/assets/ui.ts).60export TZ=UTC61export FORGE_ROOT=$T/root62export FORGE_WWW=$FORGE_ROOT/.forge/www63export FORGE_SSH=git@forge.test64export FORGE_NO_BUILD=165export FORGE_JEV=066# Scenarios run forge lake themselves, not in the background after builds.67export FORGE_LAKE=068export GIT_CONFIG_GLOBAL=$T/gitconfig69export GIT_CONFIG_NOSYSTEM=170export FORGE_CACHE=$T/cache71unset FORGE_USER FORGE_BASE_URL FORGE_PAGES_DOMAIN TYPESAFE_API_KEY 2>/dev/null || true7273cat >"$GIT_CONFIG_GLOBAL" <<'EOF'74[init]75 defaultBranch = main76[advice]77 detachedHead = false78[user]79 name = Test80 email = test@example.com81EOF8283# A fixed clock: commits and review logs get these times.84T_NOW=178822080085tick() {86 T_NOW=$((T_NOW + ${1:-60}))87 export GIT_AUTHOR_DATE="@$T_NOW +0000" GIT_COMMITTER_DATE="@$T_NOW +0000" FORGE_NOW=$T_NOW88}89tick 09091T_LOG=$T/out92note() { printf ' %s\n' "$*" >&2; }93fail() {94 printf 'FAIL: %s\n' "$*" >&295 if [ -s "$T_LOG" ]; then sed 's/^/ | /' "$T_LOG" | tail -n 20 >&2; fi96 exit 197}9899# run cmd...: run quietly, keep its output in $T_LOG for the assertions.100run() { "$@" >"$T_LOG" 2>&1; }101ok() { run "$@" || fail "expected success: $*"; }102fails() { if run "$@"; then fail "expected failure: $*"; fi; }103fails_with() {104 re=$1105 shift106 if run "$@"; then fail "expected failure: $*"; fi107 grep -Eq -- "$re" "$T_LOG" || fail "output of '$*' doesn't match /$re/"108}109output() { cat "$T_LOG"; }110has() { [ -f "$1" ] || fail "no file $1"; grep -Eq -- "$2" "$1" || fail "$1 doesn't match /$2/"; }111lacks() { if [ -f "$1" ] && grep -Eq -- "$2" "$1"; then fail "$1 matches /$2/"; fi; }112exists() { [ -e "$1" ] || [ -L "$1" ] || fail "missing $1"; }113missing() { if [ -e "$1" ] || [ -L "$1" ]; then fail "unexpected $1"; fi; }114eq() { [ "$1" = "$2" ] || fail "got '$1', want '$2'"; }115116forge() { "$FORGE" "$@"; }117118# A fresh forge: hooks, git-shell commands, keys.119"$FORGE" server init >/dev/null 2>&1120mkdir -p "$T/keys" "$T/w"121122user() {123 ssh-keygen -q -t ed25519 -N '' -C "$1@test" -f "$T/keys/$1"124 "$FORGE" user add "$1" "$T/keys/$1.pub" >/dev/null 2>&1125}126127repo() { "$FORGE" repo new "$@" >/dev/null 2>&1; }128129# The client side of ssh, without sshd: what authorized_keys runs for the130# key's user, with HOME at the forge, where git-shell finds its commands.131cat >"$T/ssh" <<EOF132#!/bin/sh133while :; do case \$1 in -o | -p | -i) shift 2 ;; -*) shift ;; *) break ;; esac; done134shift135HOME="$FORGE_ROOT" SSH_ORIGINAL_COMMAND=\$* exec "$FORGE" shell "\${FORGE_USER:?}"136EOF137chmod +x "$T/ssh"138139ssh_as() {140 u=$1141 shift142 (cd "$FORGE_ROOT" && HOME=$FORGE_ROOT FORGE_USER=$u SSH_ORIGINAL_COMMAND=$* "$FORGE" shell "$u")143}144145_client() { # dir user146 git -C "$1" config user.name "$2"147 git -C "$1" config user.email "$2@example.com"148 git -C "$1" config core.sshCommand "$T/ssh"149 git -C "$1" config forge.signingkey "$T/keys/$2"150 git -C "$1" config forge.allowedsigners "$FORGE_ROOT/.forge/allowed_signers"151}152153# clone user slug dir: over the file transport (no read check; for setup).154clone() {155 git clone -q "$FORGE_ROOT/$2.git" "$3" 2>/dev/null156 _client "$3" "$1"157}158159# clone_ssh user slug dir: through forge shell, as a real client would.160clone_ssh() {161 FORGE_USER=$1 git -c core.sshCommand="$T/ssh" clone -q "$FORGE_SSH:$2.git" "$3" && _client "$3" "$1"162}163164# fetch_ssh user slug: ls-remote through forge shell (a read).165fetch_ssh() { FORGE_USER=$1 git -c core.sshCommand="$T/ssh" ls-remote "$FORGE_SSH:$2.git"; }166167# at dir cmd...: run cmd in a working clone.168at() { d=$1; shift; (cd "$d" && "$@"); }169170T_N=0171commit() {172 tick173 T_N=$((T_N + 1))174 f=${2:-file$T_N.txt}175 mkdir -p "$(dirname "$f")"176 printf '%s %s\n' "$1" "$T_N" >>"$f"177 git add -A178 git commit -q -m "$1"179}180181# push_as user dir args...: the hooks see FORGE_USER, as forge shell sets it.182push_as() {183 u=$1 d=$2184 shift 2185 (cd "$d" && FORGE_USER=$u git push -q "$@")186}187188# push_ssh user dir args...: through the forced command (push-to-create, read checks).189push_ssh() {190 u=$1 d=$2191 shift 2192 (cd "$d" && FORGE_USER=$u git -c core.sshCommand="$T/ssh" push -q "$@")193}194195build() { "$FORGE" build "$@" >"$T_LOG" 2>&1 || fail "build ${*:-all} failed"; }196197# site_commit dir text: a commit whose tree is one index.html, as forge site198# push makes them.199site_commit() {200 (cd "$1" && b=$(printf '<p>%s</p>\n' "$2" | git hash-object -w --stdin) &&201 t=$(printf '100644 blob %s\tindex.html\n' "$b" | git mktree) && git commit-tree -m "site: $2" "$t")202}203204# mode path: its permission bits, in octal (GNU stat first: BSD's -f is205# GNU's file system status, which succeeds).206mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }207208t_sha256() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; }209210# release_sign dir key owner/repo tag [namespace]: dir/SHA256SUMS.sig, a211# signature of dir/SHA256SUMS for that release, as forge release publish212# signs it (a header naming the repo and tag, then SHA256SUMS).213release_sign() {214 { printf 'forge-release 1\nrepo %s\ntag %s\n' "$3" "$4"; cat "$1/SHA256SUMS"; } >"$T/message"215 rm -f "$T/message.sig"216 ssh-keygen -q -Y sign -n "${5:-forge-release}" -f "$2" "$T/message" 2>/dev/null217 mv "$T/message.sig" "$1/SHA256SUMS.sig"218}219220# release_commit dir tag files-dir [key|-]: a commit for refs/releases/<tag>221# made by hand, the way forge release publish makes one, from every file in222# files-dir. SHA256SUMS and SHA256SUMS.sig (signed with key, dami's by223# default, for the clone's repo or R_REPO) are written unless files-dir has224# them already; with - there is no signature. R_TAG and R_SRC set other Tag225# and Source-Commit trailers.226release_commit() {227 (228 cd "$1" || exit 1229 src=$(git rev-parse "refs/tags/$2^{commit}")230 k=${4:-$T/keys/dami}231 if [ ! -f "$3/SHA256SUMS" ]; then232 for f in "$3"/*; do printf '%s %s\n' "$(t_sha256 "$f")" "${f##*/}"; done >"$T/sums.tmp"233 mv "$T/sums.tmp" "$3/SHA256SUMS"234 fi235 slug=${R_REPO:-$(git remote get-url origin | sed "s|^$FORGE_ROOT/||; s|\.git\$||")}236 if [ "$k" != - ] && [ ! -f "$3/SHA256SUMS.sig" ]; then release_sign "$3" "$k" "$slug" "$2"; fi237 t=$(for f in "$3"/*; do printf '100644 blob %s\t%s\n' "$(git hash-object -w "$f")" "${f##*/}"; done | git mktree)238 printf 'release %s\n\nTag: %s\nSource-Commit: %s\nBuilt-By: test\n' "$2" "${R_TAG:-$2}" "${R_SRC:-$src}" | git commit-tree "$t"239 )240}241242# aside slug, back slug: take a repo out of the forge and put it back, so a243# scenario can see what a missing repo of the same name looks like.244aside() { mv "$FORGE_ROOT/$1.git" "$T/aside.git"; }245back() { mv "$T/aside.git" "$FORGE_ROOT/$1.git"; }246247# restore_into dir args...: forge restore into a new forge at dir, with248# its pages and caches inside it, as on a new host. (As root, git finishes249# the restore there, and couldn't write this forge's caches.)250restore_into() {251 rd=$1252 shift253 env FORGE_ROOT="$rd" FORGE_WWW="$rd/.forge/www" FORGE_CACHE="$rd/.forge/cache" "$FORGE" restore "$@"254}255256# fake_rclone: a stand-in for rclone on PATH, doing what forge mirror asks257# of the real one, with the bucket a directory, $T/bucket. Each call is a258# line in $T/rclone.log; each file it uploads (one that differs from the259# bucket's) a line in $T/rclone.put, and each it deletes one in260# $T/rclone.deleted. --dry-run only logs. FORGE_MIRROR_S3 points at it.261fake_rclone() {262 mkdir -p "$T/bin" "$T/bucket"263 : >"$T/rclone.log"264 : >"$T/rclone.put"265 : >"$T/rclone.deleted"266 {267 printf '#!/bin/sh\nB=%s/bucket L=%s/rclone\n' "$T" "$T"268 cat <<'EOF'269printf '%s\n' "$*" >>"$L.log"270op=$1 src=$2 list= dry= filters=271shift 2272case $1 in :s3:*) shift ;; esac273while [ $# -gt 0 ]; do274 case $1 in275 --files-from-raw) list=$2; shift ;;276 --dry-run) dry=1 ;;277 --filter) filters=1; shift ;;278 esac279 shift280done281put() {282 cmp -s "$src/$1" "$B/$1" && return 0283 mkdir -p "$(dirname "$B/$1")" && cp "$src/$1" "$B/$1" && printf '%s\n' "$1" >>"$L.put"284}285case $op in286lsf) cd "$B" && find . -type f | sed 's|^\./||' ;;287delete)288 [ -n "$dry" ] && exit 0289 while IFS= read -r f; do rm -f "$B/$f" && printf '%s\n' "$f" >>"$L.deleted"; done <"$list"290 # A bucket has no directories.291 find "$B" -mindepth 1 -type d -empty -delete ;;292sync)293 [ -n "$dry" ] && exit 0294 if [ -n "$list" ]; then295 while IFS= read -r f; do put "$f" || exit 1; done <"$list"296 elif [ -n "$filters" ]; then297 # The release downloads' pass.298 (cd "$src" && find . -path './*/*/releases/download/*' -type f | sed 's|^\./||') | while IFS= read -r f; do put "$f" || exit 1; done299 fi ;;300*) echo "fake rclone: no $op" >&2; exit 1 ;;301esac302EOF303 } >"$T/bin/rclone"304 chmod +x "$T/bin/rclone"305 export PATH="$T/bin:$PATH" FORGE_MIRROR_S3=https://r2.test/pages306}307308# synced [n]: wait (30s at most) until forge mirror has logged n syncs.309synced() {310 sn=0311 until [ "$(grep -c 'mirror: synced' "$FORGE_ROOT/.forge/mirror.log" 2>/dev/null)" -ge "${1:-1}" ] || [ "$sn" -ge 300 ]; do sleep 0.1; sn=$((sn + 1)); done312 [ "$sn" -lt 300 ] || fail "no sync ${1:-1} in mirror.log"313}314315# From here on, what hooks and ssh commands rebuild is built before they return.316sync_builds() { export FORGE_NO_BUILD=0 FORGE_SYNC_BUILD=1; }317318lq() {319 "$FORGE" lake query "$1" --csv >"$T/lq" 2>"$T_LOG" || fail "forge lake query failed: $1"320 sed 1d "$T/lq"321}322323# free_port: a TCP port nothing listens on just now.324free_port() { node -e 'const s = require("net").createServer().listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); })'; }325326# http [options] url: the curl options scenarios use, from test/http.ts, so327# they run where curl isn't installed (the forge's container).328http() { node "$T_HOME/test/http.ts" "$@"; }329330# wait_http url: until something answers there (5s at most).331wait_http() {332 n=0333 until http -s -o /dev/null "$1" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done334}335336# private_server: lib/private-server.ts on a free port, in the background,337# for this forge (with FORGE_BASE_URL as it is now). Sets PRIV, its URL.338# Scenarios run at once, so another one's server can take the free port339# first; then this one's exits (EADDRINUSE), and it tries another port340# rather than talk to someone else's forge.341private_server() {342 n=0343 while :; do344 PRIV_PORT=$(free_port)345 log=$T/private.$PRIV_PORT.log346 FORGE_PRIVATE_PORT=$PRIV_PORT node "$T_HOME/lib/private-server.ts" >"$log" 2>&1 &347 pid=$!348 k=0349 until grep -q "^private pages on 127.0.0.1:$PRIV_PORT" "$log" || ! kill -0 "$pid" 2>/dev/null || [ "$k" -ge 150 ]; do350 sleep 0.1351 k=$((k + 1))352 done353 if kill -0 "$pid" 2>/dev/null && grep -q "^private pages on" "$log"; then break; fi354 kill "$pid" 2>/dev/null || true355 n=$((n + 1))356 [ "$n" -lt 5 ] || { cat "$log" >&2; fail "the private server did not start"; }357 done358 T_PIDS="$T_PIDS $pid"359 PRIV=http://127.0.0.1:$PRIV_PORT360}361362# caddy_server: Caddy with forge server caddyfile's config (no pages363# domain), on a free port in the background, in front of a private server364# it starts. Sets C, its URL. Without caddy installed the scenario stops365# here, passing, and says so. As with private_server, another scenario can366# take the port first; then this Caddy exits, and it tries another. It367# binds 127.0.0.1 only: bound to every address, the port is still free for368# another scenario's server to take on 127.0.0.1, and with it the requests.369caddy_server() {370 command -v caddy >/dev/null 2>&1 || { note "caddy is not installed: skipped running it"; exit 0; }371 private_server372 n=0373 while :; do374 cport=$(free_port)375 {376 printf '{\n\tadmin off\n\tauto_https off\n\tpersist_config off\n\tdefault_bind 127.0.0.1\n}\n\n'377 FORGE_PRIVATE_PORT=$PRIV_PORT "$FORGE" server caddyfile "http://127.0.0.1:$cport"378 } >"$T/Caddyfile.run"379 XDG_DATA_HOME=$T/xdg XDG_CONFIG_HOME=$T/xdg caddy run --config "$T/Caddyfile.run" --adapter caddyfile >"$T/caddy.log" 2>&1 &380 pid=$!381 k=0382 until grep -q 'serving initial configuration' "$T/caddy.log" || ! kill -0 "$pid" 2>/dev/null || [ "$k" -ge 100 ]; do383 sleep 0.1384 k=$((k + 1))385 done386 if kill -0 "$pid" 2>/dev/null && grep -q 'serving initial configuration' "$T/caddy.log"; then break; fi387 kill "$pid" 2>/dev/null || true388 n=$((n + 1))389 [ "$n" -lt 5 ] || { cat "$T/caddy.log" >&2; fail "caddy did not start"; }390 done391 T_PIDS="$T_PIDS $pid"392 C=http://127.0.0.1:$cport393 wait_http "$C/"394}395396# stop_servers: stop what the scenario started in the background so far.397stop_servers() {398 [ -z "$T_PIDS" ] || { kill $T_PIDS 2>/dev/null || true; wait $T_PIDS 2>/dev/null || true; }399 T_PIDS=400}401402# response url [http options...]: the whole HTTP response (status line,403# headers, body) less its Date, to compare byte for byte.404response() {405 u=$1406 shift407 http -s -i "$@" "$u" | grep -av '^Date: '408}409410# signin user: a passkey for user from the link ssh passkey prints, added411# and signed in with at $PRIV by the software authenticator, which keeps it412# in $T/<user>.key. Prints the session cookie's value.413signin() {414 code=$(ssh_as "$1" passkey | sed -n 's/^ .*#enroll=//p')415 node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/$1.key"416}417418# csrf_of cookie: the CSRF token the session's pages post with, as their419# script gets it from /login/whoami.420csrf_of() {421 http -s -H "Cookie: __Host-forge=$1" "$PRIV/login/whoami" | node -e 'let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => console.log(JSON.parse(s).csrf ?? ""))'422}