Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
5 changed files with 288 additions and 0 deletions
Showing only changes of commit 0ab7d94294 - Show all commits
+20
View File
@@ -126,6 +126,26 @@ jobs:
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
fi
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
# lane derives a number and trusts it; this compares the derived value against
# what the channel is actually serving, and fails the lane if it went DOWN.
#
# Placed before the build, not after: a bad derivation should cost seconds, not
# a five-minute compile and a publish that has to be undone. Too-low is the
# unrecoverable direction — every installed client reports "up to date" forever
# and no later build fixes it until one climbs back above the bad number
# (#2183, #2993).
- name: Guard — the version must not go backwards
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${ github.token }
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh ../packaging/guard-forward.sh android "$channel"
- name: Make gradlew executable
run: chmod +x ./gradlew
+40
View File
@@ -126,6 +126,26 @@ jobs:
# so making it conditional is what lets the pipeline stay green before the
# operator has added the secret. With the key present, each bundle gets a
# `.sig` beside it — the file the updater actually verifies against.
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
# lane derives a number and trusts it; this compares the derived value against
# what the channel is actually serving, and fails the lane if it went DOWN.
#
# Placed before the build, not after: a bad derivation should cost seconds, not
# a five-minute compile and a publish that has to be undone. Too-low is the
# unrecoverable direction — every installed client reports "up to date" forever
# and no later build fixes it until one climbs back above the bad number
# (#2183, #2993).
- name: Guard — the version must not go backwards
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${ github.token }
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh desktop "$channel"
- name: Tauri build (deb + AppImage)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -328,6 +348,26 @@ jobs:
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
# Frontend already built above; skip the beforeBuildCommand rebuild.
# THE ONE CHECK THAT LOOKS AT REALITY (note 3127 §6.3). Everything else in this
# lane derives a number and trusts it; this compares the derived value against
# what the channel is actually serving, and fails the lane if it went DOWN.
#
# Placed before the build, not after: a bad derivation should cost seconds, not
# a five-minute compile and a publish that has to be undone. Too-low is the
# unrecoverable direction — every installed client reports "up to date" forever
# and no later build fixes it until one climbs back above the bad number
# (#2183, #2993).
- name: Guard — the version must not go backwards
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${ github.token }
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh desktop "$channel"
- name: Tauri build (NSIS installer)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+155
View File
@@ -0,0 +1,155 @@
#!/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 strictly below b
#
# 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
}
# 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
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
BASE="$SERVER/$REPO/releases/download/$channel"
# 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.
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
}
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="$(fetch "$BASE/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/')"
# 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="$(fetch "$BASE/thoughtsync-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$')"
# 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."
+18
View File
@@ -58,6 +58,24 @@ set -eu
# match nothing from `android/`. Same bug, louder symptom, pure luck.
cd "$(git rev-parse --show-toplevel)"
# A SHALLOW CLONE IS FATAL, and asked directly rather than inferred.
#
# Landmine §6.1: depth-1 sees one commit, so `git log -- <paths>` answers about
# whatever happens to be in that commit and the result is a too-LOW version — the
# unrecoverable direction, arrived at silently with every lane green.
#
# The empty-result guard below catches only the case where NOTHING matches. It missed
# the worse one: on run 4796 a partial match returned a real, six-days-stale answer.
# `--is-shallow-repository` tests the actual hazard instead of a symptom of it, costs
# no network, and covers every artifact including the ones with no published value to
# compare against.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2
echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2
echo " (note 3127 §6.1)" >&2
exit 1
fi
# 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it
# renumbers every artifact downwards, which is the one direction you cannot recover
# from (note 3127 §6.4).
+55
View File
@@ -23,6 +23,7 @@ from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.sh"
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script deliberately:
# a test that imported the value could not catch the value being changed, and moving
@@ -249,3 +250,57 @@ def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: st
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert "shallow" in r.stderr
assert r.stdout.strip() == "", f"{what} emitted a value anyway: {r.stdout!r}"
# --- the backwards guard's comparison ----------------------------------------
#
# Exercised through the guard's own `compare` mode rather than a reimplementation
# here: a test of a copy proves nothing about the code that runs. No network — the
# comparison is pure, and the fetch/compare halves are separable for exactly this.
def compare(a: str, b: str) -> bool:
"""True when the guard considers `a` to sort strictly below `b`."""
return subprocess.run(["sh", str(GUARD), "compare", a, b],
capture_output=True, text=True).returncode == 0
@pytest.mark.parametrize("a,b,expect_lt", [
# THE trap: as text, "1.0.9" > "1.0.10". The comparison must be numeric
# per dot-segment, which is what note 3127 §1 spells out and what a naive
# `[ "$a" \< "$b" ]` would get exactly backwards.
("1.0.9", "1.0.10", True),
("1.0.10", "1.0.9", False),
# Equal is NOT less. Under commit time an unchanged source derives what it
# derived last time, so this is the ordinary no-change build.
("1.0.5", "1.0.5", False),
# A missing segment reads as zero.
("1.0", "1.0.0", False),
("1.0.0", "1.0", False),
("1.0", "1.0.1", True),
# The real transition this milestone performs, on both channels: dev was
# publishing 0.2.<run>, stable was on the bare 0.2.0 from Cargo.toml.
("0.2.466", "1.0.3502151", True),
("0.2.0", "1.0.3502151", True),
("1.0.3502151", "0.2.466", False),
# Android codes are bare integers.
("3502151", "3502152", True),
("3502152", "3502151", False),
# Zero-padded display versions compare correctly despite the leading zeros
# (which is why they are stripped on parse rather than compared as text).
("2026.08.29.0110", "2026.08.29.0111", True),
("2026.08.29.0111", "2026.08.29.0110", False),
("2026.08.09.0111", "2026.08.10.0111", True),
("2026.12.31.2359", "2027.01.01.0000", True),
])
def test_the_guard_orders_versions_numerically(a: str, b: str, expect_lt: bool) -> None:
assert compare(a, b) is expect_lt
@pytest.mark.parametrize("args", [["compare"], ["compare", "1.0.0"], ["nope", "dev"],
["desktop", "nope"]])
def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
"""Including the two-place validation the version script needed twice — a guard
that answers confidently for input it does not understand is worse than none."""
r = subprocess.run(["sh", str(GUARD), *args], capture_output=True, text=True)
assert r.returncode != 0