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>
202 lines
9.6 KiB
Bash
Executable File
202 lines
9.6 KiB
Bash
Executable File
#!/bin/sh
|
|
# Single definition of WHAT EACH PUBLISHED ARTIFACT IS BUILT FROM, and the
|
|
# version derived from it. Milestone 313; generalises the shape
|
|
# extension/scripts/packaging.sh established for the extension alone.
|
|
#
|
|
# "Built from" is deliberately wider than "copied into". A file that DECIDES an
|
|
# artifact's identity is part of what that artifact is built from even though it
|
|
# never reaches the image — see DERIVER below, and #3156 for the same finding
|
|
# about packaging.sh.
|
|
#
|
|
# Four artifacts, four independent versions. An artifact whose shipped files
|
|
# did not change keeps its version and does not rebuild — that is the whole
|
|
# point, and it is why each path set must match its Dockerfile rather than
|
|
# being a plausible guess. Getting a set wrong is quiet in BOTH directions:
|
|
#
|
|
# too narrow -> a pin serves stale bytes, because the version did not move
|
|
# when the content did. This is the dangerous one.
|
|
# too wide -> the artifact re-versions and rebuilds for a change it does
|
|
# not ship. Merely wasteful.
|
|
#
|
|
# tests/test_artifact_paths.py asserts every COPY source in each Dockerfile is
|
|
# covered here, so adding a COPY without updating this file fails CI.
|
|
#
|
|
# POSIX sh only — CI's run shell is busybox on some paths.
|
|
#
|
|
# -f (no pathname expansion) is load-bearing for the whole script: the lists
|
|
# below are iterated with deliberate word-splitting, and without it the shell
|
|
# would glob `frontend/test/**` against the working tree and silently narrow
|
|
# the pattern. Callers substituting the output need their own `set -f` too;
|
|
# the two guards protect different expansions.
|
|
set -euf
|
|
|
|
ROOT=$(git rev-parse --show-toplevel)
|
|
|
|
# --- what each artifact ships ------------------------------------------------
|
|
#
|
|
# Each set includes its own Dockerfile and requirements: changing a base image
|
|
# or a pin changes the artifact just as surely as changing a source file.
|
|
#
|
|
# web (Dockerfile, context `.`) — the runtime stage copies backend/, alembic/,
|
|
# alembic.ini, entrypoint.sh and requirements.txt; the frontend-builder stage
|
|
# copies frontend/ and the runtime takes its `dist` output.
|
|
#
|
|
# frontend/test is excluded: `npm run build` is vite, which builds from src/,
|
|
# index.html and public/ and never reads test/. It lands in the builder layer
|
|
# but not in `dist`, so it cannot reach the shipped image.
|
|
#
|
|
# The web image ALSO bundles the signed XPI (build.yml downloads it into
|
|
# frontend/public/extension/ before the docker build), so an extension change
|
|
# changes the web image. The extension's packaged set is appended in cmd_paths
|
|
# rather than restated — one definition, per #2397.
|
|
WEB_PATHS='Dockerfile requirements.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
|
|
|
|
# ml (Dockerfile.ml, context `.`) — no frontend, no extension. Note it copies
|
|
# BOTH requirements-ml.txt and requirements.txt.
|
|
ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic alembic.ini entrypoint.sh'
|
|
|
|
# agent (agent/Dockerfile, context `agent`) — copies requirements.txt and
|
|
# fc_agent only. agent/README.md, agent/docker-compose.yml and agent/ruff.toml
|
|
# live in the directory but never reach the image, so they must not re-version
|
|
# it: this is deliberately NOT `agent/`.
|
|
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
|
|
|
|
# This file. It is copied into no image and it is still part of what the web
|
|
# image is built from, because it DECIDES the FC_VERSION baked into that image
|
|
# (#3202). Same finding as #3156 about packaging.sh, one level up.
|
|
#
|
|
# Why web and nothing else. Every artifact stamps `fc.revision`, but only web
|
|
# also stamps a version (build.yml line ~488 feeds `version web` to the
|
|
# FC_VERSION build arg; ml and agent ask for `revision` alone, and the
|
|
# extension takes its version from packaging.sh). For a revision-only artifact
|
|
# this file needs no entry: any change to how the revision is COMPUTED changes
|
|
# the derived value, which then disagrees with the label on the published image
|
|
# and forces a rebuild. That mechanism is self-correcting because it compares
|
|
# against a string stamped into a real artifact.
|
|
#
|
|
# The version is compared against nothing, so it has no such backstop. Before
|
|
# this entry, a change to cmd_version alone left every artifact's revision
|
|
# untouched, the reuse check hit, the build was skipped, and the published
|
|
# image went on reporting the OLD version format — silently, until some
|
|
# unrelated commit happened to force a rebuild. Milestone 318 step 5 is the
|
|
# worked instance: b3989d0 and 5771fd5 share revision fb2c4d5b80be while the
|
|
# version moved 2026.8.28.1249 -> 2026.08.28.1249. It cost nothing only because
|
|
# FC_VERSION did not exist until one commit later.
|
|
#
|
|
# Named as a file, not as `scripts`: release_notes.py lives beside it and only
|
|
# READS derived values, so it decides nothing and must not re-version anything.
|
|
# A future script that derives an identity belongs here explicitly.
|
|
DERIVER='scripts/artifacts.sh'
|
|
|
|
|
|
usage() {
|
|
echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2
|
|
exit 2
|
|
}
|
|
|
|
# The extension's packaged set, read from its own definition rather than
|
|
# copied. packaging.sh emits `:(exclude)extension/...` entries, so the bare
|
|
# `extension` include has to come with them.
|
|
ext_paths() {
|
|
echo "extension $(sh "$ROOT/extension/scripts/packaging.sh" pathspec)"
|
|
}
|
|
|
|
cmd_paths() {
|
|
case "$1" in
|
|
web) echo "$WEB_PATHS $DERIVER $(ext_paths)" ;;
|
|
ml) echo "$ML_PATHS" ;;
|
|
agent) echo "$AGENT_PATHS" ;;
|
|
extension) ext_paths ;;
|
|
*) usage ;;
|
|
esac
|
|
}
|
|
|
|
# "<unix ts> <sha>" of the newest commit touching this artifact's shipped set.
|
|
# Unquoted on purpose: the pathspec must word-split into separate args.
|
|
# Globbing is already off script-wide.
|
|
newest() {
|
|
# shellcheck disable=SC2046
|
|
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $(cmd_paths "$1") \
|
|
| sort -n | tail -1)"
|
|
if [ -z "$1" ]; then
|
|
echo "artifacts.sh: no commit touches this artifact's shipped files" >&2
|
|
exit 1
|
|
fi
|
|
echo "$1"
|
|
}
|
|
|
|
# Formatted through git rather than date(1): busybox date does not reliably
|
|
# accept `-d @<epoch>`, and git's own --date=format-local is available wherever
|
|
# git is. TZ=UTC so the value does not depend on the runner's timezone.
|
|
fmt() {
|
|
(cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1")
|
|
}
|
|
|
|
# The IDENTITY of an artifact's content: the commit its shipped files last
|
|
# changed in. This is what decides whether a build can be skipped.
|
|
#
|
|
# It is published as the `fc.revision` LABEL on the image itself, and read
|
|
# back off the moving channel tag — not as a tag of its own (milestone 318
|
|
# step 3). A tag would be a name minted per build that only one thing reads,
|
|
# which is what rule 145 narrowed against; it would also be prunable under the
|
|
# registry's keep_pattern (#3157), so the cache would silently expire.
|
|
#
|
|
# A published image with no such label reads as a MISS and rebuilds. That is
|
|
# the migration path, not a fault: `imagetools create` copies a manifest and
|
|
# config labels are not manifest annotations, so the reuse path cannot stamp
|
|
# one and there is nothing to backfill. Each artifact pays one rebuild, once.
|
|
cmd_revision() {
|
|
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
|
|
}
|
|
|
|
# The VERSION: `YYYY.MM.DD.HHMM`, zero-padded, UTC. One shape across the whole
|
|
# family (note #3127 §1, rule 148) — the number an instance reports about
|
|
# itself, and, with a `v` in front, the release tag naming the same build.
|
|
#
|
|
# Zero-padded since 2026-08-28. This stripped leading zeros until then, on the
|
|
# reasoning that every segment should read as a plain integer — which never
|
|
# held, since comparison strips them on parse anyway. Padding costs nothing,
|
|
# sorts lexically as well as numerically, and keeps this project emitting the
|
|
# same string as its siblings: unpadded, a `2026.8.28.1432` here sits beside a
|
|
# `2026.08.28.1432` there, two shapes one character apart. Two obviously
|
|
# different formats are safer than two nearly identical ones.
|
|
#
|
|
# Comparison is numeric per dot-segment, so `08` and `8` are equal and nothing
|
|
# already published is reordered by the change.
|
|
#
|
|
# HHMM is not decoration: it is what makes the value unique per build with no
|
|
# lookup. A date alone collides on the second build of a day, and resolving
|
|
# that needs a `.N` suffix, which needs asking the registry what already
|
|
# exists — at which point two lanes derive different answers for one source
|
|
# and the shared-signature property is lost.
|
|
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)
|
|
# 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
|
|
# ever there to strip the padding between them.
|
|
fmt "$sha" '%Y.%m.%d.%H%M'
|
|
}
|
|
|
|
[ $# -ge 2 ] || usage
|
|
case "$1" in
|
|
paths) cmd_paths "$2" ;;
|
|
revision) cmd_revision "$2" ;;
|
|
version) cmd_version "$2" ;;
|
|
*) usage ;;
|
|
esac
|