next-version-code.sh: derive the candidate from the commit, not the build clock

fdroid-repo-ci#6's "Superseded" sections change the rule: the candidate is
now the committer date of the commit being built, clamped on both sides,
rather than whatever minute the runner happened to pick the job up in.

Backwards: rebase, amend and cherry-pick can produce a commit older than
one already published, so the clamp keys off the committer date, not the
author date -- author date survives a rebase unchanged and would replay a
stale, already-superseded number.

Forwards: a commit's timestamp is attacker- and typo-controlled and the
number only ever rises, so a bad one can never be walked back. A value
placed just below the int32 ceiling would still publish and then no later
build could ever exceed it -- the app becomes permanently un-updatable.
TOLERANCE absorbs honest clock skew and the clamp warns loudly rather than
rejecting, since rejecting would block a legitimate build over a few
minutes of skew while buying nothing the clamp doesn't already cover.

next_version_code.py now takes the committer timestamp as a third
argument (Unix seconds, what `git log -1 --format=%ct` prints) instead of
reading the system clock, keeping it pure and testable without a git
checkout. next-version-code.sh, README.md and action.yml updated to match.

Granularity stays minutes: concurrency is handled separately (a
workflow-level concurrency group, plus publish.sh's refuse-on-existing),
so it is now a pure headroom choice.

Tests (test_next_version_code.py, test_next_version_code.sh): a normal
commit yields its own minute, a commit older than the published maximum
still yields published+1, a future-dated commit within tolerance passes
through untouched while one beyond it is clamped and warns, a commit
dated near the int32 ceiling does not produce a near-int32 result, and a
dedicated property test checks the result always strictly exceeds every
versionCode in the fixture across all of the above. Each was verified by
injecting the corresponding fault (removing the backwards clamp, removing
the forwards clamp, and ignoring the commit's date entirely) and
confirming the relevant test failed, then reverting.

98 assertions offline (0 failed, 4 skipped -- pre-existing, live-bucket
only), up from 91.

Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
2026-09-09 09:46:00 +00:00
co-authored by bit
parent fc2735624e
commit e5d1f9a5bf
6 changed files with 406 additions and 121 deletions
+17 -5
View File
@@ -5,11 +5,20 @@
# whole failure mode this avoids, so both paths call this one script rather
# than each computing their own.
#
# next-version-code.sh <index-v2.json URL or local path> <application-id>
# next-version-code.sh <index-v2.json URL or local path> <application-id> \
# <committer-timestamp>
#
# <committer-timestamp> is the COMMITTER date (not author date -- a rebase
# leaves author date unchanged, which would defeat the backwards clamp) of
# the commit being built, as Unix seconds: `git log -1 --format=%ct`. This
# script takes it as an argument rather than reading git itself, on purpose
# -- the caller already knows its own commit, and it keeps the underlying
# Python pure and testable without a git checkout.
#
# Prints the integer to stdout and nothing else:
#
# versionCode=$(bash next-version-code.sh "$INDEX_URL" "$APPLICATION_ID")
# versionCode=$(bash next-version-code.sh "$INDEX_URL" "$APPLICATION_ID" \
# "$(git log -1 --format=%ct)")
#
# The index source may be an http(s) URL -- what both CI and a manual build
# use in practice, and needs no credentials, since this repo and the F-Droid
@@ -22,7 +31,10 @@
# index cannot be fetched or parsed -- see next_version_code.py for why that
# is never allowed to fall back to a guess. An index with no entry at all for
# this application id is NOT an error: that is a normal first-ever publish
# for a channel, and yields the bare candidate.
# for a channel, and yields the bare (clamped) candidate. A commit dated
# implausibly far in the future is also NOT a hard failure -- it is clamped
# to roughly now and a warning is printed on stderr; see next_version_code.py
# for why rejecting it outright would be worse.
#
# The rule itself, and why each part of it is shaped this way, lives in
# next_version_code.py's own docstring -- read that, not this file, for the
@@ -36,9 +48,9 @@ set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
if [ "$#" -ne 2 ]; then
if [ "$#" -ne 3 ]; then
cat >&2 <<'USAGE'
usage: next-version-code.sh <index-v2.json URL or local path> <application-id>
usage: next-version-code.sh <index-v2.json URL or local path> <application-id> <committer-timestamp>
USAGE
exit 2
fi