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
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:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,24 +138,34 @@ 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
|
||||||
@@ -151,7 +173,5 @@ 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 ;;
|
|
||||||
patch) cmd_patch ;;
|
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user