Skip to content

feat(scripts): fleet-hash — deterministic build-input hashes over the bake graph - #298

Merged
elronbandel merged 5 commits into
mainfrom
elron/fleet-input-hash
Aug 9, 2026
Merged

feat(scripts): fleet-hash — deterministic build-input hashes over the bake graph#298
elronbandel merged 5 commits into
mainfrom
elron/fleet-input-hash

Conversation

@elronbandel

@elronbandel elronbandel commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Resolves #297. Rung 2 of #292's ratified migration path (rules landed in #293).

What

containers/scripts/fleet-hash.sh — the build-input hash primitive behind delivery rules 11–14, plus the offline static-stage property tests and a per-PR CI gate pinning the graph reading to plain bake. No release-behavior changes — nothing consumes the hash yet (the one workflow change adds a check step to the static-composition job).

fleet-hash.sh                        # all 153 static bake targets (TSV)
fleet-hash.sh combo <bench> <agent>  # eval + eval-standalone rows
fleet-hash.sh per-task <bench> <id>  # per-task image row
fleet-hash.sh graph                  # target|context|deps (context = registry ref path)

hash(target) = sha256 of the sorted git tree hashes of the target's context and every transitive in-repo base context, computed from the containers/ tree materialized at REF via git archive — a pure function of the commit; uncommitted worktree state is invisible. Each row decomposes into hash / context-hash / bases-hash / externals so a consumer can tell why a target moved. External FROMs are emitted with same-Dockerfile ARG defaults expanded; a ref still carrying ${…} is per-build by design.

Audited before merge

Four adversarial audit agents (script edge cases / test rigor via mutation analysis / consumer fitness for rungs 3–5 / doctrine & hygiene) ran against this branch; every reproduced finding is fixed and pinned by a test:

  • Combo closure now includes containers/core/runner/ + entrypoint/ (COPY'd by the combination Dockerfiles). Before: a framework-launcher edit changed every combo image and moved zero hashes — reproduced on real commit b072ff1d, the exact false-fresh failure rule 14 forbids.
  • REF purity: graph + Dockerfiles were read from the worktree while trees came from REF; now everything reads the git archived tree.
  • 15 phantom externals killed (heredoc from X import Y parsed as FROM); alias-ordering and --platform branches pinned by a shape fixture.
  • Runs at stock macOS ulimit -n 256 (closure splitter closed files).
  • Fail-loud hardening: second target block in one file, missing context line (previously silently hashed the whole repo root), comment-quoted target: refs, unparsable sums lines, empty/whitespace task ids.
  • Combo parents derived from combination.docker-bake.hcl's *_IMAGE defaults, not hardcoded.

Gates

  • tests/static/input_hash.rs (runs on every PR, in "clippy + daemon-free tests"): determinism; transitive cascade over a depth-2 diamond fixture (91/153 real targets are depth-2); component decomposition; REF=HEAD~1 re-derivation; worktree-purity; externals across every Dockerfile shape; combo agent-axis + standalone sensitivity; per-task id sensitivity; bad-example error paths. ~4.7s of the 30s static budget.
  • tests/static/fleet-hash.sweep.sh (new step in the static-composition job): one docker buildx bake --print over the full -f set, compared bidirectionally — every fleet-hash target must match bake's context/deps, and bake must know no target fleet-hash missed. --print is client-side HCL evaluation: no daemon, verified against a dead DOCKER_HOST.

Pre-existing red: task_inspection::inspect_every_existing_fixture is red on main and deliberately skipped by CI — untouched here.

Rules checked against

  • .agents/contributing/RULES.md 1–3 (resolves drift issue drift: delivery/RULES.md#12,#14 — no build-input hash is computed or recorded anywhere #297; code-only PR; this section).
  • .agents/delivery/RULES.md 11–14 (the computed-hash side; recording/comparison are later rungs).
  • .agents/src/RULES.md 3, 11 (graph logic in containers/scripts/, not the CLI).
  • tests/static/RULES.md 1–3 (offline, <30s, never ignored; the sweep uses the docker CLI client-side only, same as compose.config.sweep.sh).
  • .agents/RULES.md 7–8 (bash + git + awk + jq + sha256; no new static-crate dependencies).

… bake graph (#297)

The hash primitive for carried-forward releases (delivery/RULES.md rules
11-14): hash(target) = sha256(context tree hash + sorted recursive base
hashes), read off the bake graph that principle 15.d keeps aligned with
the Dockerfiles. Three providers behind one entry point:

  fleet-hash.sh                        # all 153 static bake targets
  fleet-hash.sh combo <bench> <agent>  # eval + eval-standalone rows
  fleet-hash.sh per-task <bench> <id>  # per-task image row

Output decomposes into hash / context-hash / bases-hash / externals so a
consumer can tell WHY a target moved (context edit vs base cascade vs
upstream drift). External FROM refs are emitted unresolved — digest
resolution needs the network and happens at release time (rule 11); the
script itself is offline and a pure function of the committed tree at REF.

Design notes proven by tests/static/input_hash.rs on a synthetic git
fixture: deterministic across runs, a leaf edit moves only that leaf's
context component, a base edit cascades through dependents' bases
component, REF pins the whole computation to a commit. The real-repo
test asserts one row per per-artifact bake file and exercises all three
providers.

Combos hash from the combination Dockerfiles + parent target hashes,
not the over-broad containers/core context. Dir-name lookups are
context-based, so dotted model dirs (gpt-5.4) resolve despite their
underscored target names. SQL FROM clauses on RUN continuation lines
are not image refs. Portable to bash 3.2 (macOS); one awk parse + one
batched git rev-parse keeps a full-fleet run ~5s.

Part of #292's migration path, rung 2 of 5. Resolves #297.

Signed-off-by: Elron Bandel <office.bandel@gmail.com>
Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
…ombo-list drift safety

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
hash(target) = sha256 of the sorted tree hashes of the target's context
and every transitive base context. Sensitivity-equivalent to the Merkle
chain (wiring changes edit bake files, which live inside a hashed
context) but the whole computation is three small awk programs, one
batched git rev-parse, and one sha256sum invocation: ~12 forks instead
of ~460, 24s -> 5s -> 0.3s, and the bash 3.2 eval-map workaround is
gone entirely. Combos become a flat union of parent closures + the
combination file blobs; standalone folds the eval set in directly.

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
The script reads the bake files directly (its static tests must run
without the docker CLI), so pin that reading to bake's own evaluation:
a build-lane test compares `fleet-hash.sh graph` (new subcommand,
target|context|deps) against one `docker buildx bake --print` over the
full -f set, target for target. The awk parse can never silently drift
from the canonical consumer of the graph data (RULES.md principle 15).
Verified locally: all 153 targets match.

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
…s, ulimit

Four adversarial audits on the PR surfaced real correctness bugs; all
reproduced, fixed, and pinned by tests:

- Combo closure was blind to containers/core/runner/ (and entrypoint/),
  which the combination Dockerfiles COPY from: a framework-launcher edit
  changed every combo image and moved zero hashes — the exact false-fresh
  rule 14 forbids. Reproduced on real commit b072ff1. Both trees now
  join the combo context inputs.
- The graph and Dockerfiles were read from the worktree while trees came
  from REF, so REF=<old> emitted a hybrid and uncommitted edits leaked
  in. containers/ is now materialized from REF via git archive: the
  output is a pure function of the commit (and uncommitted state is
  invisible rather than fatal).
- 15 phantom externals: python 'from X import Y' in heredoc RUN bodies
  parsed as FROM instructions. Only an unindented uppercase FROM outside
  a backslash continuation counts now; ARG defaults are expanded so
  externals are digest-resolvable (${…} left = per-build by design).
- The closure splitter held 288 files open — unrunnable at stock macOS
  ulimit 256. Files close on change (input is sorted).
- Fail-loud hardening: second target block in one bake file, missing
  context line, comment-quoted target: refs, unparsable sums lines,
  empty/whitespace per-task ids, graph missing from the usage error.
- Combo parents derived from combination.docker-bake.hcl's *_IMAGE
  defaults instead of hardcoded names.

Tests upgraded to kill every surviving mutant the audit found: depth-2
diamond fixture (transitivity is now asserted — 91/153 real targets are
depth-2), externals shape fixture (AS/platform/scratch/ARG/heredoc),
combo agent-axis + standalone sensitivity, per-task id sensitivity,
error-path bad examples, real-repo byte-determinism, Fixture Drop
cleanup + GIT_* env isolation.

The bake --print gate moves from the never-run build lane to
tests/static/fleet-hash.sweep.sh, wired into the per-PR
static-composition job, and is now bidirectional: bake must also know
no target fleet-hash missed. tests/build reverted to main.

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
@elronbandel
elronbandel merged commit 87bca29 into main Aug 9, 2026
6 checks passed
@elronbandel
elronbandel deleted the elron/fleet-input-hash branch August 9, 2026 13:09
elronbandel added a commit that referenced this pull request Aug 9, 2026
* feat(release): stamp eval.input-hash on every pushed image (#302)

Rung 3 of the carried-forward ladder (#292; primitive merged in #298):
every push site now records the image's build-input hash in its config
(delivery/RULES.md rule 12), the value rungs 4-5 will diff and retag on.

Per-target --set at every bake site — never *.labels, because a single
invocation bakes targets with different hashes (bases bakes ~19, combos
bakes eval + eval-standalone):

- bases: one fleet-hash run, one --set per shared dep
- leaves: --set "$T.labels.eval.input-hash" from the leaf's row
- per-task bake (swe-bench): per-task hash via fleet-hash per-task
- per-task script (terminal-bench, skills-bench): build.sh gains an
  optional EVAL_INPUT_HASH env hook -> --label on the final build (this
  path has no bake invocation to --set)
- combos: fleet-hash combo <b> <a> [task] — the new optional task arg
  mixes the task id into the hash exactly like per-task does and names
  rows with the release's <bench>-<tid> convention. No-task hashes are
  byte-identical to the frozen #298 definition (verified against the
  merged script across all 153 targets + combos).

Label semantics: the hash records the repo-computed inputs at the
stamped commit. On a frozen-bases dev dispatch a leaf may build FROM an
older base than its hash's base component claims; tagged releases always
rebuild bases first, so release labels are exact (noted inline).

All three --set shapes verified locally via bake --print: per-target
labels land on their own targets only.

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>

* polish: one with_task helper for the two combo task-mix sites

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>

---------

Signed-off-by: Elron Bandel <elron.bandel@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drift: delivery/RULES.md#12,#14 — no build-input hash is computed or recorded anywhere

1 participant