diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7f76a3894d..6153471c69 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -217,6 +217,7 @@ jobs: type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=sha + type=raw,value=sha-${{ github.sha }},enable=${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} labels: | io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} @@ -279,6 +280,8 @@ jobs: permissions: contents: read packages: write + id-token: write + attestations: write steps: # Checked out for `packages/db` (schema labels) and the orphan-reaping # script the verification step pipes in. @@ -332,6 +335,7 @@ jobs: type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=sha + type=raw,value=sha-${{ github.sha }},enable=${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' }} labels: | io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} @@ -362,6 +366,27 @@ jobs: echo "Verifying orphan reaping in $image" docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh + # This generic contract is useful to any downstream image composer. Only + # canonical master pushes own the full-SHA tag and its signed provenance. + # Capture exact registry bytes; both the smoke and attestation use their + # digest, so later tag movement cannot change what was verified. + - name: Resolve standard image digest + id: standard + if: github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' + run: node scripts/standard-image-contract.mjs --resolve "$GITHUB_SHA" + - name: Verify the immutable standard image + if: steps.standard.outputs.digest != '' + env: + IMAGE_DIGEST: ${{ steps.standard.outputs.digest }} + run: docker run --rm -i --pull always "ghcr.io/paperclipai/paperclip@$IMAGE_DIGEST" sh -s < scripts/assert-orphan-reaping.sh + - name: Attest the standard production image + if: steps.standard.outputs.digest != '' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-name: ghcr.io/paperclipai/paperclip + subject-digest: ${{ steps.standard.outputs.digest }} + push-to-registry: true + # Master cloud builds start independently in docker-cloud.yml. Tag builds # and manual Docker dispatches call the same implementation, preserving the # release tags and the canary promotion dependency below. diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 4e2b36c91d..d32dc1169b 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -328,7 +328,7 @@ jobs: run: node --test ./scripts/__tests__/e2e-shard.test.mjs - name: Test release verify workflow wiring - run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs + run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs - name: Test standalone package build concurrency run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs diff --git a/doc/standard-image-contract.md b/doc/standard-image-contract.md new file mode 100644 index 0000000000..420acd4a4c --- /dev/null +++ b/doc/standard-image-contract.md @@ -0,0 +1,24 @@ +# Standard production image provenance + +Canonical `master` pushes publish the standard multi-platform `production` +image at `ghcr.io/paperclipai/paperclip:sha-`. +Existing short-SHA, version and channel tags continue to work. Other refs and +manual dispatches do not write the canonical full-SHA tag. + +The Docker workflow validates the index contains exactly one Linux amd64 and +one Linux arm64 image, verifies orphan reaping against its immutable digest, +and signs that digest using GitHub artifact attestations. The signer is +`paperclipai/paperclip/.github/workflows/docker.yml@refs/heads/master`. +Consumers must verify the signed source SHA, repository identity and ref, +then compose from the verified digest, never from the mutable lookup tag. +A missing attestation means this producer contract is not available for that +commit. The presence of a tag alone is insufficient. + +This proves image provenance, not all application tests. Downstream services +must also require their source verification and migration compatibility gates. +No downstream repository names, credentials or dependencies are needed here. +The separate `-cloud` image producer remains available during migration. + +Local contract validation: `node --test scripts/standard-image-contract.test.mjs`. +Actual signing runs only after a canonical master build; a PR cannot publish +trusted provenance. diff --git a/scripts/standard-image-contract.mjs b/scripts/standard-image-contract.mjs new file mode 100644 index 0000000000..62a8249e2d --- /dev/null +++ b/scripts/standard-image-contract.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { appendFile, readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +/** Validate the multi-platform index before it becomes a signed build contract. */ +export function standardImageDigest(bytes) { + const index = JSON.parse(bytes.toString("utf8")); + assert.equal(index.schemaVersion, 2); + assert.ok(["application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.list.v2+json"].includes(index.mediaType)); + assert.ok(Array.isArray(index.manifests)); + for (const architecture of ["amd64", "arm64"]) { + const matches = index.manifests.filter(item => item.platform?.os === "linux" && item.platform?.architecture === architecture); + assert.equal(matches.length, 1, `Expected exactly one linux/${architecture} image`); + assert.match(matches[0].digest, /^sha256:[a-f0-9]{64}$/); + assert.ok(Number.isSafeInteger(matches[0].size) && matches[0].size > 0); + } + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +/** Read registry bytes directly; CLI stdout formatting is not artifact content. */ +export async function resolveStandardImageDigest(sha, fetchImpl = fetch, { + sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), +} = {}) { + assert.match(sha ?? "", /^[a-f0-9]{40}$/); + const request = async (url, headers, consume, allowNotFound = false) => { + for (let attempt = 1; attempt <= 5; attempt++) { + let response; + try { + response = await fetchImpl(url, { headers, redirect: "error", signal: AbortSignal.timeout(30_000) }); + } catch (error) { + if (attempt === 5) throw error; + await sleep(attempt * 1000); + continue; + } + if (!response.ok) { + await response.body?.cancel(); + const transient = [408, 425, 429].includes(response.status) || response.status >= 500 || (allowNotFound && response.status === 404); + assert.ok(transient && attempt < 5, `Registry lookup failed: HTTP ${response.status}`); + const retryAfter = Number(response.headers.get("retry-after")); + await sleep(Math.min(30_000, Math.max(attempt * 1000, Number.isFinite(retryAfter) ? retryAfter * 1000 : 0))); + continue; + } + try { return await consume(response); } + catch (error) { + // Interrupted response bodies are transient too. The size bound is an + // integrity limit and fails immediately, as does validation below. + if (error instanceof RangeError || attempt === 5) throw error; + await sleep(attempt * 1000); + } + } + throw new Error("Registry retry budget exhausted"); + }; + const { token } = await request("https://ghcr.io/token?service=ghcr.io&scope=repository:paperclipai/paperclip:pull", {}, response => response.json()); + assert.ok(typeof token === "string" && token, "Missing public pull token"); + const { raw, expected } = await request(`https://ghcr.io/v2/paperclipai/paperclip/manifests/sha-${sha}`, { + authorization: `Bearer ${token}`, accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json", + }, async response => { + assert.ok(response.body, "Standard image index has no body"); + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + length += next.value.length; + if (length > 1024 * 1024) throw new RangeError("Standard index exceeds its size limit"); + chunks.push(next.value); + } + } finally { await reader.cancel(); } + return { raw: Buffer.concat(chunks), expected: response.headers.get("docker-content-digest") }; + }, true); + const digest = standardImageDigest(raw); + assert.equal(digest, expected, "Registry digest does not match the index bytes"); + return digest; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const digest = process.argv[2] === "--resolve" ? await resolveStandardImageDigest(process.argv[3]) : standardImageDigest(await readFile(process.argv[2])); + if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `digest=${digest}\n`); + console.log(digest); +} diff --git a/scripts/standard-image-contract.test.mjs b/scripts/standard-image-contract.test.mjs new file mode 100644 index 0000000000..c903e95664 --- /dev/null +++ b/scripts/standard-image-contract.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { standardImageDigest, resolveStandardImageDigest } from "./standard-image-contract.mjs"; +const descriptor = architecture => ({ platform: { os: "linux", architecture }, digest: `sha256:${"a".repeat(64)}`, size: 100 }); +const index = manifests => Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.index.v1+json", manifests })); +test("signs the exact registry bytes, including both supported platforms", () => { + const bytes = index([descriptor("amd64"), descriptor("arm64")]); + assert.equal(standardImageDigest(bytes), `sha256:${createHash("sha256").update(bytes).digest("hex")}`); +}); +test("rejects incomplete, ambiguous, or malformed platform manifests", () => { + for (const manifests of [[descriptor("amd64")], [descriptor("amd64"), descriptor("amd64"), descriptor("arm64")], + [{ ...descriptor("amd64"), digest: "mutable-tag" }, descriptor("arm64")], + [{ ...descriptor("amd64"), size: -1 }, descriptor("arm64")]]) assert.throws(() => standardImageDigest(index(manifests))); +}); + + +test("lookup hashes registry bytes and rejects formatting or header mismatches", async () => { + const raw = index([descriptor("amd64"), descriptor("arm64")]); + const expected = standardImageDigest(raw); + let appendNewline = false; + const fetchImpl = async (url, options) => { + assert.equal(options.redirect, "error"); + if (url.includes("/token?")) return Response.json({ token: "public-fixture" }); + assert.ok(url.endsWith(`sha-${"a".repeat(40)}`)); + assert.equal(options.headers.authorization, "Bearer public-fixture"); + return new Response(appendNewline ? Buffer.concat([raw, Buffer.from("\n")]) : raw, { headers: { "docker-content-digest": expected } }); + }; + assert.equal(await resolveStandardImageDigest("a".repeat(40), fetchImpl), expected); + appendNewline = true; + await assert.rejects(resolveStandardImageDigest("a".repeat(40), fetchImpl), /Registry digest/); +}); + + +test("bounded retries cover token failures, propagation lag and rate limits", async () => { + const raw = index([descriptor("amd64"), descriptor("arm64")]); + const expected = standardImageDigest(raw); + const waits = []; + let tokens = 0, manifests = 0; + const fetchImpl = async url => { + if (url.includes("/token?")) { + if (++tokens === 1) throw new Error("transient connection reset"); + if (tokens === 2) return new Response(null, { status: 503 }); + return Response.json({ token: "public-fixture" }); + } + if (++manifests === 1) return new Response(null, { status: 404 }); + if (manifests === 2) return new Response(null, { status: 429, headers: { "retry-after": "120" } }); + return new Response(raw, { headers: { "docker-content-digest": expected } }); + }; + assert.equal(await resolveStandardImageDigest("a".repeat(40), fetchImpl, { sleep: async ms => { waits.push(ms); } }), expected); + assert.deepEqual(waits, [1000, 2000, 1000, 30000]); + assert.equal(tokens, 3); assert.equal(manifests, 3); +}); + +test("permanent registry errors fail immediately and transient errors exhaust a finite budget", async () => { + for (const [status, expectedCalls] of [[401, 1], [403, 1], [503, 5]]) { + let calls = 0; + await assert.rejects(resolveStandardImageDigest("a".repeat(40), async () => { calls++; return new Response(null, { status }); }, { sleep: async () => {} }), /Registry lookup failed/); + assert.equal(calls, expectedCalls); + } +});