Files
inkwell/packaging/guard-forward.sh
T
bvandeusenandClaude Opus 5.5 f16224b07c
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 23s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m31s
Android / Build the server image (push) Successful in 1s
Root, CI, docs and packaging describe the project to a stranger (#6225)
#6225 "Comment standard sweep: root, config, CI workflows, docs and
packaging", milestone 546 "Bring the repo to the comment standard (rule 32)
and compose defaults (rule 198)".

- README restructured in newcomer order: what it is, quick start,
  configuration, setup pitfalls, updating and backups, features,
  troubleshooting, development, licence. Its quick-start compose carries
  no restart policy.
- Dockerfile and .env.example trimmed to one or two lines per setting.
- Workflows, packaging scripts and docs lose issue, run, note, rule and
  milestone references and their history narration. Comments that still
  described path filters and "step 6" as future work now state what the
  workflows do.
- ci-requirements.md rewritten against the current workflows. It said the
  build gated on typecheck and lint only, the integration lane did not
  gate, a v* tag published :latest, and ci-tauri lacked bsdtar and zstd;
  none of those is true now (the last checked against run 9630's log).
- Echo strings in guard-forward.sh and version.sh drop their note
  citations; every string a test asserts on is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:02 -04:00

230 lines
10 KiB
Bash
Executable File

#!/usr/bin/env sh
#
# Refuse to publish a version lower than the one already on the channel.
#
# guard-forward.sh <desktop|android> <dev|stable>
# guard-forward.sh compare <a> <b> exit 0 iff a sorts below b
# guard-forward.sh published <artifact> <channel> print what the channel serves
#
# Everything else in the versioning derives a number and trusts it; this is the one
# thing that checks the answer against reality before a user gets it.
#
# WHAT IT CATCHES that nothing else does:
#
# * A SQUASH OR REBASE MERGE. Both rewrite the committer date, so `main` could
# stamp a value unrelated to the dev commit it merged. This repo merges with
# plain merge commits, but a forge UI's squash button is still one click away.
# * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire
# mitigation for the desktop key's clock choice, and the thing to revisit first
# if this repo ever starts rebuilding old commits routinely.
# * CLOCK SKEW between runners, for a build-time key.
#
# What it does NOT catch, because something better does: a shallow clone. That is
# tested directly in `version.sh` via `--is-shallow-repository`, which needs no
# network and covers artifacts that have no published value to compare against.
#
# TOO-LOW IS THE UNRECOVERABLE DIRECTION. A version below what is published means
# every installed client reports "up to date" forever and there is no build you can
# ship to fix it — you have to get back ABOVE the bad number. That is why this fails
# the lane rather than warning.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
# True when $1 sorts strictly below $2, comparing NUMERICALLY per dot-segment.
#
# Not a string compare, which is the classic way to get this wrong: `1.0.10` sorts
# below `1.0.9` as text. A missing segment reads as 0, so `1.0` == `1.0.0`.
version_lt() {
_a="$1"; _b="$2"
while [ -n "$_a" ] || [ -n "$_b" ]; do
if [ "${_a%%.*}" = "$_a" ]; then _ah="$_a"; _at=""; else _ah="${_a%%.*}"; _at="${_a#*.}"; fi
if [ "${_b%%.*}" = "$_b" ]; then _bh="$_b"; _bt=""; else _bh="${_b%%.*}"; _bt="${_b#*.}"; fi
[ -n "$_ah" ] || _ah=0
[ -n "$_bh" ] || _bh=0
if [ "$_ah" -lt "$_bh" ]; then return 0; fi
if [ "$_ah" -gt "$_bh" ]; then return 1; fi
_a="$_at"; _b="$_bt"
done
return 1 # equal
}
# Auth if we have it, anonymous if not — a token costs nothing, and a private repo's
# releases need one.
#
# MISSING CURL IS FATAL, not empty. Every fetch here ends in `|| true` so a network
# blip reads as "nothing published yet" and passes — which is right for a genuinely
# empty channel and catastrophic for a runner image without curl, where it would
# silently turn the guard into a no-op that reports success on every build.
if ! command -v curl >/dev/null 2>&1; then
echo "guard-forward.sh: curl is not on PATH — refusing to run, because every" >&2
echo " lookup here would read as 'nothing published' and this" >&2
echo " guard would pass without checking anything." >&2
exit 1
fi
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
else
curl -fsSL "$1" 2>/dev/null || true
fi
}
# What the channel is serving, per artifact. ONE definition of where to look, shared
# with `should-build.sh` — the skip decision and the guard must agree about what is
# published, and two readers of one fact drift apart.
#
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
# on each pipeline is for, and it is load-bearing rather than defensive noise.
#
# An empty channel is a REAL state this guard is written to pass — `[ -z "$published" ]`
# further down says so in as many words. But the value is captured as
# `published="$(published_for ...)"`, and under `set -e` a command substitution that
# exits non-zero kills the script before that branch is ever reached. Silently, too:
# everything the pipeline would have said went into the capture rather than the log.
#
# WHICH COMMAND THE PIPELINE ENDS ON decides whether that fires, which is the part
# worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these four
# lookups end in `sed`; the one that ends in `grep -oE '[0-9]+$'` — Android's
# version_code — would fail the lane, silently, on any channel with no APK published
# yet.
published_for() {
# The channel's release TAG, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh). `|| exit 2` rather than letting an
# empty tag through: a lookup against `.../download//latest.json` would find
# nothing and read as "nothing published", which this guard PASSES.
_tag="$(sh "$ROOT/packaging/channel-tag.sh" "$2")" || exit 2
case "$1" in
desktop)
# What the UPDATER reads. The manifest is the thing that decides whether a
# client is offered a build, so it is the authority on what is published.
{ fetch "$SERVER/$REPO/releases/download/$_tag/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
android)
{ fetch "$SERVER/$REPO/releases/download/$_tag/inkwell-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$'; } || true
;;
esac
}
# The NAME the channel serves, which is the commit-derived value. Separate from
# `published_for` because the guard compares ordering KEYS and the skip decision
# compares identity — for Android those are different fields, and conflating them
# would make every build look like a change (the code is build-time; it always moves).
published_name() {
_tag="$(sh "$ROOT/packaging/channel-tag.sh" "$2")" || exit 2
case "$1" in
desktop) published_for desktop "$2" ;;
android)
{ fetch "$SERVER/$REPO/releases/download/$_tag/inkwell-android.json" \
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
esac
}
# An explicit comparison mode, so the ordering logic is testable without a network
# and inspectable without a push. Read-only and bypasses nothing — it is the same
# function the guard itself uses, which is the point: a test of a reimplementation
# would prove nothing about the code that runs.
if [ "$artifact" = "compare" ]; then
a="${2:?usage: guard-forward.sh compare <a> <b>}"
b="${3:?usage: guard-forward.sh compare <a> <b>}"
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
fi
if [ "$artifact" = "published" ]; then
a2="${2:?usage: guard-forward.sh published <artifact> <channel>}"
c2="${3:?usage: guard-forward.sh published <artifact> <channel>}"
published_name "$a2" "$c2"
exit 0
fi
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "guard-forward.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
case "$artifact" in
desktop)
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
# What the UPDATER reads, not what the release happens to hold — the manifest is
# the thing that decides whether a client is offered this build.
published="$(published_for desktop "$channel")"
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
# exactly what it derived last time, and `<=` would fail every no-change build.
# So this fails only on *strictly* less.
strict=""
;;
android)
derived="$(sh "$ROOT/packaging/version.sh" key android)"
published="$(published_for android "$channel")"
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
# same minute, and Android refuses to install an APK whose versionCode does not
# RISE. So this one requires strictly greater.
#
# If it ever fires, the cheap fix is seconds rather than minutes in version.sh
# (~210M today against Android's 2.1e9 ceiling, so ~60 years of headroom).
# Not done pre-emptively: the concurrency group cancels older runs on a branch,
# so two builds finishing in one minute needs concurrent runs on different
# branches, and the failure is a refused install rather than a stranded channel.
strict="yes"
;;
esac
if [ -z "$published" ]; then
# A channel with nothing on it yet — `stable` before its first merge, or a fresh
# repo. PASS: there is nothing to go backwards from. Failing here would block the
# very first publish to a channel, which is the one case where "lower than what is
# published" is meaningless.
echo "guard: $channel has no published $artifact version yet — nothing to compare."
echo "guard: publishing $derived."
exit 0
fi
echo "guard: $artifact on $channel — derived $derived, published $published"
if version_lt "$derived" "$published"; then
echo "" >&2
echo "GUARD FAILED: $derived is BELOW the published $published on $channel." >&2
echo "" >&2
echo " Publishing it would leave every installed client reporting 'up to date'" >&2
echo " forever, and no later build fixes that until one climbs back above the" >&2
echo " bad number. Do not force past this." >&2
echo "" >&2
echo " Usual causes:" >&2
echo " - a squash or rebase merge rewrote the committer date" >&2
echo " - this build is a rebuild of an older commit" >&2
echo " - clock skew between runners (build-time keys)" >&2
exit 1
fi
if [ -n "$strict" ] && [ "$derived" = "$published" ]; then
echo "" >&2
echo "GUARD FAILED: $derived EQUALS the published $published on $channel." >&2
echo "" >&2
echo " Android requires versionCode to RISE; an equal one cannot be installed" >&2
echo " over what is already out there. Two builds landed in the same minute." >&2
exit 1
fi
echo "guard: ok — $derived may be published."