Rule 149: an artifact's ordering key must be time-derived, never a commit count. packaging.sh's cmd_patch was a count. Why that matters here rather than in the abstract. A count is per-branch: dev and main count different histories of the SAME code. Today only main signs, so nothing has ordered the two against each other and the fault is invisible. The moment dev also publishes an extension, the two versions order by which branch accumulated more commits rather than by which is newer — and a squash-merge makes it permanent, because main gains one commit where dev gained five. dev then climbs away from main and a dev install can never cross back. That is Roundtable's 2026-08-24 incident (Scribe #2993) in a different repo: their versionCode was the branch's commit count, and it produced a channel you could enter and not leave. Measured on this repo today the old formula gives main=23, dev=24 — one apart, which is exactly how the inversion stays invisible until it strands somebody. New formula: minutes since 2020-01-01 of the LATEST commit touching a packaged extension file. Same anchor and unit Roundtable settled on. Commit time, not build time, and the difference is load-bearing: - stable while the extension is unchanged, so 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. - 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. - monotonic: max() over a set that only gains members. Verified across all 24 extension-touching commits, zero non-monotonic steps. - reproducible from any checkout. Derives 1.0.3499884 on dev, 1.0.3465860 on main — both far above the last hand-set 1.0.11, so milestone 271's backfill guard is satisfied by construction rather than by an offset. Still shadow-only: nothing reads the derived value yet. Both shadow steps log it, and ci.yml's runs on dev too, so both channels' numbers are visible — that is the pair that has to stay ordered. Prior shadow observations describe the OLD formula and prove nothing about this one, so the window restarts; ci.yml says so at the step. New requirement recorded in ci-requirements.md: a depth-1 clone derives a wrong, too-low value rather than failing, so fetch-depth: 0 is load-bearing wherever packaging.sh version is called. Refs #3092, milestone 271
131 lines
5.7 KiB
Bash
Executable File
131 lines
5.7 KiB
Bash
Executable File
#!/bin/sh
|
|
# Single source of truth for "what ships inside the XPI", plus the version
|
|
# derived from it.
|
|
#
|
|
# Three consumers used to hand-maintain their own copy of this list, and
|
|
# keeping three copies of one fact in sync by hand is how issue #2397 happened:
|
|
#
|
|
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
|
# 2. the :(exclude) pathspec (ci.yml's extension-version guard)
|
|
# 3. the git-log pathspec (the derived version, below)
|
|
#
|
|
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
|
#
|
|
# -f (no pathname expansion) is set for the whole script and is load-bearing:
|
|
# the lists below are iterated with deliberate word-splitting, and without -f
|
|
# the shell would also GLOB them, expanding `test/**` into whatever files
|
|
# happen to exist and corrupting the output. A caller's own `set -f` does not
|
|
# help here — this runs as a separate sh process and does not inherit it.
|
|
# Callers still need their own `set -f` for the substituted result; the two
|
|
# guards protect different expansions.
|
|
set -euf
|
|
|
|
# Paths under extension/ that are NOT packaged into the XPI.
|
|
#
|
|
# Split by whether git tracks them: node_modules and web-ext-artifacts are
|
|
# build/dependency output that never appears in a commit, so they belong in
|
|
# web-ext's ignore list but would be meaningless in a git pathspec.
|
|
#
|
|
# Directories need BOTH forms. `test/**` matches the files inside, but not the
|
|
# directory entry itself — web-ext writes an entry for the directory too, so
|
|
# with only the glob the XPI ends up carrying empty `test/` and `scripts/`
|
|
# entries (caught by the XPI-content check on 2026-08-03). The bare name alone
|
|
# is not enough either: minimatch's `test` does not match `test/url.spec.js`,
|
|
# so dropping the glob would ship the contents. Keep both.
|
|
NOT_PACKAGED_TRACKED='package.json package-lock.json README.md .gitignore vitest.config.js scripts scripts/** test test/**'
|
|
NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
|
|
|
|
usage() {
|
|
echo "usage: packaging.sh {ignore|pathspec|version|major-minor|patch}" >&2
|
|
exit 2
|
|
}
|
|
|
|
# web-ext --ignore-files values, space-separated.
|
|
#
|
|
# Callers MUST disable pathname expansion first (`set -f`), or the shell will
|
|
# glob `test/**` against the working tree before web-ext ever sees the pattern
|
|
# and silently narrow it to whatever happens to exist right now.
|
|
cmd_ignore() {
|
|
echo "$NOT_PACKAGED_TRACKED $NOT_PACKAGED_BUILD"
|
|
}
|
|
|
|
# git pathspec excluding the non-packaged tracked files, e.g.
|
|
# :(exclude)extension/package.json :(exclude)extension/test/**
|
|
# Same `set -f` requirement as above.
|
|
cmd_pathspec() {
|
|
for entry in $NOT_PACKAGED_TRACKED; do
|
|
printf ':(exclude)extension/%s ' "$entry"
|
|
done
|
|
echo
|
|
}
|
|
|
|
# MAJOR.MINOR stays hand-set in manifest.json — it's the part that carries
|
|
# deliberate meaning. Only the patch component is derived.
|
|
cmd_major_minor() {
|
|
root=$(git rev-parse --show-toplevel)
|
|
grep -E '"version"' "$root/extension/manifest.json" \
|
|
| head -1 \
|
|
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/'
|
|
}
|
|
|
|
# 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.
|
|
#
|
|
# 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
|
|
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() {
|
|
echo "$(cmd_major_minor).$(cmd_patch)"
|
|
}
|
|
|
|
[ $# -ge 1 ] || usage
|
|
case "$1" in
|
|
ignore) cmd_ignore ;;
|
|
pathspec) cmd_pathspec ;;
|
|
version) cmd_version ;;
|
|
major-minor) cmd_major_minor ;;
|
|
patch) cmd_patch ;;
|
|
*) usage ;;
|
|
esac
|