Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
extension / lint (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
Before building, each job asks the registry whether this artifact's content is already published. On a hit it skips the build entirely and repoints the channel and date tags at the existing manifest with `imagetools create` — registry-side, no layer transfer, seconds. This is the step that stops a push touching only `agent/` from rebuilding web and ml, and stops a merge to main rebuilding what dev already built. The question is asked with a new `artifacts.sh identity`, not with the date tag: the date tag is day-precise and last-one-wins, so two different builds share it and it cannot answer "is this content published?". The commit sha would move on every push and never hit, which is the redundant rebuild being removed. The revision does both jobs — content-unique, and stable across pushes that did not touch the artifact. Identity is channel-qualified for web and only for web, because web is the only image that takes a build-arg: FC_CHANNEL is baked in and reported by /api/extension/manifest, so its dev and main builds of one revision are genuinely different images. ml and agent take none, which is what lets a merge reuse dev's build rather than rebuilding the agent's CUDA image to produce bytes that already exist. tests/test_artifact_identity.py reads the Dockerfiles and fails if that list drifts from the ARG declarations, in either direction — collapsing the channels ships an instance that reports the wrong one, and splitting them needlessly rebuilds every merge. Failure direction is deliberate: an inspect that errors for any reason reads as a miss and the build runs. Only a real 200 skips one. A tag-push never claims the identity. It rebuilds a revision main already published, and image configs are not bit-reproducible, so re-pushing r-<rev> would point an immutable tag at fresh bytes — rule 145's exact prohibition. It publishes only its own :v... label and otherwise reuses. Base-image freshness, decided rather than left implicit: an artifact whose source stops moving stops picking up base updates under its pinned tag. That is what a pin means, and rule 145 already says the refresh belongs on the moving tag instead. Filed as #3154 rather than folded in here, because the naive version regresses :latest on the next unrelated push. ci.yml's backend lane gains fetch-depth: 0 — the new tests derive real revisions, and on a depth-1 clone that derivation returns the tip sha or fails, so the lane would go green while asserting nothing. The three build jobs' shadow steps are renamed and re-commented: those values stopped being informational at step 3, and a step captioned "nothing reads this" beside steps that do is worse than no caption.
141 lines
5.8 KiB
Python
141 lines
5.8 KiB
Python
"""`artifacts.sh identity` is what decides whether a build gets skipped.
|
|
|
|
Milestone 313 step 4: build.yml asks the registry for `<image>:<identity>` and,
|
|
on a hit, publishes NO new bytes — it repoints the channel and date tags at the
|
|
manifest already there. So the identity has to be a true name for the content.
|
|
Both ways of getting it wrong are silent at build time and only surface in
|
|
production:
|
|
|
|
* **too coarse** — two genuinely different images share an identity, so the
|
|
second one never gets built and its tags point at the first one's bytes. The
|
|
live case is FC_CHANNEL: a `dev` and a `main` build of one revision differ,
|
|
and collapsing them ships an instance that reports the wrong channel forever.
|
|
* **too fine** — the identity moves when the content did not, nothing ever
|
|
hits, and step 4 buys nothing. A commit sha would do exactly this.
|
|
|
|
The Dockerfiles are read here rather than trusted, because the coarse direction
|
|
appears the moment someone adds a build-arg without touching `CHANNELLED`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# Only image artifacts have an identity — the extension is cached as an
|
|
# ext-<version> Forgejo release, not a registry tag.
|
|
IMAGE_ARTIFACTS = {
|
|
"web": "Dockerfile",
|
|
"ml": "Dockerfile.ml",
|
|
"agent": "agent/Dockerfile",
|
|
}
|
|
|
|
CHANNELS = ("main", "dev")
|
|
|
|
# docker's own tag grammar: [A-Za-z0-9_][A-Za-z0-9._-]{0,127}
|
|
_TAG = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
|
|
|
|
# `ARG FC_CHANNEL` in a Dockerfile means build.yml passes a per-channel value
|
|
# in, so the channel is part of what the image IS.
|
|
_ARG_CHANNEL = re.compile(r"^\s*ARG\s+FC_CHANNEL\b", re.MULTILINE)
|
|
|
|
|
|
def identity(artifact: str, channel: str | None = None) -> subprocess.CompletedProcess:
|
|
cmd = ["sh", str(ROOT / "scripts" / "artifacts.sh"), "identity", artifact]
|
|
if channel is not None:
|
|
cmd.append(channel)
|
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT)
|
|
|
|
|
|
def ok(artifact: str, channel: str | None = None) -> str:
|
|
proc = identity(artifact, channel)
|
|
assert proc.returncode == 0, f"identity {artifact} {channel}: {proc.stderr}"
|
|
return proc.stdout.strip()
|
|
|
|
|
|
def bakes_the_channel(artifact: str) -> bool:
|
|
return bool(_ARG_CHANNEL.search((ROOT / IMAGE_ARTIFACTS[artifact]).read_text()))
|
|
|
|
|
|
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
def test_channel_dependence_matches_the_dockerfile(artifact):
|
|
"""The coarse direction, caught at its source.
|
|
|
|
Whether the channel belongs in the identity is not a preference — it is
|
|
dictated by whether the Dockerfile takes it as a build-arg. Adding an
|
|
`ARG FC_CHANNEL` to another image without adding it to `CHANNELLED` would
|
|
make its dev and main builds collide, and nothing else would notice.
|
|
"""
|
|
per_channel = {c: ok(artifact, c) for c in CHANNELS}
|
|
differs = len(set(per_channel.values())) > 1
|
|
|
|
if bakes_the_channel(artifact):
|
|
assert differs, (
|
|
f"{IMAGE_ARTIFACTS[artifact]} declares ARG FC_CHANNEL, so a dev "
|
|
f"build and a main build of one revision are different images — "
|
|
f"but both derive the identity {per_channel['main']!r}. The main "
|
|
f"build would reuse the dev image and report the wrong channel. "
|
|
f"Add {artifact!r} to CHANNELLED in scripts/artifacts.sh."
|
|
)
|
|
else:
|
|
assert not differs, (
|
|
f"{IMAGE_ARTIFACTS[artifact]} takes no channel build-arg, so one "
|
|
f"revision is one image and a merge to main should reuse what dev "
|
|
f"already built — but the identity differs per channel "
|
|
f"({per_channel}), so every merge rebuilds it for nothing. Remove "
|
|
f"{artifact!r} from CHANNELLED in scripts/artifacts.sh."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
def test_identity_tracks_the_artifacts_own_revision(artifact):
|
|
"""The fine direction: the identity must be the revision, not the push.
|
|
|
|
`revision` is the commit this artifact's shipped files last changed in, so
|
|
it holds still across pushes that did not touch it. Anything derived from
|
|
HEAD instead would move every push and never hit the registry.
|
|
"""
|
|
rev = subprocess.run(
|
|
["sh", str(ROOT / "scripts" / "artifacts.sh"), "revision", artifact],
|
|
capture_output=True, text=True, check=True, cwd=ROOT,
|
|
).stdout.strip()
|
|
value = ok(artifact, "main")
|
|
assert rev and rev in value, (
|
|
f"identity {value!r} does not contain the {artifact} revision {rev!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("artifact", sorted(IMAGE_ARTIFACTS))
|
|
def test_identity_is_a_legal_docker_tag(artifact):
|
|
"""It is pushed as a tag, so an illegal one fails at the registry — after
|
|
the build has already run."""
|
|
for channel in CHANNELS:
|
|
value = ok(artifact, channel)
|
|
assert _TAG.match(value), f"{value!r} is not a valid docker tag"
|
|
|
|
|
|
def test_a_channelled_artifact_refuses_an_unqualified_identity():
|
|
"""Refusing beats defaulting. If `identity web` quietly returned the
|
|
unqualified `r-<rev>`, a workflow that forgot to pass the channel would
|
|
publish one image under a name both channels then reuse — the exact
|
|
collision the CHANNELLED list exists to prevent, reintroduced by an
|
|
omission rather than by an edit."""
|
|
proc = identity("web")
|
|
assert proc.returncode != 0, (
|
|
"identity web returned a value with no channel: "
|
|
f"{proc.stdout.strip()!r}"
|
|
)
|
|
|
|
|
|
def test_the_extension_has_no_image_identity():
|
|
"""It is cached as an ext-<version> release asset, and its cache key is the
|
|
version. Answering with a plausible image tag would invite a second,
|
|
divergent cache."""
|
|
proc = identity("extension", "main")
|
|
assert proc.returncode != 0
|
|
assert "ext-" in proc.stderr
|