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>
232 lines
10 KiB
Bash
Executable File
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."
|