CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 4s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 1m2s
CI and images / build-agent (push) Successful in 10m7s
CI and images / promote (push) Skipped
Operator: the agent's build string could not identify the agent. VERSION was a
literal in app.py an author was meant to bump, and nobody did — the September
image printed the same "2026-07-17.1" as the July one, so the one surface
meant to answer "did my pull work?" answered the same either way.
Nothing new was needed. scripts/artifacts.sh has derived a version per
artifact since milestone 313, and build-agent has been computing the agent's
on every run and printing it to the log. The image just never carried it.
Three values, never folded together (rule 149):
FC_VERSION YYYY.MM.DD.HHMM from the COMMIT its shipped files last changed
in — identical on dev and main for the same source, which is
what makes "am I running production's code?" answerable.
FC_CHANNEL a sibling field, never a suffix inside the name.
FC_REVISION the 12-char sha; the same string as the fc.revision LABEL, so
the image and the registry cannot disagree about which commit
this is.
The page SHOWS the version and COMPARES the revision. Those were one value
before, which is how a version acquires a second job and then cannot be
changed without breaking the reload banner. An unstamped local build reads
`unknown` and compares `local` — absent rather than empty, one spelling of
"cannot say".
scripts/artifacts.sh joins the AGENT path set in the same commit, and it had
to: a version has no backstop. A revision that is computed differently stops
matching the published label and forces a rebuild, so it self-corrects; a
version is compared against nothing, so a change to cmd_version alone would
leave the agent publishing the old format with nothing to contradict it. That
is #3202's finding, and the agent was rightly exempt only while it had no
version of its own. tests/test_artifact_paths.py pins it.
Also corrects two build.yml comments claiming agent/ had not changed since
2026-07-17. Both were already false — it changed 2026-09-23 — and one of them
is the stated rationale for the force_build escape hatch. Rewritten without
dates: how long an artifact has been quiet is a `git log` question, and its
answer in a comment is wrong the next time anyone commits (lesson #4383).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
266 lines
13 KiB
Bash
Executable File
266 lines
13 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.
|
|
#
|
|
# Three artifacts, three 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 requirements-ml.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
|
|
|
|
# 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/`.
|
|
#
|
|
# $DERIVER is appended in cmd_paths, not here, so the two artifacts that stamp
|
|
# a version pick it up the same way rather than one of them carrying it in its
|
|
# literal — see DERIVER below for why it belongs to both.
|
|
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.
|
|
#
|
|
# Web AND agent, since 2026-09-24. Both stamp a VERSION into the image they
|
|
# build (FC_VERSION), and the extension takes its version from packaging.sh.
|
|
#
|
|
# The agent was deliberately absent from this list until then, and the reason
|
|
# it was correct to be absent is the reason it no longer is: it asked this
|
|
# script for `revision` alone. 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.
|
|
#
|
|
# Giving the agent a self-reported version took that backstop away from it, so
|
|
# it inherits web's exposure and has to be listed here in the SAME change that
|
|
# introduced the version. Left out, the failure is the one recorded below: a
|
|
# change to `cmd_version` alone would move no revision, the reuse check would
|
|
# hit, the build would be skipped, and the published agent would go on
|
|
# reporting the old format — with nothing anywhere to disagree with it.
|
|
#
|
|
# 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|epoch} {web|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)" ;;
|
|
agent) echo "$AGENT_PATHS $DERIVER" ;;
|
|
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() {
|
|
# Resolve the path set into a variable rather than inlining it as
|
|
# `git log ... -- $(cmd_paths "$1")`. An empty substitution there leaves
|
|
# `git log HEAD --` with NO pathspec, which answers with the newest commit
|
|
# in the whole repository instead of failing — see the dispatch guard at the
|
|
# foot of this file for what that cost and why the real check lives there.
|
|
#
|
|
# This `|| exit 2` cannot be that check (it exits a subshell, since every
|
|
# caller wraps `newest` in one) — it is belt and braces for a future caller
|
|
# that reaches `newest` without passing the guard.
|
|
paths=$(cmd_paths "$1") || exit 2
|
|
|
|
# Unquoted on purpose: the pathspec must word-split into separate args.
|
|
# Globbing is off script-wide (`set -f`).
|
|
# shellcheck disable=SC2086
|
|
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $paths \
|
|
| 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 BUILD CLOCK: the same commit's unix timestamp, for SOURCE_DATE_EPOCH.
|
|
#
|
|
# buildkit stamps the image config's `created` field and every history entry
|
|
# with the wall clock of the build unless this is set, so two builds of
|
|
# identical source produce different config blobs and therefore different
|
|
# manifest digests. That is #3265: the weekly refresh republished all three
|
|
# `:latest` tags on 2026-08-30 with every content step CACHED and the bases
|
|
# resolved to unchanged digests — nothing was different, and the digest moved
|
|
# anyway. A digest that changes on a calendar cannot also mean "the content
|
|
# changed", which is the only thing anyone wants it for.
|
|
#
|
|
# It is the same commit `revision` and `version` name — deliberately, and this
|
|
# is the point of routing it through `newest()` rather than taking git's word
|
|
# separately. Three values derived from three lookups can disagree; three
|
|
# views of one lookup cannot. Note #3127 §2 is the record of what a second
|
|
# clock costs.
|
|
cmd_epoch() {
|
|
echo "$(newest "$1")" | cut -d' ' -f1
|
|
}
|
|
|
|
# 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
|
|
|
|
# Validate the ARTIFACT here, in the main shell, before anything dispatches.
|
|
#
|
|
# It cannot be done deeper down. `cmd_revision`, `cmd_version` and `cmd_epoch`
|
|
# all call `newest` inside a command substitution, and `newest` resolves the
|
|
# path set inside another one — so an `exit` from either kills only that
|
|
# subshell. The script carried on, printed nothing, and exited 0.
|
|
#
|
|
# Before this, `revision ml` was worse than nothing: the empty substitution
|
|
# left `git log HEAD --` with no pathspec, so it answered with the newest
|
|
# commit in the WHOLE REPOSITORY — a real-looking 12-char sha on stdout, exit
|
|
# 0, and the usage line on stderr where no caller reads it. The reuse check
|
|
# would have compared that against a published label, missed, and rebuilt
|
|
# every push forever without a red run anywhere.
|
|
#
|
|
# Latent while every name callers passed was valid. #4311 removed `ml` from
|
|
# the set, which made a name that used to work start taking that path.
|
|
case "$2" in
|
|
web|agent|extension) ;;
|
|
*) usage ;;
|
|
esac
|
|
|
|
case "$1" in
|
|
paths) cmd_paths "$2" ;;
|
|
revision) cmd_revision "$2" ;;
|
|
version) cmd_version "$2" ;;
|
|
epoch) cmd_epoch "$2" ;;
|
|
*) usage ;;
|
|
esac
|