#!/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|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)" ;; ml) echo "$ML_PATHS" ;; agent) echo "$AGENT_PATHS" ;; extension) ext_paths ;; *) usage ;; esac } # " " 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 @`, 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