Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -63,6 +63,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Derives a version, so it needs the whole history — see the note in
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -85,13 +89,17 @@ jobs:
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
version="$(sh ../desktop/packaging/build-version.sh)"
|
||||
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||
# same code?", so it comes from the COMMIT and a dev build and the main
|
||||
# build of one commit read identically. The CODE answers "may this be
|
||||
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
|
||||
# leaves a channel you cannot get out of — so it comes from BUILD time,
|
||||
# which cannot go backwards. Commit time can.
|
||||
version="$(sh ../packaging/version.sh display android)"
|
||||
code="$(sh ../packaging/version.sh key android)"
|
||||
echo "name=$version" >> $GITHUB_OUTPUT
|
||||
# versionCode must RISE for Android to accept an update, and the run
|
||||
# number is the same monotonic counter the desktop's version scheme
|
||||
# already uses — no state carried between runs, and immune to the
|
||||
# shallow checkout that makes a commit count useless here.
|
||||
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "code=$code" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
@@ -105,7 +113,7 @@ jobs:
|
||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
||||
echo "Signed release build — $version (versionCode $code)"
|
||||
else
|
||||
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -279,6 +279,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||
# and produces a too-low value silently, with the lane green (§6.1).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
@@ -286,20 +290,26 @@ jobs:
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
# The image's version is DERIVED from its own shipped files — including the
|
||||
# Android client it bakes in, which is why an APK-only change re-versions
|
||||
# it. One value and no ordering key: nothing compares a server image, so
|
||||
# §2 says do not invent one just because the other artifacts have one.
|
||||
#
|
||||
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||
# not answer "how old is this instance?" — the question that actually gets
|
||||
# asked of a self-hosted app running in several places.
|
||||
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
||||
# the :<sha> above is the rollback unit. Version label = short sha.
|
||||
# the :<sha> above is the rollback unit.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -64,6 +64,14 @@ jobs:
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
||||
@@ -123,8 +131,12 @@ jobs:
|
||||
else
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
||||
fi
|
||||
version="$(sh ../packaging/build-version.sh)"
|
||||
echo "Building version $version"
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
cargo tauri build \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -273,6 +285,14 @@ jobs:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
# Same reason as the Linux job: generate_context! embeds the built frontend
|
||||
# at compile time, so it must exist before cargo runs.
|
||||
@@ -306,8 +326,12 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
version="$(sh ../packaging/build-version.sh)"
|
||||
echo "Building version $version"
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||
# separate value and arrives with the UI that shows it (#3181).
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
updater='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -393,6 +417,14 @@ jobs:
|
||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||
# emitting something plausible, which is what turns this into a red lane
|
||||
# if it is ever dropped.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Write and publish latest.json
|
||||
env:
|
||||
@@ -404,10 +436,11 @@ jobs:
|
||||
echo "manifest to write. Add the secret to enable in-app updates."
|
||||
exit 0
|
||||
fi
|
||||
# The SAME helper the bundles were built with — a second derivation here
|
||||
# could drift, and a manifest whose version doesn't match the binary it
|
||||
# points at is an updater that never settles.
|
||||
version="$(sh desktop/packaging/build-version.sh)"
|
||||
# The SAME helper AND the same request the bundles were built with — a
|
||||
# second derivation here could drift, and a manifest whose version doesn't
|
||||
# match the binary it points at is an updater that never settles. It must
|
||||
# be `key`: this value is matched against bundle filenames.
|
||||
version="$(sh packaging/version.sh key desktop)"
|
||||
# POSIX `case` (rule 81). For a tag, GITHUB_REF_NAME is the tag name, so
|
||||
# the default arm is the tag path.
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
|
||||
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
|
||||
# this?" question unanswerable.
|
||||
# `|| true` so a miss falls through to the explicit error below rather than
|
||||
# aborting on pipefail with no explanation.
|
||||
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
|
||||
# The ORDERING KEY: pacman compares this, and it must match the filename the
|
||||
# bundle build produced (write-manifest.sh selects on it).
|
||||
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
|
||||
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
|
||||
|
||||
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Echo the version this build should carry. One definition, used in three places
|
||||
# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would
|
||||
# compare its own version against a manifest describing a different build, and the
|
||||
# updater would either offer nothing or loop forever offering the same thing.
|
||||
#
|
||||
# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL:
|
||||
# an updater decides by comparing semver. Every dev build carries the version in
|
||||
# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a
|
||||
# manifest advertising the version it already has, conclude it was current, and never
|
||||
# update. The rolling channel needs a number that actually rises.
|
||||
#
|
||||
# The CI run number is that number: monotonic, already unique per build, and it needs
|
||||
# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`.
|
||||
#
|
||||
# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort
|
||||
# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would
|
||||
# never update to a newer dev one, and Windows installer metadata wants a numeric
|
||||
# X.Y.Z anyway. Bumping the minor in Cargo.toml still wins over any dev build on the
|
||||
# old line, which is the ordering you want: 0.2.0 > 0.1.2932.
|
||||
set -eu
|
||||
|
||||
CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml"
|
||||
base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
|
||||
# Dev builds only. Anything else (a v* tag, main) ships the version as written.
|
||||
if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then
|
||||
printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER"
|
||||
else
|
||||
printf '%s\n' "$base"
|
||||
fi
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# What version an artifact carries, derived from its OWN shipped files.
|
||||
#
|
||||
# Replaces desktop/packaging/build-version.sh, which was one generator feeding the
|
||||
# desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit
|
||||
# re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as
|
||||
# tidy — one definition, no drift — which is exactly why it survived review. One
|
||||
# definition of HOW to derive is right; one VALUE for unrelated artifacts is not.
|
||||
# (Note 3127 §3, which cites this repo as its example of the failure.)
|
||||
#
|
||||
# Lives at the repo root, not under desktop/, because it now serves three artifacts
|
||||
# and a shared thing filed under one consumer is how it ends up owned by that one.
|
||||
#
|
||||
# version.sh display <artifact> the human-readable version — 2026.08.28.1815
|
||||
# version.sh key <artifact> the ordering key a comparator reads
|
||||
# version.sh paths <artifact> the shipped file set (for tests and debugging)
|
||||
#
|
||||
# TWO VALUES, NOT ONE, and which you want depends on the question:
|
||||
#
|
||||
# "is this the same code?" -> display. A dev build and the main build of one
|
||||
# commit read identically, because they ARE the
|
||||
# same bytes (note 3127 §2, reason 4).
|
||||
# "may this replace that?" -> key. What an updater or an install gate
|
||||
# compares, and never shown to a person.
|
||||
#
|
||||
# The desktop needs both because Tauri's updater parses `latest.json`'s version with
|
||||
# the semver crate, and `2026.08.28.1815` is not valid semver — four segments where
|
||||
# the spec allows three, and `08` is a leading zero, which it forbids outright. A
|
||||
# non-semver string does not sort low: the feed fails to DESERIALIZE and every client
|
||||
# reports "no update available" forever. So the platform's field takes an opaque key
|
||||
# and the display version lives beside it. See #3142's spike.
|
||||
#
|
||||
# WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script
|
||||
# this replaces, because it is a real finding and the reasoning is not obvious:
|
||||
# a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a
|
||||
# dev build could never be offered as an update to a tagged one, and Windows
|
||||
# installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling
|
||||
# field, never in the version — note 3127 §7, and rule 149.
|
||||
set -eu
|
||||
|
||||
# 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).
|
||||
EPOCH=1577836800
|
||||
|
||||
# --- the shipped file sets ---------------------------------------------------
|
||||
#
|
||||
# ONE definition, read by every consumer. The `paths:` filters in the three
|
||||
# workflows are a second, independent statement of the same fact today; they come
|
||||
# out in step 6 when skip-if-exists replaces them. Until then, a change here that is
|
||||
# not mirrored there means a lane that does not fire — check both.
|
||||
#
|
||||
# Read off what actually PACKAGES each artifact, not off intuition. Miss a file and
|
||||
# a stale build keeps its version; include one that does not ship and you re-version
|
||||
# for nothing.
|
||||
#
|
||||
# THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out.
|
||||
# A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri
|
||||
# build` argument and the bytes change while the source does not. Once step 6 skips
|
||||
# a build whose version already exists, that combination serves the OLD artifact on
|
||||
# a green run: exactly the "miss a file and a stale build keeps its version" failure,
|
||||
# arriving through the build recipe rather than the source. Same reason `packaging/`
|
||||
# is in every set: this script decides identity, so a change to it is a change to
|
||||
# what each artifact claims to be.
|
||||
paths_for() {
|
||||
case "$1" in
|
||||
# tauri's generate_context! embeds the BUILT frontend in the binary, so a
|
||||
# frontend commit is a desktop change even though nothing under desktop/ moved.
|
||||
desktop) echo "desktop core frontend Cargo.toml Cargo.lock .forgejo/workflows/desktop.yml packaging" ;;
|
||||
# The .so is cross-compiled from core/ through uniffi.
|
||||
android) echo "android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
|
||||
# BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK
|
||||
# from the channel release and copies it into the package). So the image's set
|
||||
# must contain the APK's set — an APK-only change genuinely changes what this
|
||||
# image ships. Note 3127 §3 names this trap; FC's web image embeds the extension
|
||||
# the same way.
|
||||
#
|
||||
# The base images are NOT listed and do not need to be: `Dockerfile` is in the
|
||||
# set, so pinning `FROM` by digest (step 6) puts the base inside the set for
|
||||
# free. Resolving a digest at derive time would work too and is WRONG — it is an
|
||||
# external lookup, which §7's corollary forbids because it makes the value depend
|
||||
# on when it was computed.
|
||||
server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
|
||||
*) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Sets TS to the newest commit timestamp touching this artifact's files, or exits.
|
||||
#
|
||||
# EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a
|
||||
# too-low value with every lane green — the failure landmine §6.1 exists for, and
|
||||
# the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`;
|
||||
# this is what turns forgetting it into a red lane instead of a stranded channel.
|
||||
#
|
||||
# SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as
|
||||
# `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only
|
||||
# that subshell and the caller continues with an empty string. Measured before this
|
||||
# was fixed: `key desktop` on a repo with no matching history printed the error to
|
||||
# stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a
|
||||
# problem and does not stop is worse than none — it looks like it is working.
|
||||
resolve_ts() {
|
||||
# Unquoted on purpose: the path list is several words.
|
||||
# shellcheck disable=SC2046
|
||||
TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))"
|
||||
if [ -z "$TS" ]; then
|
||||
echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2
|
||||
echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
minutes_since_epoch() { echo $(( ($1 - EPOCH) / 60 )); }
|
||||
|
||||
what="${1:?usage: version.sh <display|key|paths> <desktop|android|server>}"
|
||||
artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
|
||||
|
||||
case "$what" in
|
||||
paths)
|
||||
paths_for "$artifact"
|
||||
;;
|
||||
|
||||
display)
|
||||
# One shape for every human-readable version in this repo, and for the release
|
||||
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as
|
||||
# text as well as numerically, and so two lanes cannot emit forms one character
|
||||
# apart.
|
||||
resolve_ts "$artifact"
|
||||
date -u -d "@$TS" +%Y.%m.%d.%H%M
|
||||
;;
|
||||
|
||||
key)
|
||||
case "$artifact" in
|
||||
desktop)
|
||||
# COMMIT time. The desktop is a one-value system to Tauri — its comparator
|
||||
# reads the version name — so this key is also what lands in bundle
|
||||
# filenames and .deb metadata. Commit time buys the property in §2 reason
|
||||
# (4): the last dev build before a PR and the main build from it are the
|
||||
# same bytes and derive the same key, so the artifact is reused rather than
|
||||
# rebuilt and re-signed under a new name.
|
||||
#
|
||||
# Commit time CAN go backwards (rebuild an older commit). The backwards
|
||||
# guard in step 5 is the whole mitigation, and the desktop's failure there
|
||||
# is soft: an update is not offered. Contrast Android below.
|
||||
#
|
||||
# `1.0.` and not `0.0.`: the minor must clear the installed `0.2.<run>` line
|
||||
# or every dev user is stranded on "up to date" permanently. Checked against
|
||||
# the live feed (0.2.466), not against what we thought we had published.
|
||||
resolve_ts desktop
|
||||
echo "1.0.$(minutes_since_epoch "$TS")"
|
||||
;;
|
||||
android)
|
||||
# BUILD time, and the asymmetry with the desktop is deliberate. Android
|
||||
# HARD-FAILS an install on a downgrade (INSTALL_FAILED_VERSION_DOWNGRADE)
|
||||
# and leaves a channel you cannot get out of, so its key must be monotonic
|
||||
# BY CONSTRUCTION rather than by a guard that runs in CI. Build time cannot
|
||||
# go backwards; commit time can.
|
||||
#
|
||||
# An Int, which is what Android compares. ~3.5M today against a 2.1e9
|
||||
# ceiling — roughly four thousand years of headroom.
|
||||
minutes_since_epoch "$(date -u +%s)"
|
||||
;;
|
||||
server)
|
||||
# NO ORDERING KEY. Nothing compares the server image: no updater, no install
|
||||
# gate, and `:latest` is moved by the registry rather than chosen by a
|
||||
# client. §2 is explicit that an artifact with nothing to compare needs only
|
||||
# a name — do not add one because the other two have one.
|
||||
echo "version.sh: the server has no ordering key; use 'display'" >&2
|
||||
exit 2
|
||||
;;
|
||||
*) echo "version.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "version.sh: unknown request '$what' (want display, key or paths)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,210 @@
|
||||
"""The version derivation — `packaging/version.sh`.
|
||||
|
||||
These tests build their OWN git repo in a tmpdir rather than reading this one's
|
||||
history. Two reasons, and the second is the important one:
|
||||
|
||||
* They then need no `fetch-depth: 0` on the test lane, and cannot start passing or
|
||||
failing because somebody pushed.
|
||||
* They can commit to ONE artifact's file set at a time, which is the only way to
|
||||
assert the property this whole change exists for: that a Kotlin-only commit
|
||||
leaves the desktop's version alone. Against real history you can only observe
|
||||
whatever the last commits happened to touch.
|
||||
|
||||
Note 3127 §3 cites this repo as its example of the failure being fixed here — one
|
||||
generator feeding three artifacts, so a Rust-only commit re-versioned the phone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.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
|
||||
# this epoch renumbers every artifact downwards (note 3127 §6.4).
|
||||
EPOCH = 1577836800
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def commit(repo: Path, path: str, when: int) -> None:
|
||||
"""Write a file and commit it with a FIXED committer date.
|
||||
|
||||
`%ct` is the committer date, so both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE have
|
||||
to be pinned or the test is timing-dependent.
|
||||
"""
|
||||
f = repo / path
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_text(f"{when}\n")
|
||||
git(repo, "add", "-A")
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
|
||||
"commit", "-q", "-m", f"touch {path}"],
|
||||
check=True, capture_output=True, text=True,
|
||||
# EXTEND the environment rather than replacing it: a minimal env is enough
|
||||
# for git here but not necessarily inside the CI container, and a test that
|
||||
# fails only there is worse than no test.
|
||||
env={**os.environ,
|
||||
"GIT_AUTHOR_DATE": f"@{when} +0000", "GIT_COMMITTER_DATE": f"@{when} +0000"},
|
||||
)
|
||||
|
||||
|
||||
def version(repo: Path, what: str, artifact: str) -> str:
|
||||
return subprocess.run(
|
||||
["sh", str(SCRIPT), what, artifact],
|
||||
cwd=repo, check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
"""A repo with one commit per artifact area, at three known instants."""
|
||||
git(tmp_path, "init", "-q", "-b", "dev")
|
||||
# 2026-08-28 in UTC, an hour apart so each is distinguishable.
|
||||
commit(tmp_path, "core/lib.rs", 1787900400) # 2026-08-28 07:00 — shared
|
||||
commit(tmp_path, "android/app/build.gradle.kts", 1787904000) # 08:00 — android only
|
||||
commit(tmp_path, "desktop/src-tauri/main.rs", 1787907600) # 09:00 — desktop only
|
||||
return tmp_path
|
||||
|
||||
|
||||
# --- shape -------------------------------------------------------------------
|
||||
|
||||
def test_display_is_zero_padded_calver(repo: Path) -> None:
|
||||
"""`YYYY.MM.DD.HHMM`, padded. Padding is what makes it sort as text as well as
|
||||
numerically, and what keeps two lanes from emitting forms one character apart."""
|
||||
for artifact in ("desktop", "android", "server"):
|
||||
assert re.fullmatch(r"\d{4}\.\d{2}\.\d{2}\.\d{4}", version(repo, "display", artifact))
|
||||
|
||||
|
||||
def test_display_is_the_commit_instant_in_utc(repo: Path) -> None:
|
||||
# The desktop's newest commit is 09:00 UTC on 2026-08-28.
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.0900"
|
||||
# Android's is an hour earlier, and it does not see the desktop commit at all.
|
||||
assert version(repo, "display", "android") == "2026.08.28.0800"
|
||||
|
||||
|
||||
# --- the property the whole change exists for --------------------------------
|
||||
|
||||
def test_a_desktop_commit_does_not_move_android(repo: Path) -> None:
|
||||
before = version(repo, "display", "android")
|
||||
commit(repo, "desktop/src-tauri/other.rs", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "android") == before
|
||||
|
||||
|
||||
def test_an_android_commit_does_not_move_the_desktop(repo: Path) -> None:
|
||||
before = version(repo, "display", "desktop")
|
||||
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
|
||||
assert version(repo, "display", "android") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "desktop") == before
|
||||
|
||||
|
||||
def test_a_shared_core_commit_moves_both(repo: Path) -> None:
|
||||
"""`core/` is genuinely in both sets — the .so and the desktop binary are built
|
||||
from it — so this is correct rather than a leak between them."""
|
||||
commit(repo, "core/src/sync.rs", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
assert version(repo, "display", "android") == "2026.08.28.1000"
|
||||
|
||||
|
||||
def test_the_server_set_contains_the_android_set(repo: Path) -> None:
|
||||
"""The image BAKES IN the APK, so an APK-only change changes what the image
|
||||
ships. Note 3127 §3's bundled-artifact trap; FC's web image embeds the extension
|
||||
the same way, and Roundtable needed a bespoke workflow for want of modelling it."""
|
||||
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
|
||||
assert version(repo, "display", "server") == "2026.08.28.1000"
|
||||
assert "android" in version(repo, "paths", "server")
|
||||
|
||||
|
||||
def test_the_build_recipe_is_in_the_set(repo: Path) -> None:
|
||||
"""A workflow file is not shipped, but change a build flag and the bytes change
|
||||
while the source does not. Once step 6 skips a build whose version already
|
||||
exists, that combination serves the OLD artifact on a green run."""
|
||||
commit(repo, ".forgejo/workflows/desktop.yml", 1787911200) # 10:00
|
||||
assert version(repo, "display", "desktop") == "2026.08.28.1000"
|
||||
|
||||
|
||||
# --- the ordering keys -------------------------------------------------------
|
||||
|
||||
def test_the_desktop_key_is_valid_semver(repo: Path) -> None:
|
||||
"""THE test that keeps the update channel alive. Tauri parses `latest.json`'s
|
||||
version with the semver crate AT DESERIALIZATION — a string it cannot parse does
|
||||
not sort low, it makes the whole feed fail to load and every client report "no
|
||||
update available" forever. Exactly three numeric segments, no leading zeros."""
|
||||
key = version(repo, "key", "desktop")
|
||||
assert re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", key), key
|
||||
|
||||
|
||||
def test_the_desktop_key_and_display_describe_one_build(repo: Path) -> None:
|
||||
"""They are allowed to look unrelated. They are not allowed to disagree about
|
||||
WHICH build — so both come from one timestamp."""
|
||||
key = version(repo, "key", "desktop")
|
||||
minutes = int(key.split(".")[2])
|
||||
assert EPOCH + minutes * 60 == 1787907600 # the desktop's newest commit, 09:00
|
||||
|
||||
|
||||
def test_the_desktop_key_clears_what_is_already_installed(repo: Path) -> None:
|
||||
"""`0.0.<minutes>` reads best as "not a version" and would have stranded every
|
||||
dev user: minor 0 < 2 puts it below the installed `0.2.466` line, and "up to
|
||||
date" forever is the direction you cannot recover from."""
|
||||
key = tuple(int(p) for p in version(repo, "key", "desktop").split("."))
|
||||
assert key > (0, 2, 466)
|
||||
assert key > (0, 2, 999999) # and above any run number that line could reach
|
||||
|
||||
|
||||
def test_the_android_key_is_an_int_android_will_accept(repo: Path) -> None:
|
||||
"""Build time, not commit time: Android HARD-FAILS a downgrade with
|
||||
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
|
||||
the key must be monotonic by construction rather than by a CI guard."""
|
||||
code = int(version(repo, "key", "android"))
|
||||
assert code > 1_000_000 # far above the run numbers it replaces (~470)
|
||||
assert code < 2_100_000_000 # Android's Int ceiling
|
||||
|
||||
|
||||
def test_the_server_has_no_ordering_key(repo: Path) -> None:
|
||||
"""Nothing compares a server image — no updater, no install gate. §2: do not add
|
||||
an ordering key because the other artifacts have one."""
|
||||
r = subprocess.run(["sh", str(SCRIPT), "key", "server"],
|
||||
cwd=repo, capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
assert "no ordering key" in r.stderr
|
||||
|
||||
|
||||
# --- failing loudly ----------------------------------------------------------
|
||||
|
||||
def test_an_unknown_artifact_is_rejected(repo: Path) -> None:
|
||||
r = subprocess.run(["sh", str(SCRIPT), "display", "nope"],
|
||||
cwd=repo, capture_output=True, text=True)
|
||||
assert r.returncode != 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("what", ["display", "key"])
|
||||
def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: str) -> None:
|
||||
"""The shallow-clone failure (note 3127 §6.1), which is the one that matters:
|
||||
depth-1 sees one commit, `git log -- <paths>` finds nothing for most artifacts,
|
||||
and a script that shrugged would emit a too-LOW version with the lane green.
|
||||
Too-low is unrecoverable — every installed client is stranded.
|
||||
|
||||
BOTH REQUESTS, and the parametrize is the point rather than thoroughness. The
|
||||
first version of this guard `exit 1`-ed inside a function called as `$(...)`,
|
||||
which ends the SUBSHELL and not the script. `display` still failed — but only
|
||||
because `date` then choked on an empty string. `key` printed the error, emitted
|
||||
`1.0.-26297280`, and exited ZERO. One path was covered and the other was broken
|
||||
in exactly the way the guard existed to prevent."""
|
||||
git(tmp_path, "init", "-q", "-b", "dev")
|
||||
commit(tmp_path, "README.md", 1787900400) # in no artifact's set
|
||||
r = subprocess.run(["sh", str(SCRIPT), what, "desktop"],
|
||||
cwd=tmp_path, capture_output=True, text=True)
|
||||
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}"
|
||||
Reference in New Issue
Block a user