From aba431cfc8e5ab7ff974fde1fd1c1ff863cd3d6e Mon Sep 17 00:00:00 2001 From: Evgeny Boger Date: Fri, 19 Jun 2026 15:48:27 +0000 Subject: [PATCH] image: clamp layer mtimes (rewrite-timestamp) + pin sbom off + assert single manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up to the deterministic-manifest change. SOURCE_DATE_EPOCH only clamps the image CONFIG (created + history); layer file mtimes still defaulted to build wall-clock, so the manifest digest was reproducible only while the gha layer cache held. On a cache miss (eviction / size cap / base-layer invalidation) the apt/curl RUN steps re-ran and emitted layers with fresh mtimes → new diff_ids → new manifest digest for an unchanged commit, re-introducing the ~700 MiB consumer re-pull. - outputs: add `rewrite-timestamp=true` so buildkit clamps every in-layer file mtime to SOURCE_DATE_EPOCH (the commit time). Layer digests — and thus the manifest digest — are now reproducible independent of cache state; the digest moves only on a real content change. - sbom: false — pin it (defaults off today) so a future buildx default flip can't re-wrap the single-platform build in an attestation index whose build-time digest would churn :latest hourly. Complements provenance:false. - "Show pushed digest" now asserts the pushed `:sha-…` is a single image manifest, not an index — fails the build if an attestation ever leaks back in and silently restores the churn. Follow-up to #15 / part of #17. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG --- .github/workflows/build-image.yml | 48 +++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 63d1cc0..36938d2 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -148,7 +148,19 @@ jobs: # past that for binary-heavy content. # microsandbox's `tar_ingest.rs:427` already accepts the # `application/vnd.oci.image.layer.v1.tar+zstd` media type. - outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true + # + # `rewrite-timestamp=true` clamps the mtime of every file + # INSIDE each layer tar to SOURCE_DATE_EPOCH (the commit time). + # SOURCE_DATE_EPOCH alone only fixes the image config; layer + # file mtimes default to build wall-clock, so a `cache-from` + # MISS (gha eviction / size cap / a base-layer invalidation) + # would re-run the apt/curl RUN steps and emit layers with + # fresh mtimes → new diff_ids → new manifest digest for an + # unchanged commit, re-introducing the ~700 MiB consumer + # re-pull. Clamping mtimes makes the layer digests — and thus + # the manifest digest — reproducible independent of cache + # state, so the digest only moves on a real content change. + outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true,rewrite-timestamp=true # Per-agent version cache keys. `Resolve agent versions` # above looks up each agent's current upstream version; the # Dockerfile references these in the matching installer RUN, @@ -168,13 +180,19 @@ jobs: # change (the common hourly case). cache-from: type=gha cache-to: type=gha,mode=max - # No provenance/SBOM attestation. The attestation manifest - # embeds the build time, so it changes the multi-arch index - # digest on every build even when the image content is + # No provenance/SBOM attestation. Either one makes buildx wrap + # even a single-platform build in an OCI index whose extra + # attestation manifest embeds the build time, so the index + # digest changes every build even when the image content is # identical — which would keep `:latest`'s digest churning # hourly (and nag the update banner) despite a stable - # linux/amd64 manifest. We don't consume provenance anywhere. + # linux/amd64 manifest. We don't consume either anywhere. + # Both are pinned off (provenance defaults to mode=min on push; + # sbom defaults off but is pinned so a future default flip + # can't silently re-introduce the index). The "Show pushed + # digest" step asserts the push stayed a single manifest. provenance: false + sbom: false # Moving tags (`:latest`, `:YYYY-MM-DDTHH`) are gated to # the integration branches (`main`, `rewrite-microsandbox`) # so workflow_dispatch on a feature branch can verify the @@ -203,10 +221,28 @@ jobs: org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - - name: Show pushed digest + - name: Show pushed digest + assert single manifest run: | echo "Pushed ${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}" echo " digest: ${{ steps.build.outputs.digest }}" + # Guard the digest-stability invariant: the push must be a + # single image manifest, NOT a multi-arch index. An index + # (from a re-enabled provenance/sbom attestation, or a buildx + # default flip) carries a build-time-varying attestation digest + # and would silently restore the hourly `:latest` churn this + # workflow exists to prevent. The immutable `:sha-…` tag is + # always pushed (on every branch), so inspect that. + ref="${IMAGE}:sha-${{ github.sha }}" + if mt=$(docker buildx imagetools inspect --raw "$ref" 2>/dev/null | jq -r '.mediaType'); then + echo " pushed mediaType: $mt" + case "$mt" in + *manifest.list*|*image.index*) + echo "::error::pushed an index ($mt) — an attestation leaked back in; :latest digest will churn hourly. Ensure provenance:false + sbom:false." + exit 1 ;; + esac + else + echo "::warning::could not inspect $ref to assert single-manifest; skipping the check" + fi retain: # Delete date-tagged image versions older than 14 days so the