#!/usr/bin/env sh # # Refuse to publish a version lower than the one already on the channel. # # guard-forward.sh # guard-forward.sh compare exit 0 iff a sorts below b # guard-forward.sh published print what the channel serves # # Note 3127 §6.3. Everything else in this milestone 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 (§6.2). Both rewrite the committer date, so `main` # could stamp a value unrelated to the dev commit it merged. Rule 153 mandates # plain merge commits — but that rule governs people, and a forge UI's squash # button does not read it. # * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire # mitigation for the desktop key's clock choice (step 4), 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 #2183 and # #2993's shared symptom, and it 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/thoughtsync}" artifact="${1:?usage: guard-forward.sh }" # 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 — the releases are public, but a token costs # nothing and keeps this working if that ever changes. # # 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 is how this repo keeps producing #2181-2183. # # 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 HAPPENS TO END 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` and were fine. The one that ends in `grep -oE '[0-9]+$'` — # Android's version_code — was not, and it failed the whole Android lane on the first # merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that # had no APK published yet. Its three neighbours hid it until then. published_for() { 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/$2/latest.json" \ | grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \ | sed -E 's/.*"([^"]+)"$/\1/'; } || true ;; android) { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-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() { case "$1" in desktop) published_for desktop "$2" ;; android) { fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-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 }" b="${3:?usage: guard-forward.sh compare }" if version_lt "$a" "$b"; then exit 0; else exit 1; fi fi if [ "$artifact" = "published" ]; then a2="${2:?usage: guard-forward.sh published }" c2="${3:?usage: guard-forward.sh published }" published_name "$a2" "$c2" exit 0 fi channel="${2:?usage: guard-forward.sh }" 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. # §6.3 says *strictly* less for exactly this reason. 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 (note 3127 §6.2, §6.3):" >&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."