Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Step 6 of M314. Two changes that only make sense together.
## The image tag set rule 145 mandates
dev push -> :dev
main push -> :latest + :<sha>
a v* tag -> nothing; the trigger is gone
`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.
No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.
## Skip-if-exists, adapted, because §4 assumes a registry §5 removed
Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.
So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.
The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.
## The server always builds, deliberately
Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.
That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.
## The guard runs on the skip path
It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.
## Two defects found while wiring this
`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.
`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.
#3146
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
8.8 KiB
Bash
Executable File
210 lines
8.8 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/thoughtsync}"
|
|
|
|
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.
|
|
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/'
|
|
;;
|
|
android)
|
|
fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
|
|
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
|
|
| grep -oE '[0-9]+$'
|
|
;;
|
|
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/'
|
|
;;
|
|
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."
|