ci: pin the build clock to the commit, so an unchanged refresh publishes nothing
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 56s
CI / integration (push) Successful in 1m50s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 56s
CI / integration (push) Successful in 1m50s
Milestone 362 step 1, closing #3265's root cause. The weekly base refresh rewrote all three `:latest` tags on 2026-08-30 with nothing changed in any of them. Not a cache miss — run 4934's log shows every content step CACHED and both bases resolved to unchanged pinned digests. buildkit stamps the image config with the wall clock of the build, so identical layers get republished under a new config blob and therefore a new manifest digest. The cost is not storage, it is meaning: `:latest` moved on a calendar, so a digest change stopped being evidence that anything was different. That is the one thing a digest is any use for, and it is load-bearing here — the reuse check, the `:c-<sha>` rollback story and any future redeploy signal all rest on it. SOURCE_DATE_EPOCH normalises `created` and the history timestamps, so the same source produces the same config bytes and the same digest, and pushing it is a registry no-op. The value is routed through artifacts.sh's existing `newest()` rather than taken from git separately. `revision`, `version` and now `epoch` are three fields of ONE lookup, so they cannot drift into naming different commits — a divergence that would stamp an image reproducibly against one commit while it reported being another, with both values looking perfectly well-formed. Note #3127 §2 is the record of what a second clock costs; this adds a view, not a clock. Also corrected: the build step comment and ci-requirements.md both described the churn as current behaviour with the fix as a "likely" future. They now describe what the file does. Tests pin the property the fix depends on, not the fix: epoch is the same commit version names, in both renderings including the extension's unpadded one, and it does not move between two calls on one checkout. A future refactor that gave epoch its own `git log` would pass every other test in that file. Not yet verified end to end — proving it needs two consecutive refreshes to land on the same digest, which is the next thing, and is the step #3265 exists because nobody did last time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
This commit is contained in:
@@ -234,3 +234,56 @@ def test_version_and_revision_describe_the_same_commit(artifact):
|
||||
f"in AMO_UNPADDED may differ here."
|
||||
)
|
||||
assert sha.startswith(revision(artifact))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artifact", ARTIFACTS)
|
||||
def test_epoch_is_the_same_commit_the_version_names(artifact):
|
||||
"""The build clock and the version must be one lookup, not two.
|
||||
|
||||
`epoch` feeds SOURCE_DATE_EPOCH, which decides the image config's bytes and
|
||||
therefore the manifest digest; `version` is what the instance reports about
|
||||
itself. If they could name different commits, an image would be stamped
|
||||
reproducibly against one commit while claiming to be another — and both
|
||||
values would look perfectly well-formed, exactly like the divergence the
|
||||
test above guards.
|
||||
|
||||
They cannot, because `cmd_epoch` and `cmd_version` are two fields of one
|
||||
`newest()` result. This pins that they stay that way: a future refactor
|
||||
that gave epoch its own `git log` would pass every other test here.
|
||||
"""
|
||||
epoch = artifacts("epoch", artifact).strip()
|
||||
assert epoch.isdigit(), f"{artifact} epoch is {epoch!r}, not a unix timestamp"
|
||||
|
||||
sha = newest_by_commit_time(artifact)
|
||||
committed = subprocess.run(
|
||||
["git", "show", "-s", "--format=%ct", sha],
|
||||
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||
).stdout.strip()
|
||||
assert epoch == committed, (
|
||||
f"{artifact} derives epoch {epoch}, but its newest shipped commit "
|
||||
f"{sha[:12]} was committed at {committed}. SOURCE_DATE_EPOCH would "
|
||||
f"pin the image config to a commit the version does not name."
|
||||
)
|
||||
|
||||
# And the two renderings must agree, which is the property that actually
|
||||
# matters at build time: same commit in, same digest and same reported
|
||||
# version out.
|
||||
rendered = subprocess.run(
|
||||
["git", "show", "-s", "--format=%cd", "--date=format-local:%Y.%m.%d.%H%M", sha],
|
||||
capture_output=True, text=True, check=True, cwd=ROOT,
|
||||
env={"TZ": "UTC", "PATH": os.environ.get("PATH", "")},
|
||||
).stdout.strip()
|
||||
assert segments(artifacts("version", artifact).strip()) == segments(rendered)
|
||||
|
||||
|
||||
def test_epoch_is_stable_across_calls():
|
||||
"""SOURCE_DATE_EPOCH's entire job is to be the same on the next build.
|
||||
|
||||
A value that moved between two invocations on one unchanged checkout would
|
||||
reintroduce #3265 through the very mechanism meant to close it, and the
|
||||
symptom would be indistinguishable: a digest that changes for no reason.
|
||||
"""
|
||||
for artifact in ARTIFACTS:
|
||||
first = artifacts("epoch", artifact).strip()
|
||||
second = artifacts("epoch", artifact).strip()
|
||||
assert first == second, f"{artifact} epoch moved: {first} then {second}"
|
||||
|
||||
Reference in New Issue
Block a user