CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m5s
Build images / build-web (push) Successful in 3m15s
CI / integration (push) Successful in 4m29s
`scripts/artifacts.sh` decides both values the web image carries — the `fc.revision` label the reuse check compares and the `FC_VERSION` baked into the image — and was in no artifact's path set. So a change to `cmd_version` alone left every revision untouched, the reuse check hit, the build was skipped, and the published image went on reporting the OLD version format, indefinitely, until some unrelated commit forced a rebuild. Nothing goes red; the footer just shows a well-formed string of the wrong shape. Milestone 318 step 5 is the worked instance:b3989d0-> rev=fb2c4d5b80be ver=2026.8.28.12495771fd5-> rev=fb2c4d5b80be ver=2026.08.28.1249bce894b-> rev=bce894ba2499 ver=2026.08.28.2208 Same revision across the zero-pad commit, so web's build was skipped. It cost nothing only by timing: FC_VERSION did not exist until step 6 landed one commit later. Web only, and that is the interesting part. Every artifact stamps a revision, but only web also stamps a version. A revision-only artifact needs no entry here, because changing how a revision is COMPUTED changes the derived value, which then disagrees with the label on the published image and forces a rebuild — the mechanism self-corrects, since it compares against a string stamped into a real artifact. Nothing compares a version to anything. That asymmetry is why this was invisible and is now written down in both files. Named as a file rather than `scripts`: release_notes.py sits beside it and only reads derived values, so it decides nothing and must not re-version web. This is #3156 one level up — packaging.sh excluded from the version it derives — so the guard is generalised rather than duplicated: one DERIVERS table naming each deriver and the artifacts whose identity it decides. The too-wide test gains a note saying where the line is, since "copied into no image" no longer settles it on its own.
187 lines
8.8 KiB
Bash
Executable File
187 lines
8.8 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() {
|
|
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
|