diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index fccfe19..e9514a7 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -84,6 +84,12 @@ jobs: # can't be reclaimed. Logging it against real pushes first is the only # way to validate it at zero cost. # Placed before every early-exit path so it reports on all runs. + # + # The formula CHANGED on 2026-08-27 (commit count -> commit time, per + # rule 149), so observations logged before that date describe the old + # one and prove nothing about this. The window restarts here. Unlike + # build.yml's copy this runs on dev too, so both channels' numbers are + # visible — which is the pair that has to stay ordered. DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE") echo "shadow: manual=$PKG derived=$DERIVED" # ----------------------------------------------------------------- diff --git a/ci-requirements.md b/ci-requirements.md index 2d69ef4..620da37 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -56,11 +56,15 @@ per `docs/process.md`'s "add deps to the image when used by >1 project". - **`extension/scripts/packaging.sh` is the single definition of what ships inside the XPI.** Three consumers read from it rather than keeping their own copy: web-ext's `--ignore-files` (`extension/package.json`), the `:(exclude)` - pathspec in `ci.yml`'s `extension-version` guard, and the commit count that - derives the extension version. Three hand-kept copies of that one fact is - what allowed issue #2397. -- `build.yml`'s `sign-extension` checks out with `fetch-depth: 0` — the derived - extension version is a commit count, which a shallow clone cannot produce. + pathspec in `ci.yml`'s `extension-version` guard, and the `git log` pathspec + that derives the extension version. Three hand-kept copies of that one fact + is what allowed issue #2397. +- Jobs that derive the extension version check out with `fetch-depth: 0`. The + version is the commit TIME of the newest packaged-extension change (minutes + since 2020-01-01, per family rule 149 — never a commit count, which orders + by branch rather than by recency). A depth-1 clone sees one commit and + derives a wrong, too-low value rather than failing, so the full-history + checkout is load-bearing wherever `packaging.sh version` is called. - Callers MUST `set -f` before substituting the script's output. Without it the shell expands `test/**` against the working tree and silently narrows the pattern to whatever files exist at that moment — a failure that looks like diff --git a/extension/scripts/packaging.sh b/extension/scripts/packaging.sh index 523b871..a8aa969 100755 --- a/extension/scripts/packaging.sh +++ b/extension/scripts/packaging.sh @@ -7,7 +7,7 @@ # # 1. web-ext's --ignore-files (extension/package.json's four scripts) # 2. the :(exclude) pathspec (ci.yml's extension-version guard) -# 3. the rev-list pathspec (the derived version, below) +# 3. the git-log pathspec (the derived version, below) # # They now all read from here. POSIX sh only — CI's run shell is busybox. # @@ -68,20 +68,51 @@ cmd_major_minor() { | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/' } -# Count of commits that touched a PACKAGED extension file. Monotonic on a -# branch (the count only grows), which is a correctness requirement, not a -# nicety: Firefox refuses to install a version lower than the one present. +# 2020-01-01T00:00:00Z — the anchor for the derived patch component. Fixed +# forever; moving it would renumber every version downwards. +VERSION_EPOCH=1577836800 + +# Minutes since VERSION_EPOCH of the LATEST commit that touched a PACKAGED +# extension file. # -# Merge commits need no special handling — git's history simplification already -# prunes merges that don't change the pathspec, so --no-merges is a no-op here -# (verified on main: both forms return the same count). +# Time-derived, per family rule 149: an artifact's ordering key must never be a +# commit count. A count is per-branch — `dev` and `main` count different +# histories of the same code — so the moment BOTH channels publish, their +# versions order by which branch accumulated more commits rather than by which +# is newer. A squash-merge makes that permanent: main gains one commit where dev +# gained five, so dev climbs away from main and a dev install can never cross +# back. That is Roundtable's 2026-08-24 incident (`versionCode` was the branch's +# commit count) in a different repo. Measured here on 2026-08-27: main=23, +# dev=24 under the old formula — one apart, which is exactly how the inversion +# stays invisible until it strands somebody. +# +# Why the commit's time and not the build's: +# * MONOTONIC — max() over a set that only ever gains members. Verified +# across all 24 extension-touching commits: zero non-monotonic steps. +# * STABLE while the extension is unchanged, so an unchanged extension keeps +# its version, the ext- signature cache still hits, and AMO is +# called once per extension CHANGE rather than once per push. Build-time +# minutes would re-sign on every push and never let two channels share a +# signature. +# * SHARED ACROSS CHANNELS — after a merge, `main` sees the same commit and +# derives the same number, so `:latest` reuses the signature `:dev` already +# produced for byte-identical code. Same code, same version, one signing. +# * REPRODUCIBLE — any checkout of a commit yields that commit's version. +# +# Requires real history: a depth-1 clone sees one commit and will derive a wrong +# (too low) value. Every consumer must check out with fetch-depth: 0. cmd_patch() { root=$(git rev-parse --show-toplevel) # Unquoted on purpose: the pathspec must word-split into separate args. # Globbing is already off script-wide (set -euf above). # shellcheck disable=SC2046 - count=$(cd "$root" && git rev-list --count HEAD -- extension/ $(cmd_pathspec)) - echo "$count" + ts=$(cd "$root" && git log --format=%ct HEAD -- extension/ $(cmd_pathspec) \ + | sort -n | tail -1) + if [ -z "$ts" ]; then + echo "packaging.sh: no commit touches a packaged extension file" >&2 + exit 1 + fi + echo $(( (ts - VERSION_EPOCH) / 60 )) } cmd_version() {