#!/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}" >&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 } # Strip leading zeros from one segment, leaving at least one digit. # # This exists for AMO and nothing else. Mozilla's version grammar for # addons.mozilla.org is documented as # # ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$ # # — each segment is either the single digit `0` or starts 1-9, so `08` and # `0201` are rejected outright, while `0` itself is fine. MDN states it in # prose too: "Non-zero numbers must not include a leading zero." # # POSIX sh has no trim-loop, hence the while. unpad() { s=$1 while [ "${#s}" -gt 1 ]; do case "$s" in 0*) s=${s#0} ;; *) break ;; esac done printf '%s' "$s" } # The extension's version: `YYYY.M.D.HHMM`, UTC, derived from the commit TIME # of the newest change to a PACKAGED extension file. # # THE ONE DELIBERATE DEPARTURE FROM THE FAMILY SHAPE, and it is a rendering # difference only. Rule 148 says `YYYY.MM.DD.HHMM` zero-padded, and every other # FC artifact emits exactly that. AMO's grammar (see unpad) forbids the padding, # and AMO is not negotiable: a rejected version is burned, since AMO 409s on # re-signing a version it has already seen. So the extension emits THE SAME # NUMBERS unpadded — 2026.08.29.0201 and 2026.8.29.201 are one value in two # renderings, and rule 148 already specifies comparison as numeric per segment, # under which they are equal. Nothing published is reordered by the choice, and # left-padding each segment recovers the family string exactly. # # HHMM is one segment, not two, because AMO allows at most FOUR. Unpadded that # reads oddly (00:14 -> `14`, midnight -> `0`) but stays strictly increasing # within a day, which is all the ordering needs. # # Why the commit's time and not the build's: # * MONOTONIC — max() over a set that only ever gains members. # * 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. # # Never a commit count (family rule 149): a count is per-branch, so `dev` and # `main` count different histories of the same code and order by which branch # accumulated more commits rather than by which is newer. A squash-merge makes # that permanent. Roundtable's 2026-08-24 incident, in a different repo. # # 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. # # Formatted through git rather than date(1): busybox date does not reliably # accept `-d @`, and git's --date=format-local is available wherever git # is. TZ=UTC so the value does not depend on the runner's timezone. cmd_version() { 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 sha=$(cd "$root" && git log --format='%ct %H' HEAD -- extension/ $(cmd_pathspec) \ | sort -n | tail -1 | cut -d' ' -f2) if [ -z "$sha" ]; then echo "packaging.sh: no commit touches a packaged extension file" >&2 exit 1 fi padded=$(cd "$root" && TZ=UTC git show -s --format=%cd \ --date='format-local:%Y.%m.%d.%H%M' "$sha") # Rebinding the function's own positional params, which are unused here. # shellcheck disable=SC2046 set -- $(echo "$padded" | tr '.' ' ') echo "$(unpad "$1").$(unpad "$2").$(unpad "$3").$(unpad "$4")" } [ $# -ge 1 ] || usage case "$1" in ignore) cmd_ignore ;; pathspec) cmd_pathspec ;; version) cmd_version ;; *) usage ;; esac