1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
use super::*;
impl Harness {
/// Drive the `docker` stage when [`StageId::Docker`] is in the
/// requested stage set.
///
/// Iterates the crate-under-test's resolved [`ResolvedDockerConfig`]
/// entries ([`Self::docker_configs`]) — every configured `dockers_v2`
/// image, not just one. For each, it stages a dedicated context (see
/// [`stage_docker_context`]: per-triple binaries at
/// `<context>/<os>/<arch>/<bin>`, the config's rendered dockerfile copied
/// to `<context>/Dockerfile`, and the config's `extra_files`) then
/// delegates to [`anodizer_core::docker_build::oci_build_fixture`] (the
/// allow-listed subprocess entry point), which runs
/// `docker buildx build --output=type=oci,rewrite-timestamp=true,dest=…`
/// with the config's `build_args`. Each emitted OCI tarball is copied to
/// `<worktree>/dist/docker/<idx>/image.oci.tar` (per-config subdir so
/// multiple images don't collide) where the existing [`discover_artifacts`]
/// walker picks it up under the normal `dist/` surface.
///
/// The configured dockerfile is the SAME one the production `docker` stage
/// builds — never a hardcoded repo-root `Dockerfile`. A project with a thin
/// release dockerfile (`COPY ${TARGETOS}/${TARGETARCH}/${BIN}`) distinct
/// from a fat dev `Dockerfile` at the repo root gets its release image
/// byte-verified, not the dev one against an incomplete context.
///
/// Skipped (Ok no-op) when [`Self::docker_configs`] is empty — the crate
/// configures no `dockers_v2`, so there is nothing to byte-compare. This
/// skip is unconditional (never coverage loss regardless of intent) and
/// keeps the stage harmless for cargo-package / cargo-only fixtures that
/// share the harness binary.
///
/// When `docker buildx` is unreachable or the project opted into
/// `use: podman`, the behaviour forks on `explicitly_requested`:
/// - `true` (operator typed `--stages=…,docker`): a hard ERROR. A
/// determinism gate that silently skips a stage the caller asked it
/// to byte-verify is false coverage — a non-reproducible image could
/// ship while the gate reports green. The release pipeline's ubuntu
/// shard requests docker explicitly and provisions a
/// `docker-container` buildx driver, so this error fires only when
/// that provisioning regressed.
/// - `false` (host-default, not operator-typed): a warning through the
/// harness logger (so `-q` silences it). `docker` IS in the Linux host
/// default, so a bare `anodize check determinism` on a Linux box without
/// `docker buildx` reaches this branch and warn-skips rather than failing
/// the whole harness — the harness also runs on minimal images (e.g. the
/// docs build container) that legitimately lack Docker, where failing
/// would block unrelated stages.
pub(crate) fn run_docker_stage(
&self,
worktree_path: &Path,
env: &HashMap<String, String>,
explicitly_requested: bool,
) -> Result<()> {
let log = StageLogger::new("check-determinism", self.verbosity);
// Fork on the two empty-`docker_configs` cases. Resolution ERRORS are
// already hard failures upstream (`resolve_docker_configs` propagates
// every skip-eval / dockerfile / build_arg render error via `?`), so an
// empty set here is NEVER a swallowed error — only:
// - crate declares no `dockers_v2` → nothing to byte-verify → clean
// skip (and never a stray repo-root `Dockerfile`); or
// - crate DECLARES `dockers_v2` but every entry was LEGITIMATELY
// skipped in this context (truthy `skip:` — e.g. the common
// `skip: "{{ .IsSnapshot }}"` under the harness's snapshot mode — or
// an empty-rendered conditional dockerfile).
//
// The all-skipped case must MIRROR production, which cleanly skips
// (DockerStage::run leaves `build_jobs` empty → no build, no error;
// prepare_v2_config `return Ok(())`s a skipped / empty-render entry).
// Hard-failing it under `--require-tools` would be a false FAILURE —
// the mirror of the false pass — turning every determinism run of a
// `skip-on-snapshot` config red. So warn-and-skip even when explicitly
// requested; the warn keeps it from being silent.
if self.docker_configs.is_empty() {
if self.docker_declared {
log.warn(
"skipped docker stage for this run — crate declares dockers_v2 but all \
entries are skipped in this context (`skip:` / empty-rendered dockerfile); \
docker byte-verification produced no image (matches a normal release)",
);
}
return Ok(());
}
// The determinism harness's docker probe shells out to
// `docker buildx build --output=type=oci,rewrite-timestamp=true,...`.
// Those BuildKit-only flags are not recognised by plain
// `podman build`; when the project config opts into `use: podman`
// the only honest behaviour is to skip the docker stage with a
// clear message, rather than spawn `docker buildx` and hand the
// operator a misleading "this image is reproducible" signal that
// covers a binary they will never actually publish.
// These warnings go through the harness logger so `-q` governs
// them like every other harness line; the docker-buildx child's
// own output below is captured by `run_checked` and surfaced only
// at `-v` (or on failure), so it honours the same verbosity flag.
if self.docker_backend_hint.as_deref() == Some("podman") {
let msg = "docker stage requested but project config has `use: podman` \
(Linux-only); the determinism harness only probes BuildKit-based \
builds. Verify podman image byte-stability outside the harness.";
if explicitly_requested {
anyhow::bail!(
"{msg} Refusing to report byte-stability for an image the harness \
cannot probe — remove `docker` from --stages or build the image \
with BuildKit."
);
}
log.warn(&format!("{msg} The docker stage is skipped for this run."));
return Ok(());
}
match anodizer_core::docker_detect::buildx_available() {
Ok(true) => {}
Ok(false) | Err(_) => {
if explicitly_requested {
anyhow::bail!(
"docker stage requested via --stages but `docker buildx` is not \
available on PATH; the determinism gate cannot byte-verify the \
image. Provision a `docker-container` buildx driver \
(docker/setup-buildx-action) before running the harness."
);
}
log.warn(
"skipped docker stage for this run — `docker buildx` is not available on PATH \
(no artifacts emitted)",
);
return Ok(());
}
}
// Build EVERY configured image so the gate covers each, not just one.
for (idx, docker_cfg) in self.docker_configs.iter().enumerate() {
self.run_one_docker_config(
worktree_path,
env,
&log,
idx,
docker_cfg,
explicitly_requested,
)?;
}
Ok(())
}
/// Stage + build one resolved `dockers_v2` entry for the current run,
/// emitting its OCI tarball (and BuildKit digest) under
/// `<worktree>/dist/docker/<idx>/`.
pub(crate) fn run_one_docker_config(
&self,
worktree_path: &Path,
env: &HashMap<String, String>,
log: &StageLogger,
idx: usize,
docker_cfg: &ResolvedDockerConfig,
explicitly_requested: bool,
) -> Result<()> {
// A configured dockerfile that isn't in the committed worktree would
// also fail the production release (the release rebuilds the same
// commit). Fork on intent like the buildx / staged-binary gates: an
// explicit request hard-fails (silent skip = false coverage); a
// host-default run warn-skips so a dev box stays usable.
let dockerfile_abs = worktree_path.join(&docker_cfg.dockerfile);
if !dockerfile_abs.exists() {
if explicitly_requested {
anyhow::bail!(
"dockers_v2[{idx}] dockerfile '{}' does not exist in the rebuilt \
worktree; the determinism docker stage cannot byte-verify it. \
Ensure the dockerfile is committed.",
docker_cfg.dockerfile
);
}
log.warn(&format!(
"skipped dockers_v2[{idx}] for this run — dockerfile '{}' not found in the \
rebuilt worktree (no artifacts emitted)",
docker_cfg.dockerfile
));
return Ok(());
}
// Per-config context dir so multiple images don't clobber each other.
let context_dir = worktree_path
.join(".det-tmp")
.join(format!("docker-context-{idx}"));
let staged = stage_docker_context(worktree_path, &context_dir, docker_cfg, log)?;
if staged == 0 {
// No per-triple binaries discovered means the build pipeline
// produced nothing the dockerfile's COPY could resolve. Honour
// the explicit-vs-auto fork rather than spawn a build that is
// guaranteed to fail the COPY with a cryptic BuildKit error.
if explicitly_requested {
anyhow::bail!(
"docker stage requested via --stages but the build produced no \
per-triple binaries to stage under <os>/<arch>/; the COPY in \
dockers_v2[{idx}]'s dockerfile cannot resolve. Check that the \
requested --targets built successfully."
);
}
log.warn(
"skipped docker stage for this run — no per-triple binaries to stage \
under <os>/<arch>/ (no artifacts emitted)",
);
return Ok(());
}
// Pin the image tag to a deterministic per-config constant so the
// manifest's `org.opencontainers.image.ref.name` annotation does not
// itself drift between runs based on time-derived names.
let output = anodizer_core::docker_build::oci_build_fixture(
&context_dir,
&format!("anodize/det:harness-{idx}"),
&docker_cfg.build_args,
env,
log,
)?;
let dest_dir = worktree_path
.join("dist")
.join("docker")
.join(idx.to_string());
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("creating dest dir {}", dest_dir.display()))?;
// Rename to a stable filename so the artifact-discovery walker
// surfaces a single canonical row regardless of where buildx
// emitted the tarball under the worktree.
let target = dest_dir.join("image.oci.tar");
std::fs::copy(&output.oci_tar_path, &target).with_context(|| {
format!(
"copying {} → {}",
output.oci_tar_path.display(),
target.display()
)
})?;
// Capture the BuildKit-reported image digest alongside the OCI
// tarball so the report records it as a separately-diffed
// artifact. The two are independent stability signals: the
// tarball hash covers serialized bytes (layer tar member
// ordering, manifest serialization), while the iidfile records
// BuildKit's pre-serialization manifest digest. Both must be
// stable for the image to be declared byte-stable.
if let Some(digest) = output.image_digest.as_deref() {
std::fs::write(dest_dir.join("image.digest"), digest).with_context(|| {
format!(
"writing image digest to {}",
dest_dir.join("image.digest").display()
)
})?;
}
Ok(())
}
}