ci(deno.sh): losing git is an error, one digest, and no image left behind #42
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/issue-29-deno-sh-followups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #29 — all three follow-ups, one pass over
.forgejo/deno.shas the issue framed it.1. The soft fallback re-opened the silent-skip hole
deno.shdocuments that without git,docker/backend/commit_test.ts"silently never ran at all", and that test still guards itself withignore: !hasGit. The fallback added in #27 warned on stderr and setCI_IMAGE=$IMAGE, so an unreachable Debian mirror put that suite straight back to skipping with CI green. The external-module suites do fail loudly — but only because they shell out to git unguarded, which is the accident of which suite happens to exist, not a property of the arrangement.Of the issue's two options, this is the second: the fallback is now opt-in.
Everything else fails, with the reason and the escape hatch both named:
deps.ymlsets it on all three steps — none of those commands pass--allow-run, so they could not spawn git if they tried.build.ymldeliberately does not:deno task ciis what runs the suites that need the real thing.tools/ci_pins_test.tspins that wiring: everydeno.shstep indeps.ymlcarries the opt-out — comments stripped first, so it has to be set, not mentioned, and steps split on ANY key that can open a list item (- if:,- env:,- id:, a bare- run:), so a step cannot slip past the check by merging into its predecessor's chunk, wherever it sits in the file. The opt-out's value must also end atoptional:DENO_GIT: optionallyis notoptionalto the wrapper, so it reads as missing rather than set. No other workflow namesDENO_GITanywhere in its text, in any spelling. The workflow list is discovered from.forgejo/workflows/rather than hardcoded, so a new workflow cannot opt out silently (or ship an unpinned action or its own Deno image); it earns the opt-out by joining the per-step checkdeps.ymlis under.The first option (a "git was expected" variable that turns
hasGitskips into failures) would have meant editing the test files, against the issue's own framing that one pass overdeno.shcloses all three.2. Nothing removed superseded derived images
Prune the other
opsdeck-deno-ci:*tags on every run that holds a derived image — cache hit and cold build alike, via oneprune_stale()both paths call. A sweep only after a build always arrived one generation early: the tag a build supersedes was needed until that day, so it is young by the gate below, and no pass came past again until the next recipe change — the runner steady-stated at current + previous. Swept on the cached path too, a tag stops being refreshed two days after its last use and the next run of anything collects it. Best effort — another job may be running one, and losing that race is not this run's problem; running from the cached path also narrows the reverse race, since a candidate is by definition something nothing has self-tagged for two days. The tag just built and any<none>are skipped, and the query is scoped to theopsdeck-deno-cirepository so the basedenoland/denoimage is never a candidate.Only tags this runner has neither built nor tagged for two days are candidates. The gate is
LastTagTime: the build moves it, the post-eviction rebuild moves it, and a cache hit moves it too — the wrapper self-tags (docker tag X X) on the cached path, since nothing else there touches a timestamp — so a recipe in use is young by definition and never evicted.Createdcannot express that: a cache-hit rebuild hands back the cached image with it untouched, so an in-use recipe would read as old as its first build, be evicted anyway, and come back still "old".LastTagTimeis per image, not per tag — two tags on one ID share the timestamp — which over-keeps and never over-prunes. A daemon that cannot answer it (the containerd image store can hand back the zero time) keeps everything and warns once, rather than being a prune that silently never prunes — green with the work quietly not done is the exact shape item 1 exists against.3.
RECIPE_KEYwas not stable across runnersA runner without
sha256sumproduced a different tag for the same recipe, so a mixed fleet built the same image twice and cached neither for the other — andcksumis a CRC32 where everything around it reasons about a hash. One digest now, and a runner without it says so rather than substituting another — unless the caller setDENO_GIT=optional, in which case it needs no derived image at all and degrades the same way a failed build does: warn, run the base image. The hatches compose.Verification
No docker daemon on the machine this was written on, so the script was exercised against a stubbed docker CLI that logs every call and fails on demand; the daemon semantics this arrangement leans on (
Createdfrozen across cache-hit rebuilds, self-tag and cache-hit rebuild movingLastTagTime,{{.Metadata.LastTagTime.Unix}}rendering, the cold path end-to-end) are the reviewer's measurements against real daemons in rounds 2–4.Stub matrix this round, 28 checks: a cache hit self-tags exactly once, prunes a stale sibling, no build; a young sibling is kept; a cold build prunes only the tag older than
PRUNE_AFTER(48 h) and skips<none>and the tag just built; two candidates the daemon cannot age warn exactly once, keep everything, rc=0 — thewarnedflag lives in the prune pipeline's subshell, which is one subshell for the whole loop, so it persists across iterations (measured: two candidates, one warning line); a negative zero-time answer is kept and warned;docker images,rmiandtagfailures never red a run; a failing build without the opt-out is the only rc=1 in the matrix; both hatches unchanged in both directions (build-fail ×DENO_GIT, missingsha256sum×DENO_GIT). The recipe text is untouched by this round, so the tag staystf4224312806e885cby construction and no fleet rebuild rides along.Sixteen mutations against
tools/ci_pins_test.ts, all landing in the demanded direction: steps opening withif:,env:(holding another variable) andid:, an unnamed- run:, a comment-only opt-out, theif:-first step placed directly after Checkout (position no longer matters),DENO_GIT: optionallyand inlineDENO_GIT=optionallyall go red; the quotedDENO_GIT: "optional", the inlineDENO_GIT=optionalrun line, and a newnightly.ymlcallingdeno.shwithout the opt-out stay green; consolidatingdeps.yml's envs to job level stays the documented false red;build.ymlstep env, abuild.ymlcomment namingDENO_GIT, and anightly.ymlopting out all red.Repository gates:
deno task check,deno lint(196 files),deno fmt --check(314 files),tools/(58 passed:ci_pins_test.ts+ dep-check),sh -n .forgejo/deno.sh.packages/server/tests/was not re-run this round — the diff (CI script, test over YAML, docs) touches no server code; round 1's run stands (109 passed, 1 pre-existing interval leak inexternal_modules_test.ts).docs/dependencies.mdcarries the git policy, the tagging, the pruning — now "every run that holds a derived image", with the one-generation-late reasoning and the warn-once degradation — and what the test asserts under thedeno.shsection.I re-ran the verification independently — my own stub
dockeronPATH, a throwawaydocker/Dockerfilecarrying the current pin, and the script exactly as it stands at72641eb— rather than taking the table in the description on trust. Every one of its eight rows reproduces, so the mechanics are right. What I want changed is where two of the three items interact, and one place where the two escape hatches don't compose.What holds up
sha256sum | cut -c1-16and main'ssha256sum 2>/dev/null || cksum→tr -cd '[:alnum:]'→cut -c1-16both producef4224312806e885cfor the current recipe. Dropping thetris safe —cut -c1-16never reaches the-suffix — and it matters that it is safe: every runner that already holds the derived image keeps its cache across this merge. Worth having checked, because a silent tag change here would have been a fleet-wide rebuild disguised as a cleanup.rmi'd, the tag just built skipped,denoland/denonever a candidate.rmireturning non-zero does not stop the run. Cached path does no build and no prune.set -edoes not fire on the prune pipeline. Thewhileexits 0 whether the last body execution wascontinueordocker rmi … || true, so a run whose newest listed tag happens to be$CI_IMAGEdoes not die on the loop's status. That is load-bearing and easy to get wrong; it is right here.case $stale in "$CI_IMAGE" | …—$CI_IMAGEis quoted so the tag is a literal, and thecaseword needs no quoting. Fine.deno task ciis the caller that runs the git-guarded suites, and it is the one that now fails hard. That is the whole point of the change and it is wired correctly.1. The prune and the hard failure compound into cache thrash
Both workflows are
runs-on: cth-ubuntu-latest, and the recipe is a function of the Dockerfile'sFROM. So the moment two branches disagree on the Deno pin — which is precisely what a dep-check bump PR is, and filing those is that tool's entire job — the shared runner alternates:verifybuilds recipe B, prunes Averifymisses A (just pruned), rebuilds A, prunes BSteady state is
apt-get update && apt-get install giton every run and a cache hit never. That alone is a modest cost. What makes it worth changing is that item 1 of this same PR turns that build's failure from a warning intoexit 1— so the prune raises the frequency of exactly the build whose failure now reds CI and blocks the deploy. The two halves of the PR pull against each other, and the description reasons about each in isolation.The issue asked for "prune the other tags with that prefix", so this is faithful to it; the interaction is what the issue did not anticipate. A cheap fix keeps both properties: carry
{{.CreatedAt}}(or{{.CreatedSince}}) in the format string and skip anything young, or keep the N newest. An image superseded an hour ago is not the dead weight the comment is about — one superseded a week ago is.2.
DENO_GIT=optionalstill dies on a missingsha256sumVerified:
DENO_GIT=optional PATH=<no sha256sum> sh .forgejo/deno.sh test …exits 1. The guard sits above the policy and the policy is only consulted on build failure, so a caller that has explicitly declared it needs no git — and therefore no derived image at all — is refused over a hash tool it never uses. The correct degradation for that caller is the one the fallback already implements: warn and run$IMAGE."There is no second digest to use" is the right answer for a caller that needs the derived image. It is not the right answer for one that has said it doesn't. Both hatches exist; they should compose.
3. The verification is not in the tree
Nineteen assertions against a stubbed docker CLI, and none of them are in the diff. The next person to touch this script has the same nothing that made these three follow-ups necessary.
The invariant that will actually rot is not the shell — it is the YAML.
tools/ci_pins_test.tsalready exists to stop CI config from rotting, runs under plaindeno test --allow-read(notedeno task testgrants only--allow-run=git, so a real shell harness would need a task change; a text assertion needs nothing), and this is a fifteen-line third case: every step indeps.ymlwhoseruninvokesdeno.shcarriesDENO_GIT: optional, andbuild.yml's does not. Add a fourth step todeps.ymlwithout the env and nobody finds out until the day the Debian mirror is down and the workflow this PR set out to protect goes red anyway.Smaller
*"<none>"*is close to dead code.docker images opsdeck-deno-cifilters by repository, and an image orphaned by a rebuild loses its repository too — it lists as<none>:<none>and the filter excludes it. The only way.Tagis<none>under that filter is a digest-only reference, which this script never creates. Harmless, and I would keep it, but the table's "<none>untouched" row asserts against stub output rather than against anything docker produces.docker rmiuntags the derived image; theapt-getlayer eachdocker buildwrites stays in the builder cache, and finding 1 makes that cache grow faster. Probably out of scope — butdocs/dependencies.mdnow says "It prunes the superseded tags after a successful build", which the next person reading it for a disk problem will take as "disk is handled".sha256sum, not "asha256sumof the whole recipe"; anddeps.ymlsetsDENO_GITon every step that callsdeno.sh— Checkout doesn't.On the option not taken: rejecting the issue's first choice because "one pass over
deno.shcloses all three" is the weakest paragraph in the description. The issue's framing was about which file the fix lands in, not about which fix is correct, andignore: !hasGitincommit_test.tsis untouched — so the guarantee this PR buys is "CI refuses to run without git", not "the suite cannot silently skip". That is the right guarantee for CI and I am not asking for the other one. But 2 and 3 above are the two places where "the wrapper alone enforces it" leaks, and both are cheap to close.@ -66,0 +67,4 @@# computed it — a different value for the same recipe, so a mixed fleet built# the same image twice and cached neither for the other. (`cksum` is a CRC32# besides, where everything around it reasons about a hash.)if ! command -v sha256sum >/dev/null 2>&1; thenThis guard runs before
GIT_POLICYis read, and the policy is only consulted on build failure — so a caller that has declared it needs no git, and therefore needs no derived image at all, still exits 1 here.Verified:
DENO_GIT=optional PATH=<no sha256sum> sh .forgejo/deno.sh test x→ rc=1."There is no second digest to use" is correct for a caller that needs the image. For one that has said it doesn't, the right degradation is the one thirty lines down: warn and run
$IMAGE. ReadingGIT_POLICYabove this block and branching on it costs two lines.@ -74,0 +96,4 @@# one per recipe or toolchain change — each a full Deno image. Best# effort: another job may be running one of these, and losing that race# is not this run's problem.docker images --format '{{.Repository}}:{{.Tag}}' opsdeck-deno-ci \This prunes every other recipe on the runner, and both workflows share
cth-ubuntu-latest. As soon as two branches disagree on the Dockerfile pin — a dep-check bump PR against main is exactly that — the two recipes evict each other in a loop: PR builds B and prunes A, main rebuilds A and prunes B, next PR push rebuilds B. Cache hit never.The cost is one
apt-get install gitper run, which on its own would be a shrug. It matters because item 1 of this PR makes that build's failure fatal, so the prune raises the rate of exactly the build that now reds CI and blocks the deploy.--format '{{.Repository}}:{{.Tag}} {{.CreatedAt}}'and skipping anything young keeps the stated goal (a long-lived runner does not hoard a full Deno image per recipe change) without evicting a sibling branch's image mid-flight.@ -74,0 +99,4 @@docker images --format '{{.Repository}}:{{.Tag}}' opsdeck-deno-ci \2>/dev/null | while read -r stale; docase $stale in"$CI_IMAGE" | *"<none>"*) continue ;;docker images opsdeck-deno-cifilters by repository, and an image orphaned by a rebuild loses its repository along with its tag — it lists as<none>:<none>and never reaches this loop. The only way.Tagis<none>under a repository filter is a digest-only reference, which nothing here creates.Keep it, it costs nothing. But the description's "
<none>untouched" row is asserting against the stub's output rather than against anything the real CLI emits under this exact invocation — worth knowing when the table is read as coverage.@ -50,1 +55,4 @@# unreachable Debian mirror should not take this workflow down with it.- name: Testenv:DENO_GIT: optionalThis is the part that will rot. A fourth step added here without
DENO_GIT: optionalbehaves identically until the day the Debian mirror is unreachable, and then takes down the workflow this PR set out to keep up — the failure mode is invisible right up to the point where it matters.tools/ci_pins_test.tsis already the place where "CI config must not drift" lives, and it runs on plain--allow-read, so this is a text assertion and not a harness: every step indeps.ymlwhoseruncontainsdeno.shcarries the env, andbuild.yml's does not. That second half is worth asserting too — it is the whole point of the change and nothing currently stops someone from "fixing" a red main by pasting the env there.All three requested changes are in
f492244, plus the smaller doc corrections.1. Prune is age-gated. The format string now carries
{{.CreatedSince}}, and only tags whose age is in days/weeks/months/years go — docker humanizes in hours until 48, so the gate is effectively "at least two days old". A sibling branch's recipe on the shared runner is hours old at the point it matters and survives; the week-old leftovers the comment was about still go. The comment in the script now names the interaction you described (PR/main alternating eviction, made worse by the now-fatal rebuild) rather than reasoning about the prune in isolation.2. The hatches compose.
GIT_POLICYis read before the sha256sum guard. A caller that declaredDENO_GIT=optionalneeds no derived image at all, so a missingsha256sumdegrades for it exactly the way a failed build already did: warn and run$IMAGE. A caller that needs git still exits 1 with the original message. The build/prune block is additionally skipped whenever a hatch has already fallen back (CI_IMAGE=$IMAGE), so the missing-tool path can neverdocker buildthe recipe under the base image's name.3. The YAML invariant is in the tree.
tools/ci_pins_test.tsgains a third test: everydeps.ymlstep whoserun:invokesdeno.shcarriesDENO_GIT: optional, and nobuild.ymlstep setsDENO_GITat all — the second half so a red main cannot be "fixed" by pasting the opt-out into the workflow whose hard failure is the point. It reads from therun:key onward because the surrounding comments name the script too, and it matchesDENO_GIT:as a YAML line so the comment in deps.yml (which saysDENO_GIT=optional) is not a false positive. Mutation-tested both ways: removing one env entry from deps.yml and adding one to build.yml each turn the test red, naming the step.Smaller:
<none>guard kept as-is, per your note. Agreed it is stub-shaped rather than CLI-shaped under a repository filter; it stays as a cheap guard, and I have stopped presenting that row as coverage.docs/dependencies.mdnow says the prune is age-gated and untags only — the layers eachdocker buildwrote stay in the builder cache, so it "keeps the image list short, not the disk clean". Nobody chasing a disk problem should read "prunes" as "disk is handled".sha256sum(not the whole digest), anddeps.ymlsets the env on every step that calls the wrapper.Re-verification. The stub harness is now 30 assertions: cached / build-ok / build-fail × policy / rmi-fail / no-
sha256sum× policy. New rows: a young ("2 hours ago") tag survives the prune while day- and week-aged tags go; the no-sha256sum+optionalrun warns, never callsdocker buildordocker image inspect, and creates the container from the base image. The missing-sha256sumbranches ran against aPATHgenuinely lacking it. Tag unchanged atf4224312806e885c, so no fleet rebuild rides along. Gates:deno fmt --check,deno lint,deno task check,sh -n,tools/dep-check(55 passed),packages/server/tests(109 passed — the one failure is an interval leak inexternal_modules_test.tsthat reproduces on a clean HEAD, unrelated to this diff).Re-ran everything from scratch at
f492244— my own stubdockeronPATHrather than the PR's harness, plus a real daemon (docker 29.7.1, BuildKit) for the two questions a stub cannot answer. All three items do what the description says, and the mechanics reproduce. Two claims do not survive: the age gate does not protect a recipe that is in use, and thebuild.ymlhalf of the new test does not hold.What reproduces
sha256sum | cut -c1-16and main'ssha256sum || cksum→tr→cutboth givetf4224312806e885cfor the current recipe, recomputed here. No fleet-wide rebuild rides along.sha256sumonPATH+DENO_GIT=optional: warns, and the whole docker call log iscreate -w /w denoland/deno:2.9.5@sha256:b429777… deno test …— noimage inspect, nobuild, exactly as claimed. Same PATH without the opt-out: rc=1 with the "no second digest" message. Build-fail × policy behaves the same in both directions.Less than a second ago,About a minute ago,44 minutes ago,About an hour ago,2 hours ago,47 hours ago,2 days,13 days,3 weeks,5 months,2 years,<none>: exactly the day/week/month/year rows arermi'd; the tag just built and<none>are skipped.31 hours agoappears in my own daemon's image list, so the 48-hour boundary is not theoretical.set -estill does not fire on the prune. rc=0 and the container is created for: an all-young list (every iteration ends incontinue), a list holding only$CI_IMAGE, an empty list,docker imagesexiting non-zero, andrmifailing on every tag.deps.ymlstep, env added to abuild.ymlstep — and a third: a fourthdeps.ymlstep that calls the wrapper without it.deps.yml's opt-out is safe for a stronger reason than the comment gives. None of the three commands pass--allow-run, so they cannot spawn git at all. That is a firmer guarantee than "nothing there touches git", and it is the sentence I would put in the comment.deno fmt --check(314 files),deno lint(196),deno task check,tools/dep-check(55 passed),sh -n.1. The gate protects the recipe first built recently, not the one in use
Createddoes not reset when an untagged image is rebuilt from cache. On a real daemon:Same image ID,
Createduntouched — the rebuild is a cache hit and docker hands back the cached image, timestamp and all. (Metadata.LastTagTimedoes move on that rebuild:22:29:16→22:29:20.)So a recipe a branch has been using every day for a week reads as a week old, and the next prune to come past evicts it — the exact case the gate was added for, since a sibling branch's pin is precisely a recipe that is old and in use. And it does not self-correct: after the eviction the rebuild comes back still stamped a week old, so it is prunable again immediately. As long as the builder cache holds the layers that rebuild is seconds and no network — but the builder cache is the one thing nothing here prunes, as the docs now say, and once a runner reclaims it that rebuild is the real
apt-getone whose failure this same PR made fatal.{{.CreatedSince}}cannot express "last used".docker image inspect -f '{{.Metadata.LastTagTime}}' <tag>can: it means "when this runner last built or tagged this recipe", it moves on a cache-hit rebuild, and it converges — an evicted sibling that gets rebuilt is young again and stops being a target. One inspect per candidate, and the candidate list is short. Worth one caveat line: under the containerd image store that field can come back empty, so treat empty as "keep" rather than "prune".If you would rather keep
CreatedSince, thendocs/dependencies.md's "a sibling branch on a different pin holds a recipe that is in use, not dead" is the sentence to correct — as written the gate does not deliver it.2.
build.ymlcan still opt out — three ways, one of them the documented spellingMutation-tested the second half. Caught:
env: / DENO_GIT: optionalon the step. Not caught, test stays green:env:underverify:env:at the top ofbuild.ymlrun: DENO_GIT=optional ./.forgejo/deno.sh task ciAll three opt out for real at runtime. The third is the form
deno.sh's own comment documents (DENO_GIT=optional sh .forgejo/deno.sh test …), so the guard misses the spelling a reader of the script is likeliest to copy — and "reviving a red main" is exactly the moment someone copies from the error message and the script.docs/dependencies.mdstates "asserts both halves — … and no step inbuild.ymldoes", which is true of steps and not of the file.The fix is smaller than what is there: for
build.ymlthe invariant is not per-step at all, so assert on the whole text —assertEquals(Deno.readTextFileSync(BUILD).includes("DENO_GIT"), false, …). No step splitting, no env-shape assumption, all three closed, andbuild.ymlnamesDENO_GITnowhere today.Smaller
deps.ymlhalf is shape-sensitive in both directions. All of these are false reds, which is the safe direction, but they are surprising:DENO_GIT: "optional"(same YAML, quoted) fails; the inlinerun: DENO_GIT=optional sh .forgejo/deno.sh …form fails; and a step that does not call the wrapper goes red when a comment namingdeno.shsits between itsrun:and the next step — reading fromrun:onward only handles comments above it, and the comment block indeps.ymltoday lands in the Checkout step's chunk, which passes only because Checkout has norun:at all./DENO_GIT[:=]\s*"?optional/per step covers the first two.GIT_POLICY=${DENO_GIT:-required}reads every unrecognized value asrequired—DENO_GIT=yes,true,Optionalall mean "git required". Safe direction, keep it; the test pinning the one working spelling indeps.ymlis what makes it fine.external_modules_test.ts" line: in my environment two of those cases fail on gpg signing timeouts from my own git config, so my run says nothing either way. The diff touches no server code, so nothing in that suite can be this PR's doing.Close 2, and fix or re-word 1, and this is done.
@ -74,0 +114,4 @@# evicts the PR's, cache hit never — and a failed rebuild is fatal for# every caller that needs git. `CreatedSince` is humanized and counts in# hours until 48, so the larger units are the age gate.docker images --format '{{.Repository}}:{{.Tag}} {{.CreatedSince}}' \CreatedSinceis the wrong clock for "in use". Measured on a real daemon:docker rmithe tag, rebuild the same recipe from cache, and you get the same image ID with the originalCreated—1 second agobefore,4 seconds agoafter a 3-second sleep, not reset.So a sibling branch's recipe that has been in daily use for a week reads as a week old and gets evicted here, which is the case this gate was added for. And it does not recover: the rebuild comes back still stamped a week old, so it is prunable again on the next pass. Cheap while the builder cache holds the layers — that cache is the one thing nothing prunes — and a real
apt-getbuild once it doesn't, which this PR made fatal.docker image inspect -f '{{.Metadata.LastTagTime}}' <tag>is the signal that behaves: it moved (22:29:16→22:29:20) across exactly that untag/rebuild, so an evicted sibling becomes young again and stops being a target. Empty under the containerd image store, so treat empty as keep.@ -54,0 +72,4 @@// files and talks to registries, and nothing there touches git.const missing = steps(DEPS).filter((step) =>runsDenoSh(step) && !/^\s*DENO_GIT:\s*optional$/m.test(step)Shape-sensitive in ways that are all false reds — safe direction, worth knowing before someone hits one:
DENO_GIT: "optional"— same YAML, quoted — fails.run: DENO_GIT=optional sh .forgejo/deno.sh …form fails, though it works at runtime.deno.shsits between itsrun:and the next step. Reading fromrun:onward only handles comments above the key; today's comment block lands in the Checkout chunk and passes only because Checkout has norun:at all./DENO_GIT[:=]\s*"?optional/covers the first two.@ -54,0 +85,4 @@// git-guarded suites, and failing hard without git is the point — pasting// the env here to revive a red main defeats the arrangement.const optedOut = steps(BUILD).filter((step) => /^\s*DENO_GIT:/m.test(step))This half does not hold. Mutation-tested — all three of these leave the test green and all three opt out for real:
env:underverify:env:at the top of the filerun: DENO_GIT=optional ./.forgejo/deno.sh task ciThe last one is the spelling
deno.sh's own comment documents, which is what someone reviving a red main would copy.For
build.ymlthe invariant is not per-step, so drop the step splitting here entirely:Shorter than what is here, and it closes all three.
build.ymlnamesDENO_GITnowhere today.Both blocking items are in
d372259, plus the smaller ones.1. The gate is
LastTagTimenowPer candidate:
docker image inspect -f '{{.Metadata.LastTagTime.Unix}}', prune only past 48 h. Your rig demonstration is the whole argument —Createdsurvives a cache-hit rebuild, so the in-use sibling recipe read as old forever and the gate never delivered the sentence the docs claimed — so I took the fix, not the reword.LastTagTimemoves on that rebuild, so an evicted recipe that comes back is young again and converges out of the target set.The caveat is honored in both shapes it can arrive in: the zero time prints as a negative number through
.Unix, and an empty answer is empty — the guard iscase $last in '' | *[!0-9]*) continue, so anything that is not a plain digit string is kept. A failedinspect(candidate untagged mid-loop) is|| continue, also kept. Theset -eproperty you checked still holds: every body path ends incontinueor|| true, and the loop exits 0 for an all-young list, an empty list,docker imagesfailing,inspectfailing on every candidate, andrmifailing on every candidate — all re-asserted in the harness.docs/dependencies.mdnow describes the gate the prune actually has: "neither built nor tagged for two days", with theCreated-vs-LastTagTimereason attached.2.
build.ymlcannot opt out in any spellingThe per-step check is gone; the assertion is on the file:
Your three green mutations — job-level
env:, workflow-levelenv:, inlineDENO_GIT=optionalon the run line — are all red now, alongside the step-level one. The deps.yml half takes your regex, widened one character for single quotes:/DENO_GIT[:=]\s*["']?optional/per step that runsdeno.sh, so the quoted value and the inline run form no longer false-red. The comment-between-steps false red stays as is — safe direction, and the step chunking that causes it is what keeps the check readable.Smaller
--allow-run, so they could not spawn git if they tried.GIT_POLICYreading every unrecognized value asrequiredstays, per your note — the test pinning the working spelling is what makes it fine.Verification
Same stub-docker harness shape as before, rebuilt for the new gate: 44 assertions across cached / build-success against a mixed candidate list (stale pruned, young kept,
$CI_IMAGEand<none>never inspected, empty and zero-time and inspect-failure kept) / build-fail × both policies /rmifail /docker imagesfail / no-sha256sum× both policies. The 8 YAML mutations above each fliptools/ci_pins_test.tsin the demanded direction. Gates:deno fmt --check(314),deno lint(196),deno task check,tools/(58 passed),sh -n.What the stub still cannot answer: whether
{{.Metadata.LastTagTime.Unix}}renders on the runner's actual docker version — the template method call is standard Go text/template over atime.Time, but the first real evaluation happens in CI here.Third pass at
d372259. I did not reuse your harness or mine: this round is a real daemon (docker 29.7.2), the actual.forgejo/deno.shfrom this commit driving realdocker build/docker images/docker image inspect/docker rmi, and 14 mutations againsttools/ci_pins_test.tsrather than your 8. The two items you took are the right fixes and the mechanics reproduce. Item 1 is closer but still does not deliver the sentence the docs claim, and thedeps.ymlhalf of the new test can be satisfied by prose.What reproduces, including the three things nobody had run for real
docker cpanddocker create: the derived image builds, the command runs, andgit --versioninside it answersgit version 2.47.3. No longer a CI-first.{{.Metadata.LastTagTime.Unix}}renders. Your open question, answered:1787323657on 29.7.2. And a bad template field exits 1, so the|| continuegenuinely keeps rather than silently reading an empty string.CreatedvsLastTagTimeis exactly as you argued. Build,rmi, sleep 3, rebuild from layer cache: same image ID,Created=…16:47:35.985850282unchanged,LastTag16:47:37→16:47:40. Your rig demonstration was right and the reword would have been the wrong call.docker rmiran against a real daemon and the sibling tag went;$CI_IMAGEand<none>were never inspected. With the real 172800, a mixed young list is fully kept.set -estill does not fire on the loop. rc=0 and a container created for: all-young,$CI_IMAGE-only, empty list,docker imagesfailing,inspectfailing,rmifailing.*[!0-9]*is also doing double duty as the guard that keeps a non-numeric answer out of$(( )), which is worth the comment it does not have.env -iwith aPATHgenuinely lackingsha256sum: bare exits 1 with the "no second digest" text; withDENO_GIT=optionalit warns and runsdenoland/deno:2.5.6with noimage inspectand nobuild. Composition holds.docker/Dockerfileat this commit:f4224312806e885cfromsha256sum | cut -c1-16andf4224312806e885cfrom main'ssha256sum || cksum→tr→cut. No fleet-wide rebuild rides along.docker build -qstill surfaces why. I had assumed-qwould swallow the mirror failure and leave the operator only your message. It does not — BuildKit prints the offendingRUNandexit code: 100on stderr. The new hard failure is diagnosable.deno task ci→deno task testtakes no path filter, so it walkstools/and picks upci_pins_test.ts. Confirmed by running the task's exact flag set —1 passed | 452 filtered out.deps.yml's opt-out is safe for the firmer reason. All three commands are--allow-read/--allow-net/--allow-envonly; none passes--allow-run. Good that this is the sentence in the comment now.deno fmt --check(314),deno lint(196),sh -n,tools/ci_pins_test.ts(3 passed).1.
LastTagTimeis never refreshed on the path an in-use recipe takesLastTagTimemeans "last built or tagged".deno.sh's hot path does neither:docker image inspectsucceeds and the script goes straight todocker create. Measured on the real daemon — cached run, three seconds apart:So a recipe used on every push for a month still reads a month old, and the first build of any new recipe evicts it. That is the same failure mode you correctly rejected
Createdfor; the gate only protects a recipe built in the last 48 hours, and in the scenario the gate exists for — a sibling branch on a pin that landed weeks ago — the recipe has been used continuously and built once, long ago. The gate does not fire for it.This is not a no-op change, and I want to be fair about what it bought:
LastTagTimedoes move on the post-eviction rebuild, so the ping-pong converges instead of repeating on every alternation. One forced rebuild per 48-hour window per pin pair, not one per run. That is strictly better thanCreated. But it is not whatdocs/dependencies.mdnow says:and the forced rebuild is still the
apt-getone whose failure item 1 of this PR made fatal on the workflow that deploys.Closing it is one line, because a cache hit is the signal — this recipe is in use — and
docker tag X Xis what records it. Verified on the daemon:1787323660→1787323663.With that, "neither built nor tagged for two days" and "in use, not dead" become the same statement, and the docs sentence is true as written.
One incidental thing a future reader of the harness will hit:
LastTagTimeis per image, not per tag. Two tags resolving to one image ID share one timestamp, so tagging either refreshes the gate for both. I ran into it directly — a forced-threshold prune kept a candidate I expected to go, because it and the just-built tag were both0a95e0f2d863. It over-keeps, so it is harmless, and in production distinct pins mean distinct IDs. It belongs in a comment rather than in someone's afternoon.2. The
deps.ymlhalf of the new test is satisfied by a commentYour eight mutations reproduce: env removed from a
deps.ymlstep (red), and step-, job-, workflow-level and inlineDENO_GITinbuild.yml(all red). The file-text assertion forbuild.ymlis the right call and closes all three spellings I found last round.The
deps.ymlhalf does not hold. Four mutations that remove the opt-out for real and leave the test green:- name: Testwith# DENO_GIT=optional is not needed hereand noenv:— passes.deps.yml's own comment block, relocated. Move the existing "DENO_GIT=optionalon every step below" paragraph from above- name: Testto inside it and drop theenv:— passes. It reads today only because that block lands in the Checkout chunk, and Checkout has norun:.run:.# note: DENO_GIT=optional was dropped here— passes.- run: sh .forgejo/deno.sh run --allow-read tools/other.ts, noname:, no env — invisible.steps()filters on- name:, so the whole step never reaches the check, and it merges into the previous chunk.The first three are the cost of the
[:=]widening I asked for last round. That is on me for proposing the regex without saying what it gave up — but the trade is real and unremarked: your round-1 design note was explicitly "it matchesDENO_GIT:as a YAML line so the comment in deps.yml … is not a false positive", and that property is gone. The invariant is what the step sets, so strip comments before asking.Both are one hunk. I applied and mutation-tested it here;
deno fmt --check,deno lintand the suite pass, and all 14 mutations land in the demanded direction — the four above flip red, the baseline and the quotedDENO_GIT: "optional"stay green:with the label falling back to the run line for a nameless step:
Stripping comments also retires the false red you kept as "safe direction" — a comment naming
deno.shbetween a step'srun:and the next step no longer reaches the check at all, so the chunking stops being load-bearing.Smaller
BUILD/DEPSare hardcoded, so.forgejo/workflows/nightly.ymlcallingdeno.shwithDENO_GIT: optionalis green — unpinned actions and its own Deno image too, sinceWORKFLOWSis the same list. That predates this PR, but the invariant this PR adds is "no caller opts out silently", and a new workflow file is the likeliest place it rots. ADeno.readDirSync(".forgejo/workflows")in place of the literal list covers all three tests at once.env:indeps.ymlis a false red. Consolidating the three step envs into one job-level entry is runtime-correct and turns the test red. Safe direction, and I would not chase it — but it is the natural cleanup someone will try, so it deserves a word in the test's comment.build.ymlcannot document its own abstention.includes("DENO_GIT")bans the token, so# deliberately no DENO_GIT here: this caller needs gitfails the test. That is the assertion working as designed, and it is also the one comment a reader most wants at that step. Worth noting in the failure message that the ban is on the literal string, so whoever hits it does not spend ten minutes looking for anenv:block.172800is bare. "Two days" lives only in prose two comment paragraphs up.PRUNE_AFTER=172800 # 48hreads at the comparison.docker image inspectand itsdocker create, this run can untag that image;id=$(docker create …)is unguarded, soset -ereds that run. It needs two different recipes concurrently on one runner, which is a bump PR racing main, so it is rare and it was rare before the age gate too. Mentioning it so it is a known shape rather than a mystery red.What I still cannot answer: the containerd image store's zero-time
LastTagTime(my daemon uses the classic store — the guard is right by construction, andinspectfailing and a bogus field both exit 1 and keep, which I did test), and a genuinely 48-hour-old image, since I forced the threshold to 0 instead of waiting.Close 2, close or reword 1 — and with the one-line
docker tagthe reword is not needed — and this is done.Round 4 at
e18994d— both items taken as you specified, plus the smaller ones.1. The self-tag, exactly as proposed. The cached path now branches on a successful
docker image inspectand runsdocker tag "$CI_IMAGE" "$CI_IMAGE" || truewith your comment's reasoning attached. "Neither built nor tagged for two days" and "in use, not dead" are now the same statement, so the docs sentence stands unreworded. The per-image-not-per-tag footgun is written down next to the gate. Honesty note: no daemon on this machine, so theLastTagTimemovement itself rests on your measurements (1787323660 → 1787323663); what I verified against the stub is placement — a cache hit tags exactly once, builds nothing, prunes nothing; a failingtagdoes not red the run; the cold path never self-tags.2. Your hunk, applied whole. Comments stripped before asking, steps split on
- (name|run|uses):, filter matching the split, label falling back to the run line, and thebuild.ymlhalf generalized: the file-text ban now applies to every workflow exceptdeps.yml, withWORKFLOWSdiscovered viaDeno.readDirSync(".forgejo/workflows")— your "third workflow" smaller item, folded into the same loop sonightly.ymlwithDENO_GIT: optionalis red until it joins the per-step check deliberately. That is a step beyond what you asked (you asked for readDir on the list; the ban-outside-deps.yml rule is my generalization of "no caller opts out silently") — flagging it in case you want the rule narrower.Your four green-mutation escapes all flip red now: comment-only opt-out, the relocated
deps.ymlcomment block, a trailing comment afterrun:, and the unnamed step. Ten mutations total this round, all in the demanded direction — including quoted"optional"and a straydeno.shcomment staying green (the chunking is no longer load-bearing), and job-level env consolidation as the false red the test comment now documents as deliberate.Smaller, all taken:
PRUNE_AFTER=172800 # 48hat the comparison; the*[!0-9]*guard's second job ($(( ))protection) commented; the inspect-vs-prune race named in the prune preamble as a known shape;build.yml's failure message says the ban is on the literal string, comments included, so nobody hunts for anenv:block.Gates:
deno fmt --check(314),deno lint(196),deno task check,tools/58 passed (ci_pins_test.ts3 + dep-check 55),sh -n. Server suite: 109 passed, 1 failed — the known interval leak inexternal_modules_test.ts, which this diff does not touch. Recipe tag stilltf4224312806e885c. PR description updated to match.What I could not check here, same as before: anything needing a real daemon — the containerd zero-time branch and a genuine 48-hour-old image remain open from your list too.
Fourth pass at
e18994d. Real daemon (docker 29.7.2), the actual script from this commit driving realbuild/images/image inspect/rmi/tag, plus a stubdockerfor the failure paths and 18 mutations againsttools/ci_pins_test.tsrather than your 10. Both items you took are the right fixes and every mechanic in the description reproduces, including thedocker tag X Xmeasurement. Two things do not hold: the prune only runs on the one path where its own gate guarantees there is nothing to collect, and thedeps.ymlhalf of the test still has three silent opt-outs — one of them a keydeps.ymlsteps already carry.What reproduces
docker cp/create:opsdeck-deno-ci:tf4224312806e885cbuilds and the 55tools/dep-checktests run inside it, rc=0, 21 s wall.docker/Dockerfileat this commit:tf4224312806e885cfromsha256sum | cut -c1-16andtf4224312806e885cfrom main'ssha256sum || cksum→tr→cut.cksumalone givest1622011007241, so the mixed-fleet claim is real, not theoretical.LastTagTime, and it is the only thing on that path that does. Direct:1787336766→1787336769acrossdocker tag X X,Createduntouched. Through the script: a cache-hit run moved1787336818→1787336840in 0.8 s with no build. The stub call log for that path is exactlyimage inspect/tag/create/cp/start/wait/rm.{{.Metadata.LastTagTime.Unix}}renders and a bad field exits 1, so|| continuegenuinely keeps rather than reading an empty string.rmi'd, the tag just built kept,denoland/denonever a candidate, rc=0. Two sibling tags sharing one image ID both went — the per-image caveat over-keeps only when a candidate shares an ID with something young.set -estill does not fire. rc=0 and a container created for: all-young list,$CI_IMAGE-only, empty list,docker imagesfailing,inspectfailing,rmifailing,tagfailing, non-numericLastTagTime. The only rc=1 in the matrix is the intended one.env -iwith aPATHgenuinely lackingsha256sum: bare exits 1 with the "no second digest" text; withDENO_GIT=optionalit warns and runs the base image with noimage inspectand nobuild.nightly.ymlgoes red for the opt-out, for an unpinneduses:, and for its owncontainer: image: denoland/deno— three separate mutations. One callingdeno.shwithout the opt-out stays green, which is the correct default.build.ymlhalf holds in every spelling I could find. Stepenv:, job-levelenv:, workflow-levelenv:, inline on the run line, and a comment merely namingDENO_GIT— all red.deno task test's exact flag set overtools/: 58 passed,ci_pins_test.tsincluded. Gates re-run here:deno fmt --check(314),deno lint(196),sh -n,tools/ci_pins_test.ts(3 passed).set -eis load-bearing in this file and it is fine: thefor name in ${DENO_ENV:-}loop ends in[ -n "$value" ] && env_args=…, so the loop's status is 1 whenever the last name is unset. Neithershnor busyboxashexits on it — the AND-OR exemption carries through the loop — so theCheck dependencies and file issuesstep is safe. Pre-existing and untouched; no action.1. The prune runs only on the path where the gate guarantees nothing to collect
The loop is inside the build
elif(.forgejo/deno.sh:110), so it runs only when a recipe is cold-built. Measured: threshold forced to 0, one sibling tag aged three seconds, cache-hit run — the sibling survives. Only a cold build sweeps.Now put that next to the 48-hour gate. At the moment a cold build happens, the recipe it just superseded is by definition young: something used it within the last 48 hours, which is why the bump is landing today. So:
The one pass that could collect A always arrives too early, and the passes that would find it old never happen. In the ping-pong case the gate correctly keeps both live recipes — that half works — but the issue's actual complaint ("nothing removed superseded derived images") is still answered one generation late, and the runner steady-states at current + previous, which on a toolchain bump is a whole extra Deno image rather than the extra apt layer.
Closing it is a hoist, and it makes the age gate the whole policy: a recipe stops being self-tagged 48 h after its last use, and then the next run of anything at all collects it. It is also strictly safer on the race named at
:113— a candidate is by definition something nothing has tagged for two days, and any concurrent user would have self-tagged it on the way in.Applied and measured here on the real daemon with the same forced threshold: the aged sibling is pruned on the cache-hit path, rc=0,
sh -nclean, the run itself unaffected.docs/dependencies.mdthen reads "on every run" rather than "after a successful build".2. Three step shapes still opt out silently —
if:among themsteps()splits on- (?:name|run|uses):, so a step whose first key is anything else never opens a chunk: it merges into the previous step's, and if that one is opted out, the new one inherits the pass. Mutations againstdeps.yml, all three green:- if: github.event_name == 'pull_request'+run: sh .forgejo/deno.sh run …, no env.if:is a key twodeps.ymlsteps already carry — just never first.- env:first, holding some other variable, noDENO_GIT.- id:first.All three genuinely require git at runtime. It is also still position-dependent: the same step placed after Checkout (no
run:in that chunk) goes red — so the chunking is load-bearing again, which the round-3 note said stripping comments had retired.Both the code comment at
:68("Split on any list item that can open a step") and the description ("steps split on any opening key so an unnamed- run:step is seen") describe the fix that isn't there:name|run|usesis three keys, not any. Two lines:on: schedule: - cron:starts a chunk under that pattern and is harmlessly dropped byrunsDenoSh. Verified with the patch in place: the three above flip red, the baseline stays green, and the other 15 mutations land exactly as they do today — quotedDENO_GIT: "optional"green, inlineDENO_GIT=optional sh …green, unnamed- run:red, comment-only red, job-levelenv:red (the documented false red), all fourbuild.ymlspellings red, all threenightly.ymlshapes red.deno fmt --checkanddeno lintclean.3.
DENO_GIT: optionallypasses the test and requires git at runtime/DENO_GIT[:=]\s*["']?optional/has no right-hand boundary, so any value that merely starts withoptionalsatisfies it.GIT_POLICY=optionallyis notoptional, so that step needs git — the silent skip this test exists to prevent, spelled with a typo, green.optional(?![\w-])closes it and keeps both the quoted and the inline forms green; verified in the same matrix.Smaller
inspectthat cannot answer.Metadata.LastTagTime— the containerd image store's zero time, as the comment says, or any other reason — keeps every candidate, rc=0, nothing on stderr. Verified. Item 1 of this PR exists because "green with the work silently not done" is the bug; oneecho "warning: no LastTagTime from this daemon; not pruning" >&2on the first skip would keep item 2 from being the same shape. Not a blocker.docs/dependencies.md:115— "everydeno.shstep indeps.ymlcarries the env" is the sentence finding 2 falsifies. Nothing to reword once the split is fixed.PRUNE_AFTER=172800 # 48hat the comparison: taken, and it reads.DENO_GITanywhere in its text, in any spelling" is true of the repo's files, and that is the whole guarantee: the wrapper reads the process environment, so a runner exportingDENO_GIT=optionalglobally opts every caller out with all three tests green. Nothing a text assertion can reach — worth knowing where the edge is rather than fixing.commit_test.ts'signore: !hasGitis untouched, so what this buys is "CI refuses to run without git" and not "the suite cannot silently skip" — which is the right guarantee, and findings 1–3 are the places where the wrapper-and-test arrangement does not yet deliver it.Fix 1 and 2 — both are single hunks I have applied and measured here — and 3 rides along on the same regex. Then this is done.
Round-5 rework at
751c701, all of round 4 taken — both blockers, the regex rider, and the warn-once Also-item.1. The prune runs from both paths now. Your hoist, almost verbatim: the loop is
prune_stale(), called right after the self-tag on the cache-hit path and after a successful cold build. The comment block moved up with it and now carries the one-generation-early reasoning — at the moment of a cold build, the tag it supersedes is young by the gate, so a build-only sweep always kept it and none came past again until the next bump. It also notes what the hoist buys the race at the old:113: a candidate is by definition something nothing self-tagged for two days, so any concurrent user would have refreshed it on the way in.docs/dependencies.mdnow says "every run that holds a derived image" instead of "after a successful build".2.
steps()splits on any opening key. Your two lines:.split(/^(?=\s*- [\w-]+:)/m)+ the matching filter. Your three green mutations flip red —- if:first (including placed directly after Checkout, so the chunking is no longer load-bearing),- env:first holding another variable,- id:first. Theon: schedule: - cron:item opens a chunk and is dropped byrunsDenoSh, as you predicted; noted in the comment.3.
optional(?![\w-]).DENO_GIT: optionallyand inlineDENO_GIT=optionallyboth red; quoted and inlineoptionalboth still green.Also: the un-ageable-candidate case warns once —
warning: no LastTagTime from this daemon; not pruningon the first skip, then silence. The inspect failure and the non-numeric answer land in the same warned-and-kept branch (|| last=replaced|| continue), so a daemon that never answers is no longer a prune that silently never prunes.Verification (stub CLI here; your rounds 2–4 own the real-daemon semantics): 28-check stub matrix — cache hit self-tags once + prunes a stale sibling with no build, young sibling kept, cold build prunes and skips
<none>and the tag just built, two un-ageable candidates produce exactly one warning line (thewarnedflag lives in the loop's single subshell, so it persists across iterations), negative zero-time kept,images/rmi/tagfailures never red a run, both hatches unchanged in both directions. 16 mutations against the test, all in the demanded direction — your four new reds plus my round-4 set, with the quoted form, the inline opt-out, anightly.ymlcalling the wrapper without the opt-out, and the baseline staying green, and job-level consolidation staying the documented false red. Gates:deno task check,deno lint(196),deno fmt --check(314),tools/58 passed,sh -n. The recipe text is untouched, so the tag istf4224312806e885cby construction.Description updated to match. Over to you.
Fifth pass at
751c701. Same method as round 4 and not your harness: real daemon (docker 29.7.2) driving the actual script from this commit, my own stub for the failure paths, and 29 mutations againsttools/ci_pins_test.tsrather than your 16. All three findings are closed, the warn-once you added on top does what it says, and I could not find a new hole in either half. Approving.The three, re-measured
prune_stale()at.forgejo/deno.sh:133, called from both branches. Real daemon, threshold forced to 0, sibling tag aged three seconds, cache-hit run: the sibling isrmi'd, rc=0, the run itself unaffected — the exact case that survived ate18994d. With the real 172800 a sibling tagged seconds ago is kept and the run costs 1.4 s instead of 0.8 s, which is the whole price of the change. Cold path re-run end to end afterwards: untagged, rebuilt from layer cache in 2.6 s, pruned the other sibling, kept its own tag, 55tools/dep-checktests pass inside the derived image.- if:(both after the opted-out step and first in the job, so position no longer matters),- env:holding another variable,- id:,- shell:,- continue-on-error:,- working-directory:, plus the unnamed- run:. The- cron:chunk your comment calls out does start a chunk and is dropped byrunsDenoSh, as claimed. A nested- key:list item inside a step ahead of itsrun:splits the env away from the run line and reads as missing — a false red, the safe direction, and contrived enough not to be worth a word.DENO_GIT: optionally,DENO_GIT: optional-ishand inlineDENO_GIT=optionallyare red;DENO_GIT: "optional",'optional',optional # trailing comment,env: { DENO_GIT: optional }and the inlineDENO_GIT=optionalrun line stay green. Runtime and test agree on every one of those.Full matrix this round, 29 mutations, all in the demanded direction: the thirteen above, comment-only opt-out and env-removed red, job-level
env:still the documented false red,build.ymlstep / job / workflow / inline / comment-mention all red,nightly.ymlred for the opt-out, for an unpinneduses:and for its owncontainer: image:, and green when it calls the wrapper without the opt-out. Baseline green.The warn-once
Verified rather than assumed, because a flag set inside a
whilefed by a pipe is exactly the shape that silently doesn't persist: two candidates the daemon cannot age produce one warning line, rc=0, nothing pruned. Mixed list — one unreadable, one 48 h+, one young — warns once,rmis only the old one, keeps the young one. The containerd zero time (-6795364578871) and an empty answer both land in the same branch and warn.docker imagesfailing,rmifailing,tagfailing, an empty list, an all-young list: rc=0 every time, container still created. The only rc=1 in the matrix is the intended build failure without the opt-out, andDENO_GIT=optionalstill turns it into a warning on the base image. Cold path takes no self-tag, hot path takes exactly one.Gates here:
sh -n,deno fmt --check(314),deno lint(196),tools/ci_pins_test.ts(3 passed), and the derived-image run oftools/dep-check(55 passed). Tag is stilltf4224312806e885c, so nothing rebuilds fleet-wide on merge.Two nits, neither blocking
last=$(docker image inspect …) || last=funnels every non-zero inspect into the "no LastTagTime from this daemon" branch, andNo such imageexits 1 the same way a missing template field does (checked). The concurrent-prune race the comment names at:104produces precisely that: the other run untags between this run'sdocker imagesand itsinspect, and the log then accuses the daemon. Wording, not behaviour — "could not read LastTagTime for$stale; not pruning it" covers both causes and stays true.inspectand itsrmi, against a tag nothing has self-tagged for two days — the loser is a retriable red, and the alternative was a sweep that never collected anything. Worth knowing it went from rare-and-after-builds to rare-and-always; not worth changing.The cost of the hoist is real and correctly bounded: a recipe whose branch runs less often than every 48 hours now loses its image to somebody else's run and pays a rebuild — 2.6 s while the builder cache holds the layers, the real
apt-getonce a runner reclaims it. That is the age gate working as designed, anddocs/dependencies.mdsays so in the sentence about what "in use" means.Good change. Merge it.