next-version-code.sh: one monotonic versionCode rule, shared by CI and manual builds #6

Closed
opened 2026-09-09 07:36:51 +00:00 by claude · 2 comments
Member

One implementation of the versionCode rule, called by both CI and the manual build path — because two implementations is the whole failure mode being avoided.

Why it lives here and not in a consumer

The rule has to read what is already published in order to clamp against it, and reading the repo index is what this repo already does. It is also genuinely generic: "the next monotonic version code for this application id" has nothing to do with any particular app.

And this repo is public, so a manual build can fetch it with no credentials — which is the point of weblib-viewer#16's "buildable when CI is down".

The rule

candidate  = whole minutes since 2026-01-01T00:00:00Z
published  = highest versionCode already in the repo index for this applicationId
result     = max(candidate, published + 1)

Print the result; do nothing else.

Why each part, so none of it gets "simplified" later

  • Minutes, not seconds. versionCode is a signed 32-bit int, so every scheme has a ceiling. Seconds since 2026 exhaust it in 67 years; minutes in 4,082. bit, weblib-viewer#15: a 67-year horizon is the reasoning that produced Y2038.
  • Not raw Unix time. That overflows int32 on 2038-01-19 — 11 years away, and already the shortest option.
  • Not YYMMDDHHMM. It exceeds int32 today (2,609,080,000 > 2,147,483,647).
  • The clamp is not optional. Minute granularity means two builds in the same minute compute the same number, and Android then does not treat the second as an upgrade — silently. The clamp makes monotonicity structural rather than a matter of timing, and needs no waiting or cross-job coordination.
  • The clamp also covers two cases a pure clock cannot: a clock that steps backwards, and rebuilding an older commit. Both would otherwise emit a lower number that every device which saw the higher one rejects.

Cost of a collision is one minute of future headroom. It would take 2,147,121,772 collisions to exhaust that, so it is not a budget anyone can spend.

Interface

Takes the repo URL (or bucket and endpoint) and an application id; prints an integer. Fails loudly if the index cannot be read — guessing a number when the published state is unknown is precisely how a duplicate or a decrease gets published.

Worth deciding explicitly and documenting: what it does when the index exists but contains no build for that application id. That is the first-ever publish for a channel, it is a normal state rather than an error, and it should yield the bare candidate.

Verification

Offline, against fixture index files rather than a live repo: no prior build, a lower prior, an equal prior, a higher prior (the clock-went-backwards case), and an unreadable index. Prove each is caught by injecting the fault.

The interesting assertion is not that the arithmetic is right — it is that the result is strictly greater than every version code already in the fixture, which is the property that actually matters and the one a future refactor could break while still looking correct.

The clamp does not close the race — this is required alongside it

bit, weblib-viewer#15: "isn't there a race in the published detection?" Yes, and it is bigger than the one the clamp fixes.

Read the index → build → publish is TOCTOU. Two jobs starting in the same minute both read published = N, both compute N + 1, both build, both publish. And the window is the entire build, because the number has to be baked into the APK before it exists — so it is minutes, not the 60 seconds of plain minute granularity.

Note it bites exactly where the clamp was supposed to help: different minutes give different candidates and never collide, so only same-minute jobs reach the clamp at all.

So "monotonic by construction" is wrong as previously written here — the clamp is monotonic against observed state, and concurrent publishers are what it does not see.

Required, in the same change: the publish step must refuse to overwrite an existing APK. An F-Droid repo names them <applicationId>_<versionCode>.apk, so a collision is a name collision — check for the object and abort rather than uploading over it.

This cannot prevent the wasted build. What it does is convert a silent, permanent wrong state — two different APKs claiming one versionCode, clients pinned to whichever they happened to fetch — into a loud failure of the second job at the last moment, when the information is finally reliable. That trade is the whole point.

Serialising jobs with a concurrency group would prevent the overlap entirely, but do not rely on it: whether Gitea 1.25.2 honours it is unverified, and this instance has already surprised us on environments, owner-gated endpoints and artifact visibility. Treat it as an optimisation to measure separately; the refusal above is the part that must exist.

Superseded: derive from the commit, and clamp on both sides

The rule above derives from the build clock. It should derive from the committer date of the commit being built (bit, weblib-viewer#15) — deterministic per commit, and it stops the number depending on when a runner picked the job up. Use committer date, not author date: author date survives a rebase unchanged, so a rebased branch would re-emit an old number.

That makes the clamp needed in both directions, for opposite reasons:

candidate = minutes(committer_date)
candidate = min(candidate, minutes(now) + TOLERANCE)    # forwards
result    = max(candidate, published + 1)               # backwards

Backwards — rebase, amend or cherry-pick can produce a commit older than one already published.

Forwards — a commit timestamp is attacker- and typo-controlled, and the number only ever rises, so a bad one cannot be walked back. Measured:

commit dated versionCode cost
2030 (typo) 2,103,840 burns 3 years of headroom, permanently
2126 (century typo) 52,594,560 burns 99 years
2999 (malicious) 511,748,640 burns 972 years

The worst case is not overflow. A value just below int32 is accepted by Android, publishes, and then no later build can ever exceed it — the app becomes permanently un-updatable, recoverable only by a new applicationId with every user reinstalling by hand. One commit, unrecoverable.

TOLERANCE only has to cover honest clock skew between the committer's machine and the runner; a few hours is generous. It does not need to be tight — its only job is keeping the number near the present.

Clamp and warn, do not reject. A future-dated commit is a broken clock or someone probing, and both are worth seeing in the log — but rejecting would block a legitimate build over a few minutes of skew, and the clamp already removes the harm.

Accepted trade: a future-dated commit's code then depends on when it was built, so it is no longer purely deterministic for that commit. Determinism for well-formed commits, safety for malformed ones.

Also superseded: granularity is no longer load-bearing

Concurrency is handled by a concurrency group with cancel-in-progress — measured working on this instance (weblib-viewer#15: two pushes 24 s apart, the earlier run cancelled) — plus refuse-on-existing at publish. So granularity is a pure headroom choice and minutes stand. The earlier 5-second proposal is withdrawn.

One implementation of the `versionCode` rule, called by **both** CI and the manual build path — because two implementations is the whole failure mode being avoided. ## Why it lives here and not in a consumer The rule has to read **what is already published** in order to clamp against it, and reading the repo index is what this repo already does. It is also genuinely generic: "the next monotonic version code for this application id" has nothing to do with any particular app. And this repo is **public**, so a manual build can fetch it with no credentials — which is the point of weblib-viewer#16's "buildable when CI is down". ## The rule ``` candidate = whole minutes since 2026-01-01T00:00:00Z published = highest versionCode already in the repo index for this applicationId result = max(candidate, published + 1) ``` Print the result; do nothing else. ## Why each part, so none of it gets "simplified" later - **Minutes, not seconds.** `versionCode` is a signed 32-bit int, so every scheme has a ceiling. Seconds since 2026 exhaust it in **67 years**; minutes in **4,082**. bit, weblib-viewer#15: a 67-year horizon is the reasoning that produced Y2038. - **Not raw Unix time.** That overflows int32 on **2038-01-19** — 11 years away, and already the shortest option. - **Not `YYMMDDHHMM`.** It exceeds int32 *today* (2,609,080,000 > 2,147,483,647). - **The clamp is not optional.** Minute granularity means two builds in the same minute compute the same number, and Android then does not treat the second as an upgrade — silently. The clamp makes monotonicity structural rather than a matter of timing, and needs no waiting or cross-job coordination. - The clamp also covers two cases a pure clock cannot: **a clock that steps backwards**, and **rebuilding an older commit**. Both would otherwise emit a lower number that every device which saw the higher one rejects. Cost of a collision is one minute of future headroom. It would take **2,147,121,772** collisions to exhaust that, so it is not a budget anyone can spend. ## Interface Takes the repo URL (or bucket and endpoint) and an application id; prints an integer. **Fails loudly** if the index cannot be read — guessing a number when the published state is unknown is precisely how a duplicate or a decrease gets published. Worth deciding explicitly and documenting: what it does when the index exists but contains **no** build for that application id. That is the first-ever publish for a channel, it is a normal state rather than an error, and it should yield the bare candidate. ## Verification Offline, against fixture index files rather than a live repo: no prior build, a lower prior, an equal prior, a *higher* prior (the clock-went-backwards case), and an unreadable index. Prove each is caught by injecting the fault. The interesting assertion is not that the arithmetic is right — it is that the result is **strictly greater than every version code already in the fixture**, which is the property that actually matters and the one a future refactor could break while still looking correct. ## The clamp does not close the race — this is required alongside it bit, weblib-viewer#15: *"isn't there a race in the published detection?"* Yes, and it is bigger than the one the clamp fixes. Read the index → build → publish is TOCTOU. Two jobs starting in the **same minute** both read `published = N`, both compute `N + 1`, both build, both publish. And the window is the **entire build**, because the number has to be baked into the APK before it exists — so it is minutes, not the 60 seconds of plain minute granularity. Note it bites exactly where the clamp was supposed to help: different minutes give different candidates and never collide, so only same-minute jobs reach the clamp at all. **So "monotonic by construction" is wrong as previously written here** — the clamp is monotonic against *observed* state, and concurrent publishers are what it does not see. **Required, in the same change:** the publish step must **refuse to overwrite an existing APK**. An F-Droid repo names them `<applicationId>_<versionCode>.apk`, so a collision is a name collision — check for the object and abort rather than uploading over it. This cannot prevent the wasted build. What it does is convert a **silent, permanent** wrong state — two different APKs claiming one `versionCode`, clients pinned to whichever they happened to fetch — into a loud failure of the second job at the last moment, when the information is finally reliable. That trade is the whole point. Serialising jobs with a `concurrency` group would prevent the overlap entirely, but **do not rely on it**: whether Gitea 1.25.2 honours it is unverified, and this instance has already surprised us on environments, owner-gated endpoints and artifact visibility. Treat it as an optimisation to measure separately; the refusal above is the part that must exist. ## Superseded: derive from the commit, and clamp on **both** sides The rule above derives from the **build clock**. It should derive from the **committer date** of the commit being built (bit, weblib-viewer#15) — deterministic per commit, and it stops the number depending on when a runner picked the job up. Use *committer* date, not author date: author date survives a rebase unchanged, so a rebased branch would re-emit an old number. That makes the clamp needed in **both** directions, for opposite reasons: ``` candidate = minutes(committer_date) candidate = min(candidate, minutes(now) + TOLERANCE) # forwards result = max(candidate, published + 1) # backwards ``` **Backwards** — rebase, amend or cherry-pick can produce a commit older than one already published. **Forwards** — a commit timestamp is attacker- and typo-controlled, and the number only ever rises, so a bad one cannot be walked back. Measured: | commit dated | versionCode | cost | |---|---|---| | 2030 (typo) | 2,103,840 | burns 3 years of headroom, permanently | | 2126 (century typo) | 52,594,560 | burns 99 years | | 2999 (malicious) | 511,748,640 | burns 972 years | The worst case is not overflow. A value just **below** int32 is accepted by Android, publishes, and then **no later build can ever exceed it** — the app becomes permanently un-updatable, recoverable only by a new `applicationId` with every user reinstalling by hand. One commit, unrecoverable. `TOLERANCE` only has to cover honest clock skew between the committer's machine and the runner; a few hours is generous. It does not need to be tight — its only job is keeping the number near the present. **Clamp and warn, do not reject.** A future-dated commit is a broken clock or someone probing, and both are worth seeing in the log — but rejecting would block a legitimate build over a few minutes of skew, and the clamp already removes the harm. **Accepted trade:** a future-dated commit's code then depends on when it was built, so it is no longer purely deterministic for that commit. Determinism for well-formed commits, safety for malformed ones. ## Also superseded: granularity is no longer load-bearing Concurrency is handled by a **concurrency group with cancel-in-progress** — measured working on this instance (weblib-viewer#15: two pushes 24 s apart, the earlier run cancelled) — plus refuse-on-existing at publish. So granularity is a pure headroom choice and **minutes stand**. The earlier 5-second proposal is withdrawn.
claude reopened this issue 2026-09-09 08:20:20 +00:00
Author
Member

Reopened — #7 auto-closed this, but the rule it implements is not final.

Built and merged: next-version-code.sh + next_version_code.py, the clamp, and the tests. The JSON path was verified against fdroidserver's own index.py rather than inferred from the file's shape, which is the part most likely to have been quietly wrong.

Still open: the granularity. This ships minutes. On weblib-viewer#15 the requirement changed after this issue was written — bit wants every build kept, which makes collisions something to render impossible rather than resolve, and minutes are too coarse for that.

granularity headroom exhausted collides if two builds start within
1 s 67 yr 2093 1 second
5 s 340 yr 2365 5 seconds
10 s 680 yr 2705 10 seconds
1 min (shipped) 4,082 yr 6108 1 minute

5 seconds is proposed and awaiting bit. It keeps the long horizon that made minutes attractive while making a collision mean "two publishes were fired simultaneously on purpose".

Nothing calls this script yet — no workflow, no Makefile — so the shipped constant has no operational effect. That is why #7 merged rather than waiting.

When the number is decided, two things change together:

  1. the constant and its tests;
  2. the justification for the clamp, which the comments currently tie to concurrency. At 5-second granularity the clamp is no longer about concurrent builds at all — it is about a clock stepping backwards and about rebuilding an older commit, which are the two cases no clock-derived scheme can handle. Leaving the old justification in place would be worse than useless: it would make the clamp look redundant to whoever reads it next, and removable.
Reopened — #7 auto-closed this, but the rule it implements is **not final**. **Built and merged:** `next-version-code.sh` + `next_version_code.py`, the clamp, and the tests. The JSON path was verified against fdroidserver's own `index.py` rather than inferred from the file's shape, which is the part most likely to have been quietly wrong. **Still open: the granularity.** This ships **minutes**. On weblib-viewer#15 the requirement changed after this issue was written — bit wants **every build kept**, which makes collisions something to render impossible rather than resolve, and minutes are too coarse for that. | granularity | headroom | exhausted | collides if two builds start within | |---|---|---|---| | 1 s | 67 yr | 2093 | 1 second | | **5 s** | **340 yr** | 2365 | 5 seconds | | 10 s | 680 yr | 2705 | 10 seconds | | 1 min *(shipped)* | 4,082 yr | 6108 | **1 minute** | 5 seconds is proposed and awaiting bit. It keeps the long horizon that made minutes attractive while making a collision mean "two publishes were fired simultaneously on purpose". **Nothing calls this script yet** — no workflow, no Makefile — so the shipped constant has no operational effect. That is why #7 merged rather than waiting. **When the number is decided, two things change together:** 1. the constant and its tests; 2. the **justification for the clamp**, which the comments currently tie to concurrency. At 5-second granularity the clamp is no longer about concurrent builds at all — it is about a clock stepping backwards and about rebuilding an older commit, which are the two cases no clock-derived scheme can handle. Leaving the old justification in place would be worse than useless: it would make the clamp look redundant to whoever reads it next, and removable.
Author
Member

Done — #8 merged. next_version_code.py now derives from the committer date of the commit being built and clamps in both directions.

Verified independently: 98 passed, 4 skipped, 0 failed, and disabling the forwards clamp fails the test that guards the unrecoverable case —

FAIL: test_commit_dated_near_int32_does_not_produce_a_near_int32_result
FAIL: test_future_dated_commit_beyond_tolerance_is_clamped_and_warns

That distinction is the whole reason the forwards clamp exists: without it a future-dated commit is not merely odd, it can publish a versionCode no later build can exceed, leaving the app permanently un-updatable.

TOLERANCE_MINUTES is 6 hours, with a comment saying its job is keeping the number near the present rather than pinning it — so nobody tunes it precisely believing that matters.

The granularity question is settled and recorded in the docstring: minutes stand, purely as headroom (4,082 years), because concurrency is handled by a workflow concurrency group with cancel-in-progress (measured working on this instance) plus refuse-on-existing at publish. Without that note, the next reader sees a coarse granularity beside a clamp and removes one of them.

Done — #8 merged. `next_version_code.py` now derives from the **committer date** of the commit being built and clamps in both directions. Verified independently: **98 passed, 4 skipped, 0 failed**, and disabling the forwards clamp fails the test that guards the unrecoverable case — ``` FAIL: test_commit_dated_near_int32_does_not_produce_a_near_int32_result FAIL: test_future_dated_commit_beyond_tolerance_is_clamped_and_warns ``` That distinction is the whole reason the forwards clamp exists: without it a future-dated commit is not merely odd, it can publish a versionCode no later build can exceed, leaving the app permanently un-updatable. `TOLERANCE_MINUTES` is 6 hours, with a comment saying its job is keeping the number *near* the present rather than pinning it — so nobody tunes it precisely believing that matters. The granularity question is settled and recorded in the docstring: minutes stand, purely as headroom (4,082 years), because concurrency is handled by a workflow `concurrency` group with `cancel-in-progress` (measured working on this instance) plus refuse-on-existing at publish. Without that note, the next reader sees a coarse granularity beside a clamp and removes one of them.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weblib/fdroid-repo-ci#6