CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-ml (push) Successful in 1m52s
Build images / build-web (push) Successful in 1m58s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m24s
Run 7271. `test_every_copied_path_is_in_the_artifacts_path_set` — a guard I did not know this repo had — caught the merge's real consequence within minutes, and it is precisely #3202's class: ml: Dockerfile copies 'frontend/package.json' into the ml image, but no include in scripts/artifacts.sh covers it. web: Dockerfile copies 'requirements-ml.txt' into the web image, but no include covers it. Both follow from step 6 and neither was visible by reading my own diff. The merged Dockerfile carries the frontend-builder stage, so the ml image now copies frontend files; and the web image now installs the ML requirements. Left as they were, each artifact's derived version would have stopped moving when those files changed, and a pinned build would serve stale bytes — silently, since nothing fails when a version simply does not advance. The fix is structural rather than two more entries. `fabledcurator-ml` IS web's image now: same Dockerfile, same context, same bytes, published under a second name only because the operator's Swarm stack still references it. So ML_PATHS is WEB_PATHS by assignment, and `cmd_paths` gives ml the same deriver and extension append — the XPI is in those bytes too. Two lists describing one image is the duplication this milestone has been collapsing all day. It existed for about an hour and the guard found it first, which is the argument for the guard. I also predicted this failure would be the image build. It was not; the build passed and the unit lane failed. Worth noting because the prediction was confident and wrong, and reading the log took one call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
236 lines
12 KiB
Bash
Executable File
236 lines
12 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 requirements-ml.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
|
|
|
|
# ml — THE SAME IMAGE as web since milestone 422 step 6, built from the same
|
|
# Dockerfile with the same context and published under a second name only
|
|
# because the operator's Swarm stack still references it.
|
|
#
|
|
# So it is the same path set, by assignment rather than by a copy that would
|
|
# drift. This was two lists describing one image for about an hour, and the
|
|
# artifact-paths guard caught it immediately: the merged Dockerfile carries
|
|
# the frontend-builder stage, so the ml image copies `frontend/package.json`,
|
|
# and the old ml list did not cover it — its version would not have moved when
|
|
# the frontend changed, and a pinned build would serve stale bytes (#3202).
|
|
#
|
|
# `requirements-ml.txt` is in WEB_PATHS for the mirror-image reason: the web
|
|
# image now installs the ML requirements, so changing them changes it.
|
|
ML_PATHS="$WEB_PATHS"
|
|
|
|
# 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|epoch} {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)" ;;
|
|
# Identical to web, deliberately: it IS web's image. That includes the
|
|
# deriver and the bundled extension — the XPI is in these bytes too.
|
|
ml) echo "$ML_PATHS $DERIVER $(ext_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 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
|
|
case "$1" in
|
|
paths) cmd_paths "$2" ;;
|
|
revision) cmd_revision "$2" ;;
|
|
version) cmd_version "$2" ;;
|
|
epoch) cmd_epoch "$2" ;;
|
|
*) usage ;;
|
|
esac
|