#!/bin/sh
# Deploy trunk to zeitforge.app (ticket 8eb0ca531a).
#
# 0. take the deploy lock: two deploys never overlap
# 1. (optionally) pull the store from another forge — this one is the origin
# 2. open trunk into a scratch tree; with --require-green, refuse unless
# trunk's required checks are green on that exact check-in
# 3. build there, in a golang container
# 4. refuse below a free-space floor (JetStream will not start on a full disk)
# 5. install beside the running binary, keep the previous one, restart;
# the runner downloads only as a complete, verified set
# 6. health-check; if the new binary does not answer, put the old one back
# 7. restart the CI runner last, delayed, so a deploy it ran can report
#
# deploy.sh [--dry-run] [--require-green] [--expect HASH]
# deploy.sh [--dry-run] --release X.Y.Z
# deploy.sh --rollback
#
# --dry-run build (or fetch) and verify only: install nothing, restart nothing
# --require-green refuse unless every check trunk requires is green on
# the check-in about to be deployed (asked of the forge)
# --expect HASH refuse unless trunk's tip is HASH (a prefix will do):
# a CI deploy ships the check-in it tested, never a newer one
# --release X.Y.Z deploy a published release instead of building trunk:
# fetch releases/X.Y.Z/ from the forge's own unversioned
# files, verify every line of its SHA256SUMS, then install,
# restart and health-check as a build would (RFC 0020)
# --rollback put quarry.prev (and zit.prev, runner.prev) back and restart: for
# when the forge cannot serve a release to deploy
#
# Every way this ends badly ends the same way: exit non-zero, one line on
# stderr naming the stage, and the same line in the journal under
# `logger -t quarry-deploy` as "FAILED stage=<stage> ...". A deploy run as
# quarry-deploy@<hash>.service is alerted on by its OnFailure=; a deploy
# run by hand is picked up from the journal by quarry-deploy-watch.timer
# (scripts/systemd, docs/running.md).
set -eu
# Every path is overridable so scripts/deploy-test.sh can run the whole
# script against temp directories and stubs; on the host none is set.
STORE=${QUARRY_DEPLOY_STORE:-/srv/quarry/repos/quarry}
BIN=${QUARRY_DEPLOY_BIN:-/srv/quarry/bin}
ZITREL=${QUARRY_DEPLOY_ZITREL:-/home/dmestas/zit-release}
TMP=${QUARRY_DEPLOY_TMP:-/tmp}
FLOOR_KB=$((3*1024*1024))
LOCK=${QUARRY_DEPLOY_LOCK:-/run/quarry-deploy.lock}
STATE=${QUARRY_DEPLOY_STATE:-/var/lib/quarry-deploy/state}
API=${QUARRY_DEPLOY_API:-http://127.0.0.1:8950}
usage="usage: deploy.sh [--dry-run] [--require-green] [--expect HASH] | [--dry-run] --release X.Y.Z | --rollback"
dry=; green=; expect=; rel=; rollback=
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry=1 ;;
--require-green) green=1 ;;
--expect)
# At least the 12 characters zit prints, so a prefix can never
# name two check-ins; compared against the full hash below.
case "${2:-}" in
*[!0-9a-f]* | "") echo "deploy: --expect needs a lowercase hex hash" >&2; exit 2 ;;
esac
[ "${#2}" -ge 12 ] || { echo "deploy: --expect needs at least 12 hex characters" >&2; exit 2; }
expect=$2; shift ;;
--release)
# The release's folder name, and so part of every URL fetched.
printf '%s\n' "${2:-}" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+' || { echo "deploy: --release needs a version, X.Y.Z" >&2; exit 2; }
rel=$2; shift ;;
--rollback) rollback=1 ;;
*) echo "$usage" >&2; exit 2 ;;
esac
shift
done
# A release is built already and was checked when it was cut: there is
# no trunk tip to expect and no check-in to ask the forge about, and
# accepting the flags would read as a check that never ran.
if [ -n "$rel" ] && [ -n "$green$expect" ]; then
echo "deploy: --release deploys a published build; --require-green and --expect check a trunk build and do not apply" >&2
exit 2
fi
if [ -n "$rollback" ] && [ -n "$rel$green$expect$dry" ]; then
echo "deploy: --rollback takes no other flag; to deploy an older release, use --release X.Y.Z" >&2
exit 2
fi
# stage is what the deploy is doing now; the exit trap names it when the
# script dies somewhere nobody wrote a message for (set -e). The state
# file carries it too, for the watcher: a state that is neither ok nor
# failed, with the lock free, is a deploy that died without saying so.
id="$(date +%s)-$$"
stage=start
tip=
# [email protected] sets QUARRY_DEPLOY_VIA=unit: its OnFailure=
# alerts for it, and the watcher alerts only for the rest.
via=${QUARRY_DEPLOY_VIA:-manual}
said=
work=
check=
say_state() {
[ -n "$dry" ] && return 0
mkdir -p "$(dirname "$STATE")" 2>/dev/null || true
printf '%s %s %s\n' "$id" "$1" "${tip:-unknown}" >"$STATE" 2>/dev/null || true
}
at() { stage=$1; say_state "$1"; }
fail() {
said=1
echo "deploy: FAILED at $stage: $1" >&2
logger -t quarry-deploy "FAILED stage=$stage tip=${tip:-unknown} id=$id via=$via${dry:+ dry-run}: $1" 2>/dev/null || true
say_state failed
exit 1
}
on_exit() {
rc=$?
[ -n "$work" ] && rm -rf "$work"
[ -n "$check" ] && rm -rf "$check"
if [ "$rc" -ne 0 ] && [ -z "$said" ]; then
said=1
echo "deploy: FAILED at $stage: exited $rc" >&2
logger -t quarry-deploy "FAILED stage=$stage tip=${tip:-unknown} id=$id via=$via${dry:+ dry-run}: exited $rc" 2>/dev/null || true
say_state failed
fi
}
trap on_exit EXIT
trap 'exit 130' INT TERM HUP
answers() {
for _ in $(seq 1 30); do
if curl -sf -m 3 -o /dev/null http://127.0.0.1:8950/; then return 0; fi
sleep 2
done
return 1
}
# install_set puts $work/bin's quarry, zit and runner set in place, the
# one install the build path and the release path share. The previous
# ones are kept as quarry.prev, zit.prev and runner.prev, always the
# three that ran together one deploy ago (quarry is built against one
# zit, and the runner downloads are that build's), so a rollback puts
# back a set that matches. The runner downloads (served at /runner/ from
# $BIN/runner, the directory beside the binary) swap whole, so a visitor
# never gets one platform's new build beside another's old one; a build
# that predates them, or whose set is incomplete ($runner_bad), leaves
# the installed set alone, and runner.prev a copy of it.
install_set() {
at install
cp -p "$BIN/quarry" "$BIN/quarry.prev" 2>/dev/null || true
install -m 0755 "$work/bin/quarry" "$BIN/quarry.new" && mv -f "$BIN/quarry.new" "$BIN/quarry"
cp -p "$BIN/zit" "$BIN/zit.prev" 2>/dev/null || true
install -m 0755 "$work/bin/zit" "$BIN/zit"
rm -rf "$BIN/runner.prev"
if [ -d "$work/bin/runner" ] && [ -z "$runner_bad" ]; then
rm -rf "$BIN/runner.new" && cp -r "$work/bin/runner" "$BIN/runner.new" && chmod 0755 "$BIN/runner.new"/quarry-runner-*
if [ -d "$BIN/runner" ]; then mv "$BIN/runner" "$BIN/runner.prev"; fi
mv "$BIN/runner.new" "$BIN/runner"
elif [ -d "$BIN/runner" ]; then
cp -Rp "$BIN/runner" "$BIN/runner.prev"
fi
}
# go_live restarts onto what is installed and sees it through: health
# check (rolling back to quarry.prev on silence), ready, the edge, and the
# CI runner last and delayed. Every path ends here.
go_live() {
at restart
systemctl restart quarry.service
at health
if ! answers; then
# --rollback has just put quarry.prev in place: there is nothing older
# to fall back to.
[ -z "$rollback" ] || fail "the reinstated previous binary did not answer in 60s; quarry is down"
echo "deploy: new binary did not answer in 60s - rolling back" >&2
at rollback
if [ -f "$BIN/zit.prev" ]; then mv -f "$BIN/zit.prev" "$BIN/zit" || true; fi
if [ -d "$BIN/runner.prev" ]; then rm -rf "$BIN/runner" && mv "$BIN/runner.prev" "$BIN/runner" || true; fi
if ! { mv -f "$BIN/quarry.prev" "$BIN/quarry" && systemctl restart quarry.service; }; then
fail "could not put the previous binary back; quarry may be down"
fi
answers || fail "put the previous binary back and it does not answer either; quarry is down"
stage=health
fail "new binary did not answer in 60s; rolled back to the previous one"
fi
# The port answering is not the forge answering. After a restart it
# registers its repositories one by one — 65-75s for two dozen — and
# refuses every sync and every repository page as "still starting" for
# that whole window, while / answers 200. This script used to print
# success at the top of that window, so the operator's last signal was
# "serving" while the forge was down for another minute (b81ae623).
# So: wait for ready, say how long it took, and refuse to call a forge
# that never gets there healthy.
at ready
started=$(date +%s)
ready=
for _ in $(seq 1 150); do
if curl -sf -m 3 http://127.0.0.1:8950/api/v1/version | grep -q '"ready":true'; then ready=1; break; fi
sleep 2
done
waited=$(( $(date +%s) - started ))
if [ "$ready" != 1 ]; then
echo "deploy: NOT rolling back — the binary answers; registration is what is slow. Watch: journalctl -u quarry -f" >&2
fail "forge is up but not ready after ${waited}s; still starting: $(curl -s -m 3 http://127.0.0.1:8950/api/v1/version | grep -o '"starting":\[[^]]*\]')"
fi
echo "deploy: zeitforge.app serving $(curl -s http://127.0.0.1:8950/ | grep -o 'quarry 0[^<]*' | head -1 | sed 's/+/+/') — ready after ${waited}s"
# 8. warm the edge: every deploy renames the hashed assets, so the first
# visitor at each Cloudflare location would otherwise pay the miss.
# Best-effort: a cold edge is slow, not broken.
at warm
warm() { curl -s -m 10 -o /dev/null -H "Accept-Encoding: gzip" "https://zeitforge.app$1" || true; }
warm / ; warm /start
for a in $(curl -s -m 10 https://zeitforge.app/ | grep -o "/assets/[^\"]*" | sort -u); do warm "$a"; done
echo "deploy: edge warmed"
# quarry's own CI runs on `quarry runner` (quarry-runner.service, unit in
# /etc/systemd/system, token in /etc/quarry-runner.env). It is this same
# binary, so it restarts onto the new build — LAST, and five seconds from
# now rather than now: a deploy the runner itself started is a child of
# that runner, and restarting it inline would kill the deploy before it
# could report. systemd-run hands the restart to a transient timer that
# outlives this script. A host that never installed the runner unit
# skips this quietly.
restart_bad=
if systemctl cat quarry-runner.service >/dev/null 2>&1; then
if systemd-run --quiet --on-active=5 /bin/systemctl restart quarry-runner.service; then
echo "deploy: quarry runner restarts in 5s"
else
restart_bad=1
fi
fi
if [ -n "$runner_bad" ]; then
stage=runner
fail "forge deployed and ready, but the runner downloads were NOT published (kept the previous set): $runner_bad${restart_bad:+; and the quarry-runner restart could not be scheduled}"
fi
if [ -n "$restart_bad" ]; then
stage=runner-restart
fail "forge deployed and ready, but the delayed quarry-runner restart could not be scheduled; the runner is still on the previous binary"
fi
said=1
logger -t quarry-deploy "OK id=$id via=$via tip=$tip ready=${waited}s" 2>/dev/null || true
say_state ok
}
# The lock. flock on fd 9, opened for APPEND so a second deploy does not
# truncate the first one's note of when it started; only the holder
# rewrites it. /run is tmpfs, so a reboot is the one thing that clears
# it, and the kernel drops the lock with the last descriptor — a killed
# deploy never leaves it held.
stage=lock
exec 9>>"$LOCK"
if ! flock -n 9; then
said=1
echo "deploy: another deploy is running — $(cat "$LOCK" 2>/dev/null || echo "since an unknown time")" >&2
exit 1
fi
printf 'since %s (pid %s, id %s)\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$id" >"$LOCK"
at start
logger -t quarry-deploy "START id=$id via=$via${dry:+ dry-run}" 2>/dev/null || true
at space
free_kb() { df -k / | awk 'NR==2 {print $4}'; }
if [ "$(free_kb)" -lt "$FLOOR_KB" ]; then
fail "$(( $(free_kb) / 1024 )) MB free, need $(( FLOOR_KB / 1024 )) MB"
fi
# fetch_release fills $work/bin from releases/$rel/ in the forge's own
# unversioned files exactly as a build fills it, and installs nothing.
# Every file SHA256SUMS lists is fetched and every line verified before
# anything is unpacked. The forge answers over localhost; a download is
# a redirect to the repository's bucket, so curl follows it.
fetch_release() {
# The release must be trunk's: version-X.Y.Z on exactly one check-in,
# and that one on trunk. The tag is not a gate of its own, since anyone
# who can push can tag a check-in on their branch; trunk review is the
# gate, and this ties the release to it. The scripts read from that
# check-in below run as quarry, never as root.
at tag
zt() { sudo -u quarry "$BIN/zit" "$@" 2>/dev/null; }
carriers=$(zt timeline -R "$STORE/.zit" -n 0 -F medium) || fail "zit timeline of the store failed"
n=$(printf '%s\n' "$carriers" | sed -n 's/.*tags: \(.*\))$/, \1,/p' | grep -cF ", version-$rel,") || true
[ "$n" -gt 0 ] || fail "the store has no check-in tagged version-$rel: pull the store, or the release is not tagged yet"
[ "$n" -eq 1 ] || fail "$n check-ins carry version-$rel; a release names one"
info=$(zt info -R "$STORE/.zit" "tag:version-$rel") || fail "zit info tag:version-$rel failed"
carrier=$(printf '%s\n' "$info" | head -1)
on=$(printf '%s\n' "$info" | sed -n 's/^branch: *//p')
[ "$on" = trunk ] || fail "version-$rel is on ${on:-no branch}, not trunk; a release is cut from trunk"
# A branch label is a claim anyone can push (a fork leaf saying
# *branch trunk); being in trunk's history is not. The carrier must be
# trunk's tip or an ancestor of it, trunk resolved as the build path's
# `zit open trunk` resolves it.
history=$(zt log -R "$STORE/.zit" -n 0 trunk) || fail "zit log trunk failed"
printf '%s\n' "$history" | grep -qxF "checkin $carrier" ||
fail "version-$rel's check-in $(printf '%.12s' "$carrier") is labelled trunk but is not in trunk's history; a release is cut from trunk"
at fetch
# Root's, 0755: quarry reads what the check below needs and changes
# nothing here, so what was verified is what is installed.
work=$(mktemp -d "$TMP/quarry-deploy.XXXXXX")
chmod 0755 "$work"
base="$API/r/$(basename "$STORE")/uv/releases/$rel"
dl=$work/dl
mkdir -p "$dl" "$work/bin/runner"
curl -fsSL -m 60 -o "$dl/SHA256SUMS" "$base/SHA256SUMS" || fail "the forge has no releases/$rel/SHA256SUMS"
# Each name goes into a path and a URL, so each is checked first: a
# sum and a bare file name, one per line, or the release is refused.
awk 'NF != 2 || length($1) != 64 || $1 ~ /[^0-9a-f]/ || $2 !~ /^\*?[A-Za-z0-9_][A-Za-z0-9._-]*$/ {bad=1} END {exit bad || NR == 0}' \
"$dl/SHA256SUMS" || fail "releases/$rel/SHA256SUMS is not one sum and one file name per line"
names=$(awk '{sub(/^\*/, "", $2); print $2}' "$dl/SHA256SUMS")
for f in $names; do
curl -fsSL -m 300 -o "$dl/$f" "$base/$f" || fail "could not fetch releases/$rel/$f"
done
at verify
( cd "$dl" && sha256sum -c --quiet SHA256SUMS ) >/dev/null 2>&1 || fail "releases/$rel does not match its SHA256SUMS; nothing installed"
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) fail "releases carry no forge build for $(uname -m)" ;;
esac
tarball=quarry-$rel-linux-$arch.tar.gz
[ -f "$dl/$tarball" ] || fail "releases/$rel has no $tarball"
# The layout release.sh writes (docs/running.md, "Releases and
# rollback"): quarry-X.Y.Z-linux-<amd64|arm64>.tar.gz holding exactly
# one directory, quarry-X.Y.Z-linux-<arch>/, with quarry, zit,
# CHANGELOG.md and LICENSE in it. Anything else is refused rather than
# searched for a binary.
top=quarry-$rel-linux-$arch
mkdir "$work/x"
tar -xzf "$dl/$tarball" -C "$work/x" || fail "could not unpack $tarball"
[ "$(ls -A "$work/x")" = "$top" ] || fail "$tarball is not the release layout: one directory, $top/"
for b in quarry zit; do
[ -f "$work/x/$top/$b" ] || fail "$tarball is not the release layout: no $top/$b"
install -m 0755 "$work/x/$top/$b" "$work/bin/$b"
done
got=$("$work/bin/quarry" version 2>/dev/null) || fail "the release's quarry does not run here"
[ "$got" = "quarry $rel" ] || fail "the release's quarry says \"$got\", not quarry $rel"
# The runner downloads are every quarry-runner file the release lists,
# with their own lines of its SHA256SUMS: the same set, served the same
# way, as a build's.
for f in $names; do
case "$f" in quarry-runner-*) cp "$dl/$f" "$work/bin/runner/" ;; esac
done
grep -E '^[0-9a-f]+ [ *]quarry-runner-' "$dl/SHA256SUMS" >"$work/bin/runner/SHA256SUMS" || fail "releases/$rel has no runner downloads"
# The same check a build's set gets, by the release's own tree: every
# platform build-linux.sh at the tag version-X.Y.Z builds. An
# incomplete set is refused whole: a release is not installed half.
for f in build-linux.sh runner-check.sh; do
# The redirect is this script's on purpose: the copy is root's, read-only to quarry.
# shellcheck disable=SC2024
sudo -u quarry "$BIN/zit" cat -R "$STORE/.zit" "scripts/$f" "tag:version-$rel" >"$work/$f" 2>/dev/null ||
fail "the store has no scripts/$f at tag version-$rel to check the release against"
done
# Code from the store runs as quarry, as the trunk path's zit does,
# and on a copy outside $work: it is handed nothing that will be
# installed, and $work, root's and 0755, is not its to change.
if ! { check=$(mktemp -d "$TMP/quarry-check.XXXXXX") && chmod 0755 "$check" &&
cp -R "$work/bin/runner" "$work/runner-check.sh" "$work/build-linux.sh" "$check/"; }; then
fail "could not copy the runner set for its check"
fi
bad=$(cd "$check" && sudo -u quarry sh "$check/runner-check.sh" "$check/runner" "$check/build-linux.sh") ||
fail "releases/$rel's runner downloads are not the set version-$rel builds: $bad; nothing installed"
}
# --rollback: the binary before the last install, without the forge. The
# health check cannot fall back from here, so it fails loudly instead.
if [ -n "$rollback" ]; then
at reinstate
[ -f "$BIN/quarry.prev" ] || fail "there is no $BIN/quarry.prev to put back"
tip=$("$BIN/quarry.prev" version 2>/dev/null | awk '{print $NF; exit}')
[ -n "$tip" ] || fail "$BIN/quarry.prev does not run"
mv -f "$BIN/quarry.prev" "$BIN/quarry"
# A quarry.prev from before zit.prev was kept has none: its zit stays.
if [ -f "$BIN/zit.prev" ]; then
mv -f "$BIN/zit.prev" "$BIN/zit"
fi
if [ -d "$BIN/runner.prev" ]; then
rm -rf "$BIN/runner" && mv "$BIN/runner.prev" "$BIN/runner"
fi
runner_bad=
go_live
exit 0
fi
# --release: a published build, verified, installed as a build is.
if [ -n "$rel" ]; then
tip=$rel
fetch_release
runner_bad=
if [ -n "$dry" ]; then
logger -t quarry-deploy "OK id=$id via=$via dry-run tip=$tip" 2>/dev/null || true
said=1
echo "deploy: dry run — release $rel fetched and verified; nothing installed, nothing restarted"
exit 0
fi
install_set
go_live
exit 0
fi
# This store is the ORIGIN: landings are accepted here and the merge
# is already in it. QUARRY_UPSTREAM is only for pulling from another
# forge on purpose (a migration); unset, nothing is pulled. A dry run
# changes nothing, the store included.
if [ -n "${QUARRY_UPSTREAM:-}" ] && [ -z "$dry" ]; then
at pull
n=0
until sudo -u quarry sh -c "cd $STORE && $BIN/zit pull $QUARRY_UPSTREAM 2>&1 | tail -1 | tee /dev/stderr | grep -qvE 'refused|failed|error|cannot'"; do
n=$((n+1)); [ "$n" -lt 8 ] || fail "pull from $QUARRY_UPSTREAM kept failing"
sleep 8
done
fi
at open
work=$(mktemp -d "$TMP/quarry-deploy.XXXXXX")
chown quarry:quarry "$work"
sudo -u quarry sh -c "cd $work && $BIN/zit open trunk --store=$STORE/.zit >/dev/null"
# The FULL hash: `zit status` prints only a 12-character prefix, and a
# full --expect never matches a prefix. `zit info current` names the
# checkout's check-in in full on its first line (BARE `zit info` starts
# with "repository:" instead). scripts/deploy-test.sh holds this to the
# real zit.
full=$(cd "$work" && sudo -u quarry "$BIN/zit" info current | head -1)
case "$full" in
*[!0-9a-f]* | "") fail "no check-in hash in the scratch tree (zit info current said: $full)" ;;
esac
[ "${#full}" -eq 40 ] || [ "${#full}" -eq 64 ] || fail "zit info current named $full, not a full hash"
tip=$(printf '%s' "$full" | cut -c1-12)
say_state open
if [ -n "$expect" ]; then
case "$full" in
"$expect"*) ;;
*) fail "trunk is at $tip, not $expect — trunk moved since it was tested" ;;
esac
fi
# --require-green asks the forge, not the tree: which checks trunk
# requires is policy the forge keeps (RFC 0009), and whether each is
# green on this exact check-in is the forge's answer too, by the rule a
# landing's accept uses (checkin.get's required and unmet). Nothing is
# hard-coded here. A trunk that requires nothing is refused: "green" of
# nothing is not a reason to ship.
if [ -n "$green" ]; then
at green
js=$(curl -sf -m 10 "$API/api/v1/r/$(basename "$STORE")/checkin/$full") || fail "the forge did not answer checkin.get for $tip"
req=$(printf '%s' "$js" | jq -r 'if (.required|type) == "array" then .required|join(" ") else "?" end')
unmet=$(printf '%s' "$js" | jq -r 'if (.unmet|type) == "array" then .unmet|join(" ") else "?" end')
[ "$req" != "?" ] || fail "the forge's checkin.get names no required checks (a forge older than this script)"
[ -n "$req" ] || fail "trunk requires no checks; --require-green has nothing to require"
[ -z "$unmet" ] || fail "required checks not green on $tip: $unmet (required: $req)"
echo "deploy: $tip is green for trunk ($req)"
fi
# This script is BOTH tracked here and installed at /srv/quarry/deploy.sh,
# and the installed copy is the one that runs. So the two can drift, and
# on 2026-09-19 they did: the installed copy was edited by hand to build
# and install the CI worker, with no review possible because the file was
# not in the repository. That is the same silence that let the worker go
# fifteen days and one protocol migration without being rebuilt.
#
# Warn, never fail. A deploy that refuses to run because its own script
# disagrees with the tree is worse than one that tells you they disagree
# — and this script must keep working when the tree's copy is the broken
# one, which is exactly when you need to deploy.
if [ -f "$work/scripts/deploy.sh" ] && ! cmp -s "$0" "$work/scripts/deploy.sh"; then
echo "deploy: WARNING — $0 differs from scripts/deploy.sh in trunk $tip" >&2
echo "deploy: diff -u $0 $work/scripts/deploy.sh" >&2
echo "deploy: install -m 0755 $work/scripts/deploy.sh $0 # once the tree's copy is the right one" >&2
fi
# The build image is the Go that go.mod names — one pin, not two. And
# every deploy looks for a newer stable Go: quarry is meant to track the
# latest (e3dfc93f), and a warning here is the one place an operator
# reads on every release. Warn, never fail, like the check above.
gover=$(sed -n 's/^go //p' "$work/go.mod")
latest=$(curl -s -m 10 'https://go.dev/dl/?mode=json' | grep -o '"version": *"go[0-9.]*"' | head -1 | grep -o '[0-9][0-9.]*') || true
if [ -n "$latest" ] && [ "$latest" != "$gover" ]; then
echo "deploy: WARNING — go.mod builds with Go $gover; Go $latest is the latest stable. Bump it: go mod edit -go=$latest && go mod tidy" >&2
fi
at build
# The stamp is the tree's own rule (scripts/version-stamp.sh, RFC 0020):
# X.Y.Z when trunk's tip carries the tag version-X.Y.Z, else
# X.Y.Z-dev+<tip>. A tree from before VERSION keeps the stamp it had.
stamp="0.1.0-beta+$tip"
if [ -f "$work/VERSION" ] && [ -f "$work/scripts/version-stamp.sh" ]; then
# The tags the tip carries itself, propagating ones included and
# inherited ones not: its raw tags, its parent's, and its own singleton
# names (version-stamp.sh). The stamp never
# blocks a deploy: a lookup that fails stamps X.Y.Z-dev+<tip>, with a
# warning, since half an answer could read an inherited tag as its own.
tagged=
if info=$(cd "$work" && sudo -u quarry "$BIN/zit" info "$full") &&
raw=$(cd "$work" && sudo -u quarry "$BIN/zit" tag list --raw "$full"); then
parent=$(printf '%s\n' "$info" | sed -n 's/^parent: *//p')
praw=
if { [ -z "$parent" ] || praw=$(cd "$work" && sudo -u quarry "$BIN/zit" tag list --raw "$parent"); } &&
plain=$(cd "$work" && sudo -u quarry "$BIN/zit" tag list "$full"); then
tagged=$(printf '%s\n--\n%s\n--\n%s\n' "$raw" "$praw" "$plain")
fi
fi
[ -n "$tagged" ] || { tagged=--; echo "deploy: WARNING — could not read $tip's tags; stamping it as a -dev build" >&2; }
stamp=$(printf '%s\n' "$tagged" | sh "$work/scripts/version-stamp.sh" "$work/VERSION" "$full" -) || fail "no version stamp for $tip"
fi
echo "deploy: building trunk $tip as $stamp with Go $gover"
docker run --rm -v "$work":/src -v "$ZITREL":/zitrel:ro -w /src -e GOTOOLCHAIN=auto -e ZIT_RELEASE_DIR=/zitrel \
-e VERSION="$stamp" -e PREFIX=/src/bin "golang:$gover" sh -c "scripts/build-linux.sh" 2>&1 | grep -v "^go: downloading" | tail -2
[ -x "$work/bin/quarry" ] || fail "build produced no binary"
[ -x "$work/bin/zit" ] || fail "build produced no zit"
"$work/bin/quarry" version >/dev/null 2>&1 || fail "the built binary does not run"
# The runner downloads are published all or nothing. A cross-build that
# fails for one platform stops build-linux.sh part-way — some binaries,
# no SHA256SUMS — and the docker pipe above hides its exit status, so a
# partial set used to replace the whole published one (2026-09-30: an
# arm64-only compile error dropped SHA256SUMS and four downloads). The
# check is the tree's own (scripts/runner-check.sh reads build-linux.sh's
# platform list). A bad set is not installed, the forge still deploys,
# and the deploy ENDS FAILED at stage runner once everything else is
# done: the forge is fine, the downloads are stale, and somebody has to
# hear it.
#
# A tree whose build-linux.sh names RUNNER_TARGETS builds them, so no
# bin/runner there means the build died before it got that far: that is
# a failed build, never "a tree from before the runner downloads". Only
# a tree with no RUNNER_TARGETS line leaves the published set alone.
runner_bad=
if grep -q '^RUNNER_TARGETS=' "$work/scripts/build-linux.sh" 2>/dev/null; then
[ -d "$work/bin/runner" ] || fail "build-linux.sh builds runner downloads and the build made none; the build died part-way"
runner_bad=$(sh "$work/scripts/runner-check.sh" "$work/bin/runner" "$work/scripts/build-linux.sh") || true
if [ -n "$runner_bad" ]; then
echo "deploy: runner downloads incomplete, keeping the published set: $runner_bad" >&2
fi
elif [ -d "$work/bin/runner" ]; then
runner_bad="build-linux.sh names no RUNNER_TARGETS, so the set cannot be checked"
fi
if [ -n "$dry" ]; then
[ -z "$runner_bad" ] || fail "runner downloads incomplete: $runner_bad"
logger -t quarry-deploy "OK id=$id via=$via dry-run tip=$tip" 2>/dev/null || true
said=1
echo "deploy: dry run — trunk $tip builds and runs; nothing installed, nothing restarted"
exit 0
fi
install_set
go_live