LearnNewsExamplesServices
Frontmatter
id16635
titleA branch as NEO_REF freezes the image at its first build
stateClosed
labels
bugaibuildagent-os
assigneesneo-opus-ada
createdAtAug 7, 2026, 4:05 PM
updatedAtAug 8, 2026, 3:35 PM
githubUrlhttps://github.com/neomjs/neo/issues/16635
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 8, 2026, 3:35 PM

A branch as NEO_REF freezes the image at its first build

Closed Backlog/active-chunk-13 bugaibuildagent-os
neo-opus-grace
neo-opus-grace commented on Aug 7, 2026, 4:05 PM

Context

ai/deploy/Dockerfile:12 ships ARG NEO_REF=dev as the default, and documents the alternative at :8 as:

NEO_REF : git ref to clone (default 'dev'; pin to a tag/SHA for full reproducibility)

That framing says a SHA buys reproducibility. It also buys freshness, and without it there is none — a branch ref makes the source layer cache-stable, so the image silently packages whatever that branch pointed at the first time the layer was built, forever.

Surfaced by an operator who had already worked this out empirically on a live deployment: "dev is a bit risky, since it will not auto-update on future updates then. this was the reason that we so far used explicit SHA values." Filed because the file still recommends the opposite reason, so the next reader learns it the same expensive way.

The Problem

The source stage is one RUN (ai/deploy/Dockerfile:20-23):

ARG NEO_REF
RUN git init -q && git remote add origin "${NEO_REPO_URL}" \
    && git fetch --depth 1 origin "${NEO_REF}" \
    && git checkout -q --detach FETCH_HEAD \
    && git rev-parse HEAD > /neo/.neo-revision

A RUN layer's cache key is its command string after ARG substitution. With NEO_REF=dev that string is byte-identical on every build, so the layer is reused and the fetch never executes again. With a SHA the arg changes whenever the code changes, which busts the cache by construction.

So the two options are not "pinned versus tracking". They are pinned deliberately versus pinned accidentally, at an arbitrary past commit, with a build log that reads as though it fetched.

Demonstrated

Minimal reproduction of the cache-key property, the RUN standing in for the fetch:

FROM alpine:3.20
ARG NEO_REF=dev
RUN echo "fetch ${NEO_REF}" && head -c6 /dev/urandom | od -An -tx1 | tr -d ' \n' > /what-we-got
build 1, NEO_REF=dev    -> e5267c5a4fab
build 2, NEO_REF=dev    -> e5267c5a4fab   <- identical: the layer never re-ran
build 3, NEO_REF=<sha1> -> 35240ad7c0a1
build 4, NEO_REF=<sha2> -> 57efc8e99f2b   <- changed arg busts the cache

Docker 29.2.1. Two dev builds produce the same bytes from a command that cannot produce the same bytes twice if it runs.

The Architectural Reality

  • ai/deploy/Dockerfile:8 — the comment that makes the branch default look safe, by naming reproducibility as the only thing a SHA adds.
  • ai/deploy/Dockerfile:12ARG NEO_REF=dev. A mutable channel as the default value for a cache-keyed arg.
  • ai/deploy/Dockerfile:20-23 — the single cache-keyed RUN.
  • ai/deploy/Dockerfile:~112-140NEO_REVISION is the integrity assertion and is empty by default, so the one mechanism that would catch a stale package is off in exactly the configuration that produces one. /app/.neo-revision still records the truth, but nothing compares it to an expectation unless a caller supplies one.

The provenance work in #15774 / #15775 made the packaged revision observable. This is the adjacent gap: observable is not asserted, and the default configuration asserts nothing.

The Fix

Direction, not prescription — the cheapest correct option depends on how much the deploy pipeline should own.

Option A — fix the comment only. State that a mutable ref freezes the layer and that a SHA is required for a build to fetch at all. One line, zero behaviour change, and it stops the misreading. Weakest, because the default still does the wrong thing.

Option B — make the mutable-ref case fail closed. Refuse a non-SHA NEO_REF unless an explicit opt-in arg is passed (NEO_ALLOW_MUTABLE_REF=1). Matches the fail-closed posture the plane and blobless-clone guards already use: the guard fires on the shape that is silently wrong, and the escape hatch is explicit.

Option C — resolve the channel at build entry. Have the compose/pipeline layer resolve dev to a full SHA before invoking the build, so the mutable name never reaches a cache-keyed arg. Strictly better provenance, and it makes NEO_REVISION populated by default — but it moves work into every caller.

B and C compose: C for the pipeline, B as the backstop for a hand-run docker build.

Acceptance Criteria

  • ai/deploy/Dockerfile:8 no longer describes SHA-pinning as being about reproducibility alone; it states that a mutable ref freezes the source layer.
  • A build invoked twice against a moved branch either re-fetches or fails — it must not silently package the older commit.
  • Coverage that fails on today's shape: two builds with an unchanged mutable ref, asserting the resolved revision is not silently reused. A test that only checks a SHA build passes today and proves nothing.
  • Whatever is chosen, NEO_REVISION's default-empty behaviour is revisited in the same change — an integrity assertion that is off by default cannot catch the case this ticket is about.

Out of Scope

  • #15774 / #15775 — provenance emission. This consumes their output rather than changing it.
  • #15792 — the reference pipeline building unpinned. Adjacent and probably fixed by Option C, but that ticket is about the pipeline's own pinning, not about the cache-key property of a mutable ref.
  • Remediating any specific deployment. That is operator-side.

Avoided Traps

  • Reading the comment as the contract. The file says a SHA is for reproducibility, which is true and incomplete, and the incompleteness is invisible because the sentence is correct as far as it goes. I repeated the same wrong framing into a deployment guide before this was pointed out.
  • Believing the build log. A cached layer prints its command. Nothing distinguishes "fetched and got the same commit" from "did not fetch" without comparing /app/.neo-revision across builds.
  • Assuming --no-cache covers it. It does, and it is a per-invocation flag in a different document from the one that recommends the branch default. A claim in one step being true only because of a flag in another step is how this stayed plausible.
  • Testing with a SHA. Any test that pins a SHA cache-busts by construction and cannot reproduce the defect. The failing case requires an unchanged mutable ref across two builds.

Related

#15774 (deployed images cannot prove their revision) · #15775 (forward NEO_REF, emit OCI revision label) · #15792 (reference pipeline builds unpinned) · #15782 (abbreviated-SHA fetch failure in the guide) · #16206 (ai/deploy composable layering).

Live latest-open sweep: checked the latest 20 open issues at 2026-08-07T14:03:32Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages: no competing lane-claim on this scope.

Origin Session ID: 9ced67a1-8f21-4da2-a1bf-a2a968c47ed2

Retrieval Hint: query_raw_memories("NEO_REF branch default docker layer cache freeze source stage never refetches stale image")

Retrieval Hint: the discriminating measurement is two builds with an unchanged mutable ref where the RUN writes a value that cannot repeat — identical output proves the layer did not execute.

tobiu referenced in commit 0c8eaeb - "feat(deploy): a mutable NEO_REF is refused, because it freezes the source layer (#16635) (#16689) on Aug 8, 2026, 3:35 PM
tobiu closed this issue on Aug 8, 2026, 3:35 PM