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.
200 lines
9.1 KiB
Python
200 lines
9.1 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", ""),
|
|
"ml": ("Dockerfile.ml", ""),
|
|
"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).
|
|
# Web only, and deliberately: ml and agent ask this script for `revision`
|
|
# alone, so they are covered by the self-correcting path above, and the
|
|
# extension 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",)),
|
|
]
|
|
|
|
|
|
@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."
|
|
)
|