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
233 lines
10 KiB
Python
233 lines
10 KiB
Python
"""`scripts/artifacts.sh` path sets must match what the Dockerfiles copy.
|
|
|
|
Each published artifact's version derives from the newest commit touching its
|
|
own shipped file set (milestone 313). The whole scheme rests on those sets
|
|
being right, and both ways of being wrong are silent:
|
|
|
|
* **too narrow** — a file ships but is not in the set, so the version does not
|
|
move when the content does, and a pin serves stale bytes. This is the
|
|
dangerous direction and the one this module exists for.
|
|
* **too wide** — a file is in the set but never reaches the image, so the
|
|
artifact re-versions and rebuilds for a change it does not ship.
|
|
|
|
Nothing else notices either. The version still derives, CI still goes green,
|
|
and the mismatch only surfaces as "I pinned that build and got the wrong
|
|
bytes". So the Dockerfiles are read here and compared against the declaration.
|
|
|
|
The COPY list is not the whole answer, though. A file that DECIDES what an
|
|
artifact reports belongs in its set even though it is copied into nothing —
|
|
see DERIVERS below, where the same finding is recorded twice (#3156, #3202).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# artifact -> (dockerfile, build context relative to the repo root)
|
|
ARTIFACTS = {
|
|
"web": ("Dockerfile", ""),
|
|
"agent": ("agent/Dockerfile", "agent"),
|
|
}
|
|
|
|
# COPY --from=<stage> copies from an earlier build stage, not from the build
|
|
# context, so its source is not a repo path and cannot be in a path set.
|
|
_COPY = re.compile(r"^\s*COPY\s+(?!--from=)(?P<args>.+)$", re.MULTILINE)
|
|
|
|
|
|
def declared_paths(artifact: str) -> list[str]:
|
|
out = subprocess.run(
|
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), "paths", artifact],
|
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
|
).stdout
|
|
return out.split()
|
|
|
|
|
|
def includes(artifact: str) -> list[str]:
|
|
"""The set minus its `:(exclude)…` entries."""
|
|
return [p for p in declared_paths(artifact) if not p.startswith(":(exclude)")]
|
|
|
|
|
|
def copy_sources(dockerfile: str, context: str) -> list[str]:
|
|
"""Repo-relative sources of every context COPY in a Dockerfile."""
|
|
text = (ROOT / dockerfile).read_text()
|
|
sources: list[str] = []
|
|
for m in _COPY.finditer(text):
|
|
args = m.group("args").split()
|
|
# Last arg is the destination; everything before it is a source.
|
|
for src in args[:-1]:
|
|
# `frontend/package-lock.json*` — the glob is an optional-file
|
|
# idiom; the directory it sits in is what matters for coverage.
|
|
src = src.rstrip("*")
|
|
sources.append(f"{context}/{src}" if context else src)
|
|
return sources
|
|
|
|
|
|
def covered_by(path: str, include: str) -> bool:
|
|
"""`path` ships if an include names it or one of its ancestors."""
|
|
path = path.rstrip("/").lstrip("./")
|
|
include = include.rstrip("/")
|
|
return path == include or path.startswith(include + "/")
|
|
|
|
|
|
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
|
|
def test_every_copied_path_is_in_the_artifacts_path_set(artifact):
|
|
"""The too-narrow direction — the one that serves stale bytes on a pin."""
|
|
dockerfile, context = ARTIFACTS[artifact]
|
|
inc = includes(artifact)
|
|
for src in copy_sources(dockerfile, context):
|
|
assert any(covered_by(src, i) for i in inc), (
|
|
f"{dockerfile} copies {src!r} into the {artifact} image, but no "
|
|
f"include in scripts/artifacts.sh covers it. The {artifact} "
|
|
f"version will not move when that file changes, so a pinned build "
|
|
f"will serve stale bytes. Add it to the path set.\n"
|
|
f" declared includes: {inc}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
|
|
def test_the_dockerfile_itself_is_in_the_path_set(artifact):
|
|
"""Changing a base image or a RUN changes the artifact as surely as
|
|
changing a source file, so each set must include its own Dockerfile."""
|
|
dockerfile, _ = ARTIFACTS[artifact]
|
|
assert any(covered_by(dockerfile, i) for i in includes(artifact)), (
|
|
f"{dockerfile} is not in the {artifact} path set — a base-image bump "
|
|
f"would not move the version."
|
|
)
|
|
|
|
|
|
def test_the_web_image_versions_on_an_extension_change():
|
|
"""The web image bundles the signed XPI, so the extension's packaged files
|
|
are part of what it ships. Miss this and `:latest` serves a NEW extension
|
|
under an unchanged web version — a pin that quietly disagrees with itself.
|
|
"""
|
|
inc = includes("web")
|
|
assert any(covered_by("extension/background/background.js", i) for i in inc), (
|
|
"the web path set does not cover the extension's packaged files, but "
|
|
"build.yml downloads the signed XPI into frontend/public/extension/ "
|
|
"before the docker build"
|
|
)
|
|
|
|
|
|
# A file that DECIDES an artifact's identity is part of what that artifact is
|
|
# built from, even though it is copied into no image. Both entries here are the
|
|
# same finding twice — #3156 for packaging.sh, #3202 for artifacts.sh — and
|
|
# both were latent for the same reason: the version has no backstop.
|
|
#
|
|
# The revision does. Change how a REVISION is computed and the derived value
|
|
# stops matching the label on the published image, which forces a rebuild; the
|
|
# mechanism self-corrects because it compares against a string stamped into a
|
|
# real artifact. Nothing compares a version to anything, so a version-only
|
|
# derivation change is invisible unless the deriver is in the set.
|
|
DERIVERS = [
|
|
# packaging.sh decides the version build.yml stamps into the packaged
|
|
# manifest.json, so changing it changes the shipped bytes. Left out,
|
|
# milestone 313 step 4 turns silent: the new version misses the
|
|
# ext-<version> cache and gets signed, while web's revision has not moved,
|
|
# so the reuse path republishes the old image and the fresh signature is
|
|
# orphaned. Guarded for web too, since web bundles what the extension makes.
|
|
("extension/scripts/packaging.sh", ("extension", "web")),
|
|
# artifacts.sh decides the FC_VERSION baked into the web image (#3202)
|
|
# and, since 2026-09-24, the agent image too.
|
|
#
|
|
# It was web-only before that, and correctly so: the agent asked this
|
|
# script for `revision` alone, which the self-correcting path above
|
|
# covers. Giving the agent a self-reported version is what moved it into
|
|
# this list, and the two had to happen in the SAME change — a version with
|
|
# no deriver entry is the exposure this list exists for, not a smaller
|
|
# version of it. The extension still takes its version from packaging.sh.
|
|
#
|
|
# 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 was harmless only because FC_VERSION did not exist
|
|
# until one commit later.
|
|
("scripts/artifacts.sh", ("web", "agent")),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("path, artifacts", DERIVERS, ids=lambda v: str(v))
|
|
def test_a_version_deriver_is_in_the_set_of_what_it_decides(path, artifacts):
|
|
for artifact in artifacts:
|
|
inc = includes(artifact)
|
|
excluded = [
|
|
p[len(":(exclude)"):] for p in declared_paths(artifact)
|
|
if p.startswith(":(exclude)")
|
|
]
|
|
assert any(covered_by(path, i) for i in inc), (
|
|
f"{path} decides the version {artifact} reports, but is not in the "
|
|
f"{artifact} path set. A change to the derivation would leave the "
|
|
f"revision untouched, the build skipped, and the published image "
|
|
f"reporting the old version — with nothing to disagree with it."
|
|
)
|
|
assert not any(
|
|
covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded
|
|
), (
|
|
f"{path} is excluded from the {artifact} path set, so a change to "
|
|
f"how the version is derived would not move the version — and "
|
|
f"step 4 would reuse the image that carries the old one"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"artifact, path",
|
|
[
|
|
# Deliberate exclusions — the too-wide direction. Each of these lives
|
|
# beside shipped code but never reaches an image, and including it
|
|
# would re-version the artifact for a change it does not carry.
|
|
#
|
|
# "Never reaches an image" is the test, not "is not source": DERIVERS
|
|
# above are also copied into nothing and DO belong in their sets,
|
|
# because they decide what the image reports. The line between the two
|
|
# lists is whether the file has a say in the artifact's identity.
|
|
("agent", "agent/README.md"),
|
|
("agent", "agent/ruff.toml"),
|
|
("agent", "agent/docker-compose.yml"),
|
|
# vite builds from src/, index.html and public/; it never reads test/,
|
|
# so a frontend test change cannot reach `dist`.
|
|
("web", "frontend/test/gallery.spec.js"),
|
|
],
|
|
)
|
|
def test_files_that_never_reach_an_image_do_not_version_it(artifact, path):
|
|
paths = declared_paths(artifact)
|
|
excluded = [p[len(":(exclude)"):] for p in paths if p.startswith(":(exclude)")]
|
|
inc = [p for p in paths if not p.startswith(":(exclude)")]
|
|
|
|
included = any(covered_by(path, i) for i in inc)
|
|
exempted = any(covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded)
|
|
assert not included or exempted, (
|
|
f"{path} is in the {artifact} path set but is not copied into the "
|
|
f"image — it would re-version and rebuild {artifact} for a change it "
|
|
f"does not ship."
|
|
)
|
|
|
|
|
|
def test_an_unknown_artifact_fails_instead_of_answering():
|
|
"""It used to answer — with the newest commit in the whole repository.
|
|
|
|
`newest()` inlined the path set as `git log ... -- $(cmd_paths "$1")`.
|
|
`usage` exits from the command SUBSHELL, so an unknown name made the
|
|
substitution come back empty and `git log HEAD --` walked everything:
|
|
stdout got a real-looking 12-char sha, the exit code was 0, and the usage
|
|
line went to stderr where no caller reads it. A wrong answer shaped
|
|
exactly like a right one, which the reuse check would have compared
|
|
against a published label and quietly rebuilt on forever.
|
|
|
|
Latent while every name callers passed was valid. #4311 removed `ml` from
|
|
that set, which is what made a name that used to work start taking the
|
|
silent path.
|
|
"""
|
|
out = subprocess.run(
|
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), "revision", "ml"],
|
|
capture_output=True, text=True, cwd=ROOT,
|
|
)
|
|
assert out.returncode != 0, (
|
|
f"an unknown artifact exited 0 and printed {out.stdout!r}"
|
|
)
|
|
assert not out.stdout.strip(), (
|
|
f"an unknown artifact printed {out.stdout!r} on stdout"
|
|
)
|