Files
inkwell/packaging/guard-forward.sh
T
bvandeusenandClaude Opus 5.5 81cd719327 rename: Android is Inkwell — package, applicationId, uniffi class, assets
Step 4 of milestone 481 (Scribe note 5071: a full rename).

- namespace and applicationId com.fabledsword.inkwell; the Kotlin package moves
  with them, and ktlint re-sorted the imports the rename reordered (checked
  locally with CI's ktlint 1.4.0 and detekt 1.23.7, both clean)
- uniffi: class Inkwell in com.fabledsword.inkwell.core, InkwellApplication,
  InkwellTheme, Theme.Inkwell, log tags, prefs and work names, client agent
  inkwell-android
- the lane publishes inkwell.apk / inkwell-android.json; fetch-clients,
  guard-forward, publish-release and write-manifest read the same names

A new applicationId is a new app. The old ThoughtSync app keeps its own store
and stays installed beside it. Notes cross over by syncing, and the old app is
then removed by hand.

Kept: the signing keyAlias is still "thoughtsync". It names the key inside the
existing keystore, and the key, and so the certificate, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:02 -04:00

232 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
#
# 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/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 — 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() {
# The channel's release TAG, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh, Scribe #2184). `|| 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.
# §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."