Extension channels: dev and main each carry their own signed extension #237

Merged
bvandeusen merged 16 commits from dev into main 2026-08-27 12:49:40 -04:00
3 changed files with 55 additions and 14 deletions
Showing only changes of commit cd5444e3ae - Show all commits
+6
View File
@@ -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"
# -----------------------------------------------------------------
+9 -5
View File
@@ -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
+40 -9
View File
@@ -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-<version> 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() {