#!/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 (what moves the version — a WIDER # set than the ignore list; see # NOT_VERSION_RELEVANT) # 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' # Paths under extension/ that cannot change the SHIPPED BYTES, and so must not # move the derived version. # # Deliberately NOT the same list as NOT_PACKAGED_TRACKED, and the whole # difference is `scripts/`. packaging.sh is not packaged into the XPI — but it # DECIDES the version string, and build.yml stamps that string into the # manifest.json that is packaged. A change to how the version is computed is # therefore a change to the shipped bytes. # # Excluding it was harmless only while every push rebuilt the web image. # Milestone 313 step 4 made the rebuild conditional on the derived revision # moving, which turned it into a silent failure: a packaging.sh change gives a # NEW version, so sign-extension misses its ext- cache and signs — # while build-web sees an unmoved revision, reuses the published image, and # ships the OLD XPI. An orphaned AMO signature, and an instance quietly serving # code the registry says is current. # # The two directions are not symmetric, which is why this list is the narrower # one. Too wide costs a re-sign and a rebuild for a change that ships nothing # new. Too narrow serves stale bytes and says nothing. NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**' 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 tracked files that cannot change the shipped # bytes, e.g. :(exclude)extension/package.json :(exclude)extension/test/** # # This answers "what moves the version?", NOT "what goes in the XPI?" — see # NOT_VERSION_RELEVANT for why those differ. cmd_ignore answers the other one. # Same `set -f` requirement as above. cmd_pathspec() { for entry in $NOT_VERSION_RELEVANT; 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- 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