CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
The rolling dev release lived on a tag named `dev`, beside the branch named `dev`. Once a clone had fetched tags, `git push origin dev` failed with "src refspec dev matches more than one" (Scribe #2184, note #3042), and every session had to know to spell out refs/heads/dev. The channel is still `dev` everywhere a person sees it: the app's setting, `install.sh --channel dev`, the stored pref. Only the release tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no branch to collide with and keeps its name. - packaging/channel-tag.sh is the one channel -> tag mapping CI reads: the publish steps in android.yml and desktop.yml, the manifest job, fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an unmapped channel instead of fetching an empty URL and passing. - update.rs and install.sh carry their own copy because neither can run it; update.rs gains a test that no channel feed is named like a branch. - tests/test_channel_tag.py runs the script: no tag is a branch name, dev is exactly dev-rolling, an unknown channel fails with no output. - publish-release.sh titles the release "ThoughtSync dev (rolling)", so the tag name does not leak into what people read. TEMPORARY bridge: desktop apps installed before this have .../download/dev/latest.json compiled in. The dev manifest job sets BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to the old `dev` release. Its URLs name dev-rolling assets, so those apps update once into a build that reads the new tag. The bridge, and the old release and tag, are removed once installed apps have crossed over. Until then the push still needs the explicit refspec, as ci-requirements.md now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
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/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.
|
|
#
|
|
# 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/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() {
|
|
_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/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 <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."
|