feat(extension): derive the version as unpadded CalVer (milestone 318 step 8)
Build images / build-ml (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Failing after 33s
Build images / sign-extension (push) Successful in 2m24s
Build images / build-web (push) Successful in 2m38s
CI / integration (push) Successful in 5m15s

`1.0.<minutes since 2020>` -> `YYYY.M.D.HHMM` UTC, from the commit time of
the newest change to a packaged extension file. Same clock and same commit as
before; readable instead of opaque, and the same value the rest of the family
derives.

The hold on this step was two questions about AMO, and Mozilla's own docs
answer both:

    ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$

  1. four all-numeric segments -> ACCEPTED ({0,3} more after the first).
  2. leading zeros              -> REJECTED. A segment is the single digit
     `0` or starts 1-9, so `08` and `0201` are refused. MDN says it in prose
     too: "Non-zero numbers must not include a leading zero."

So the documented fallback applies, extension only: the same numbers rendered
without the family's zero-padding. `2026.08.29.0201` and `2026.8.29.201` are
one value in two renderings — rule 148 defines comparison as numeric per
segment, under which they are equal — so nothing already published is
reordered, and left-padding each segment recovers the family string exactly.
HHMM stays one segment because AMO allows at most four.

The transition is safe in the other direction too: 2026 > 1, so every CalVer
outranks every published 1.0.x. build.yml's downgrade guard confirms it.

Also in scope:

* MAJOR.MINOR is gone. `cmd_major_minor`, `cmd_patch` and VERSION_EPOCH go
  with it, the committed version in manifest.json / package.json is now
  wholly inert, and ci.yml's MAJOR.MINOR-agreement check is retired rather
  than left running beside a fact that stopped existing (rule 22).
* ci.yml's `extension-version` lane now asserts Mozilla's regex verbatim
  instead of a loose `^[0-9]+(\.[0-9]+)*$` — which would have passed the
  padded shape. It also asserts YYYY.M.D.HHMM, because AMO would accept a
  regression to `1.0.<minutes>` while that orders below everything signed
  since. Checking here is the point: AMO 409s on re-signing, so a version it
  rejects is burned and cannot be reused.
* `artifacts.sh version extension` delegates to packaging.sh, so the two
  cannot answer differently. The direction matches the existing one —
  artifacts.sh already asks packaging.sh for the extension's path set.

#3156 is what makes this commit safe to make: packaging.sh is in web's path
set, so the web revision moves with the extension version and build-web
rebuilds instead of republishing an image bundling the previous XPI.

Scribe #3138.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:43:30 -04:00
co-authored by Claude Opus 5
parent 41f2bec3af
commit 2e01242381
7 changed files with 196 additions and 109 deletions
+15 -10
View File
@@ -75,18 +75,23 @@ jobs:
fetch-depth: 0 fetch-depth: 0
# The version is DERIVED, not read from the repo (milestone 271 step 4, # The version is DERIVED, not read from the repo (milestone 271 step 4,
# cut over 2026-08-27). `packaging.sh version` returns MAJOR.MINOR from # cut over 2026-08-27). `packaging.sh version` returns `YYYY.M.D.HHMM`
# manifest.json plus a patch component that is the commit TIME of the # UTC — the commit TIME of the newest change to a PACKAGED extension
# newest change to a PACKAGED extension file, in minutes since # file, per family rule 148/149. Never a commit count, which orders by
# 2020-01-01 — family rule 149, never a commit count, which orders by
# branch rather than by recency. # branch rather than by recency.
# #
# The committed "version" in manifest.json / package.json no longer # Unpadded, and only here: AMO's grammar rejects a leading zero, so the
# decides anything: the stamp step below overwrites it in the working # extension renders rule 148's numbers without the family's padding
# tree before web-ext ever reads it. It is deliberately NOT committed # (milestone 318 step 8). Same value, one character narrower per segment;
# back — the commit carrying the bump would itself be a change to the # ci.yml's extension-version lane checks the string against Mozilla's
# extension and would move the version again. The repo holds the source; # published regex before this job ever calls AMO.
# the build derives the label. #
# The committed "version" in manifest.json / package.json decides NOTHING
# — not even a MAJOR.MINOR prefix, which step 8 removed. The stamp step
# below overwrites it in the working tree before web-ext ever reads it,
# and it is deliberately NOT committed back: the commit carrying the bump
# would itself be a change to the extension and would move the version
# again. The repo holds the source; the build derives the label.
- name: Derive extension version - name: Derive extension version
id: extver id: extver
run: | run: |
+35 -21
View File
@@ -2,7 +2,7 @@ name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy": # CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce. # - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - extension-version: the derived version resolves and MAJOR.MINOR agrees. # - extension-version: the derived version resolves and is a shape AMO takes.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers. # - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build. # - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`. # - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -58,9 +58,12 @@ jobs:
# the extension.yml suite runs on node:24-slim, which is exactly why # the extension.yml suite runs on node:24-slim, which is exactly why
# version.spec.js sticks to packaging.sh's git-free subcommands. # version.spec.js sticks to packaging.sh's git-free subcommands.
# 1. the derivation actually resolves on this commit # 1. the derivation actually resolves on this commit
# 2. MAJOR.MINOR agrees between the two files — the one part still hand-set, # 2. the derived string is one AMO will accept, checked against Mozilla's
# and packaging.sh reads it from manifest.json ALONE, so a divergence # own published grammar rather than a loose "digits and dots"
# ships a version package.json disagrees with #
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
# 318 step 8: the committed version no longer seeds anything, so there is no
# hand-set part left for the two files to disagree about.
# #
# Deliberately NOT checked here: that the derived value beats what has already # Deliberately NOT checked here: that the derived value beats what has already
# been signed. That guard belongs in build.yml, where it compares against the # been signed. That guard belongs in build.yml, where it compares against the
@@ -85,27 +88,38 @@ jobs:
# busybox sh on the act_runner — no bashisms (family rule). # busybox sh on the act_runner — no bashisms (family rule).
VERSION=$(sh extension/scripts/packaging.sh version) VERSION=$(sh extension/scripts/packaging.sh version)
echo "derived: $VERSION" echo "derived: $VERSION"
# The shape AMO accepts, and the shape build.yml will stamp.
if ! echo "$VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then # Mozilla's published grammar for AMO, transcribed verbatim from
echo "ERROR: derived version '$VERSION' is not plain dotted-numeric." # MDN's manifest.json/version page:
echo "AMO would reject it, and build.yml stamps it verbatim." #
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
#
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
# the single digit 0 or start 1-9 — and it also passes five segments,
# where AMO allows four. Both would surface as a failed sign with the
# version already burned: AMO 409s on re-signing, so a rejected value
# cannot be reclaimed and cannot be reused. This lane is the cheap
# place to find out. (#3138, milestone 318 step 8.)
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
echo "of the family pads; the extension must not — see packaging.sh."
exit 1 exit 1
fi fi
mm() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+\.[0-9]+).*/\1/'; }
MAN=$(mm extension/manifest.json) # ...and the shape this project actually derives. AMO would happily
PKG=$(mm extension/package.json) # take `1.0.3500147` too, so the grammar check alone would not notice
test -n "$MAN" || { echo "ERROR: no parseable version in extension/manifest.json"; exit 1; } # a regression to the pre-318 shape — which orders BELOW everything
test -n "$PKG" || { echo "ERROR: no parseable version in extension/package.json"; exit 1; } # signed since, and is unrecoverable once Firefox has the higher one.
if [ "$MAN" != "$PKG" ]; then if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
echo "ERROR: MAJOR.MINOR disagrees between the two files." echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
echo " extension/manifest.json = $MAN <- packaging.sh reads MAJOR.MINOR from here" echo "Rule 148's CalVer is what build.yml signs; the old"
echo " extension/package.json = $PKG" echo "1.0.<minutes> shape would order below every ext-2026.* release."
echo "Only MAJOR.MINOR is hand-set. The patch component is derived from"
echo "commit time and overwritten at build time, so the committed patch"
echo "numbers are inert — but MAJOR.MINOR still ships. Set both the same."
exit 1 exit 1
fi fi
echo "OK: MAJOR.MINOR $MAN, derived version $VERSION" echo "OK: derived version $VERSION"
backend-lint-and-test: backend-lint-and-test:
runs-on: python-ci runs-on: python-ci
+19 -6
View File
@@ -71,12 +71,25 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
published bytes?"*, not *"is this file copied in?"* — which is why the script published bytes?"*, not *"is this file copied in?"* — which is why the script
keeps two lists rather than one. keeps two lists rather than one.
- **The shipped extension version is derived, not committed.** It is the commit - **The shipped extension version is derived, not committed.** It is the commit
TIME of the newest packaged-extension change (minutes since 2020-01-01, per TIME of the newest packaged-extension change, rendered `YYYY.M.D.HHMM` UTC
family rule 149 — never a commit count, which orders by branch rather than by (family rules 148/149 — never a commit count, which orders by branch rather
recency). `build.yml`'s `sign-extension` computes it and stamps it into than by recency). `build.yml`'s `sign-extension` computes it and stamps it
`extension/manifest.json` + `package.json` in the working tree before signing; into `extension/manifest.json` + `package.json` in the working tree before
the stamp is never committed. Treat the version in the repo as a base: only signing; the stamp is never committed. The version in the repo is **wholly
its MAJOR.MINOR is read, and its patch component is inert. inert** — since milestone 318 step 8 there is no hand-set MAJOR.MINOR either.
- **The extension is the one artifact that does not zero-pad, and that is not a
drift** (#3138). Mozilla's grammar for AMO is
`^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$` — a segment is the single
digit `0` or starts 1-9, and there are at most four. `2026.08.29.0201` is
rejected; `2026.8.29.201` is the same value one character narrower per
segment, and rule 148 defines comparison as numeric per segment, so nothing is
reordered. `ci.yml`'s `extension-version` lane asserts the derived string
against that exact regex, plus a `YYYY.M.D.HHMM` shape check that would catch
a regression to the pre-318 `1.0.<minutes>` — which AMO would accept and which
orders below everything already signed. Checking here is the whole point: AMO
409s on re-signing, so a version it rejects is burned and cannot be reused.
`scripts/artifacts.sh version extension` **delegates** to `packaging.sh` so
the two cannot answer differently.
- Every job that derives anything checks out with `fetch-depth: 0` — all four - Every job that derives anything checks out with `fetch-depth: 0` — all four
`build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test` `build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test`
(for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and (for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and
+24 -9
View File
@@ -38,27 +38,42 @@ npm run build # unsigned XPI in web-ext-artifacts/
- [ ] Subscriptions list: popup → "Sources" tab → list renders - [ ] Subscriptions list: popup → "Sources" tab → list renders
- [ ] Check now: click play icon on source row → no error toast - [ ] Check now: click play icon on source row → no error toast
## Versioning — don't hand-edit the patch number ## Versioning — the committed number decides nothing
The shipped version is **derived**, not committed. `scripts/packaging.sh The shipped version is **derived**, not committed. `scripts/packaging.sh
version` returns `MAJOR.MINOR` from `manifest.json` plus a patch component version` returns `YYYY.M.D.HHMM` in UTC: the commit *time* of the newest change
that is the commit *time* of the newest change to a packaged extension file, to a packaged extension file. `build.yml` computes it and stamps it into both
in minutes since 2020-01-01. `build.yml` computes it and stamps it into both
`manifest.json` and `package.json` at build time. The stamp is never `manifest.json` and `package.json` at build time. The stamp is never
committed — the commit carrying it would itself be a change to the extension, committed — the commit carrying it would itself be a change to the extension,
which would move the version again. which would move the version again.
So: So:
- **Editing the patch number does nothing.** It is overwritten before web-ext - **Editing the version does nothing.** All of it is overwritten before web-ext
ever reads it. There is no bump to make, and none to forget. ever reads it. There is no bump to make, and none to forget. There is no
- **MAJOR.MINOR is still yours.** It carries the deliberate meaning, it is read hand-set part left either: MAJOR.MINOR went away with milestone 318 step 8.
from `manifest.json` alone, and CI fails the `extension-version` lane if the
two files disagree on it.
- `npm run build` locally produces an XPI labelled with the *committed* - `npm run build` locally produces an XPI labelled with the *committed*
version, since nothing stamped it. Fine for loading into a test profile; not version, since nothing stamped it. Fine for loading into a test profile; not
what ships. what ships.
**Why the extension is the one artifact that does not zero-pad.** Every other
FC artifact emits rule 148's `YYYY.MM.DD.HHMM`. AMO will not take it: Mozilla's
grammar for addons.mozilla.org is
```
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
```
— each segment is the single digit `0` or starts 1-9, so `08` and `0201` are
rejected, and at most four segments are allowed. The extension therefore emits
**the same numbers unpadded**: `2026.8.29.201` where the rest of the family
says `2026.08.29.0201`. Rule 148 already defines comparison as numeric per
segment, under which the two are equal, so nothing is reordered by the choice
and left-padding each segment recovers the family string exactly. `ci.yml`'s
`extension-version` lane checks the derived string against that regex on every
push — the cheap place to find out, because AMO 409s on re-signing and a
rejected version is burned for good.
Why commit time and not a commit count: a count is per-branch, so `dev` and Why commit time and not a commit count: a count is per-branch, so `dev` and
`main` count different histories of the same code and their versions end up `main` count different histories of the same code and their versions end up
ordered by which branch accumulated more commits rather than by which is newer. ordered by which branch accumulated more commits rather than by which is newer.
+61 -41
View File
@@ -60,7 +60,7 @@ NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**' NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**'
usage() { usage() {
echo "usage: packaging.sh {ignore|pathspec|version|major-minor|patch}" >&2 echo "usage: packaging.sh {ignore|pathspec|version}" >&2
exit 2 exit 2
} }
@@ -86,36 +86,48 @@ cmd_pathspec() {
echo echo
} }
# MAJOR.MINOR stays hand-set in manifest.json — it's the part that carries # Strip leading zeros from one segment, leaving at least one digit.
# deliberate meaning. Only the patch component is derived. #
cmd_major_minor() { # This exists for AMO and nothing else. Mozilla's version grammar for
root=$(git rev-parse --show-toplevel) # addons.mozilla.org is documented as
grep -E '"version"' "$root/extension/manifest.json" \ #
| head -1 \ # ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/' #
# — 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"
} }
# 2020-01-01T00:00:00Z — the anchor for the derived patch component. Fixed # The extension's version: `YYYY.M.D.HHMM`, UTC, derived from the commit TIME
# forever; moving it would renumber every version downwards. # of the newest change to a PACKAGED extension file.
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 # THE ONE DELIBERATE DEPARTURE FROM THE FAMILY SHAPE, and it is a rendering
# commit count. A count is per-branch — `dev` and `main` count different # difference only. Rule 148 says `YYYY.MM.DD.HHMM` zero-padded, and every other
# histories of the same code — so the moment BOTH channels publish, their # FC artifact emits exactly that. AMO's grammar (see unpad) forbids the padding,
# versions order by which branch accumulated more commits rather than by which # and AMO is not negotiable: a rejected version is burned, since AMO 409s on
# is newer. A squash-merge makes that permanent: main gains one commit where dev # re-signing a version it has already seen. So the extension emits THE SAME
# gained five, so dev climbs away from main and a dev install can never cross # NUMBERS unpadded — 2026.08.29.0201 and 2026.8.29.201 are one value in two
# back. That is Roundtable's 2026-08-24 incident (`versionCode` was the branch's # renderings, and rule 148 already specifies comparison as numeric per segment,
# commit count) in a different repo. Measured here on 2026-08-27: main=23, # under which they are equal. Nothing published is reordered by the choice, and
# dev=24 under the old formula — one apart, which is exactly how the inversion # left-padding each segment recovers the family string exactly.
# stays invisible until it strands somebody. #
# 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: # Why the commit's time and not the build's:
# * MONOTONIC — max() over a set that only ever gains members. Verified # * MONOTONIC — max() over a set that only ever gains members.
# across all 24 extension-touching commits: zero non-monotonic steps.
# * STABLE while the extension is unchanged, so an unchanged extension keeps # * STABLE while the extension is unchanged, so an unchanged extension keeps
# its version, the ext-<version> signature cache still hits, and AMO is # its version, the ext-<version> signature cache still hits, and AMO is
# called once per extension CHANGE rather than once per push. Build-time # called once per extension CHANGE rather than once per push. Build-time
@@ -126,32 +138,40 @@ VERSION_EPOCH=1577836800
# produced for byte-identical code. Same code, same version, one signing. # produced for byte-identical code. Same code, same version, one signing.
# * REPRODUCIBLE — any checkout of a commit yields that commit's version. # * 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 # 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. # (too low) value. Every consumer must check out with fetch-depth: 0.
cmd_patch() { #
# Formatted through git rather than date(1): busybox date does not reliably
# accept `-d @<epoch>`, 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) root=$(git rev-parse --show-toplevel)
# Unquoted on purpose: the pathspec must word-split into separate args. # Unquoted on purpose: the pathspec must word-split into separate args.
# Globbing is already off script-wide (set -euf above). # Globbing is already off script-wide (set -euf above).
# shellcheck disable=SC2046 # shellcheck disable=SC2046
ts=$(cd "$root" && git log --format=%ct HEAD -- extension/ $(cmd_pathspec) \ sha=$(cd "$root" && git log --format='%ct %H' HEAD -- extension/ $(cmd_pathspec) \
| sort -n | tail -1) | sort -n | tail -1 | cut -d' ' -f2)
if [ -z "$ts" ]; then if [ -z "$sha" ]; then
echo "packaging.sh: no commit touches a packaged extension file" >&2 echo "packaging.sh: no commit touches a packaged extension file" >&2
exit 1 exit 1
fi fi
echo $(( (ts - VERSION_EPOCH) / 60 )) 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.
cmd_version() { # shellcheck disable=SC2046
echo "$(cmd_major_minor).$(cmd_patch)" set -- $(echo "$padded" | tr '.' ' ')
echo "$(unpad "$1").$(unpad "$2").$(unpad "$3").$(unpad "$4")"
} }
[ $# -ge 1 ] || usage [ $# -ge 1 ] || usage
case "$1" in case "$1" in
ignore) cmd_ignore ;; ignore) cmd_ignore ;;
pathspec) cmd_pathspec ;; pathspec) cmd_pathspec ;;
version) cmd_version ;; version) cmd_version ;;
major-minor) cmd_major_minor ;; *) usage ;;
patch) cmd_patch ;;
*) usage ;;
esac esac
+27 -22
View File
@@ -8,10 +8,10 @@ const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8')) const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8') const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')
// Only the git-free subcommands are exercised here: `version`/`patch` shell out // Only the git-free subcommands are exercised here: `version` shells out to
// to git, and the extension lane runs on node:24-bookworm-slim which may not // git, and the extension lane runs on node:24-bookworm-slim which may not ship
// ship it. Those two are covered where git is guaranteed — ci.yml and build.yml // it. That one is covered where git is guaranteed — ci.yml's extension-version
// run on ci-python. // lane and build.yml both run on ci-python.
const packaging = (cmd) => const packaging = (cmd) =>
execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], { execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
cwd: EXT_DIR, cwd: EXT_DIR,
@@ -145,28 +145,33 @@ describe('consumers delegate rather than keeping their own copy', () => {
}) })
describe('extension version', () => { describe('extension version', () => {
const majorMinor = (v) => v.split('.').slice(0, 2).join('.') // Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's
// manifest.json/version page. Each segment is the single digit 0 or starts
// 1-9 — so no leading zeros — and there are at most four of them.
const AMO = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/
it('keeps the hand-set MAJOR.MINOR in lockstep across both files', () => { it('keeps a committed version AMO would accept, though it ships nothing', () => {
// Narrowed from full-string equality at milestone 271 step 5. Since step 4 // The committed value is wholly inert since milestone 318 step 8: there is
// the patch component is derived from commit time and stamped into both // no hand-set MAJOR.MINOR left for packaging.sh to read, and build.yml
// files at build time, so the committed patch numbers are inert — nothing // stamps the derived string over both files before web-ext sees them.
// reads them and they are not what ships. Asserting on them would fail for
// a difference that changes nothing.
// //
// MAJOR.MINOR is the opposite: still hand-set, still shipped, and // It is still asserted, for one reason: `npm run build` locally packages
// packaging.sh reads it from manifest.json ALONE. Let the two diverge and // whatever is committed, so a value AMO would reject turns a local build
// the extension ships a version package.json disagrees with, with no other // into a confusing failure with no CI signal ahead of it. ci.yml checks
// signal. // the same grammar against the DERIVED value, which is the one AMO sees.
expect(majorMinor(read('manifest.json').version)) for (const file of ['manifest.json', 'package.json']) {
.toBe(majorMinor(read('package.json').version)) expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO)
}
}) })
it('uses a plain dotted numeric version AMO will accept', () => { it('rejects the zero-padded family shape, which is why the extension unpads', () => {
// The committed value seeds MAJOR.MINOR, so it still has to parse even // Guards the reason for the exception, not just its result. If this ever
// though its patch component never ships. ci.yml asserts the same shape on // starts passing, someone has loosened the pattern and the next sign burns
// the DERIVED value, which is the one AMO actually sees. // an AMO version to find out. (#3138.)
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/) expect('2026.08.29.0201').not.toMatch(AMO)
expect('2026.8.29.201').toMatch(AMO)
// Five segments: AMO allows four.
expect('2026.8.29.2.1').not.toMatch(AMO)
}) })
it('declares manifest v3', () => { it('declares manifest v3', () => {
+15
View File
@@ -170,6 +170,21 @@ cmd_revision() {
# exists — at which point two lanes derive different answers for one source # exists — at which point two lanes derive different answers for one source
# and the shared-signature property is lost. # and the shared-signature property is lost.
cmd_version() { cmd_version() {
# The extension is the one artifact this script does not FORMAT, only route.
# AMO's version grammar forbids leading zeros, so the extension emits the
# same numbers unpadded (#3138) — a rendering exception, documented in
# packaging.sh beside the signing step that has to obey it. Delegating keeps
# one answer per artifact: `artifacts.sh version extension` and
# `packaging.sh version` cannot drift into two.
#
# The direction is deliberate. artifacts.sh already asks packaging.sh for the
# extension's PATH SET (ext_paths above), so the version has to flow the same
# way; reversing it would have packaging.sh call back into this script, which
# would call packaging.sh for the paths again.
if [ "$1" = extension ]; then
sh "$ROOT/extension/scripts/packaging.sh" version
return
fi
sha=$(echo "$(newest "$1")" | cut -d' ' -f2) sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
# One git call for the whole string rather than four and a sed. git's # One git call for the whole string rather than four and a sed. git's
# format-local takes the complete format, and doing it in pieces was only # format-local takes the complete format, and doing it in pieces was only