mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
feat: attest standard production images at full source commits (#13797)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators deploy its standard production container on several CPU architectures. > - Downstream image builders need to identify the exact source of their base image. > - A short commit tag does not provide signed source evidence. > - This pull request adds a full commit tag and signed image digest for canonical master pushes. > - Consumers can verify the source and compose from the immutable digest. ## Linked Issues or Issue Description **What existing behavior does this improve?** Publication of the standard multi-platform production image. **Current behavior** The Docker workflow publishes short commit tags and channel tags. It does not provide a signed standard-image contract tied to the complete master commit. **Proposed behavior** Canonical master pushes also publish `sha-<full-commit>` and attest the exact index digest after platform validation and an immutable-image orphan-reaping check. The signer certificate binds the source repository, commit, workflow and ref. Existing tags and the separate cloud producer remain available. **Reason and benefit** Downstream builders can prove the source of a standard base without adding their dependencies or repository details to the public workflow. No matching open issue or duplicate PR was found. ## What Changed - Add the canonical full-SHA tag without changing existing tag mappings. - Validate amd64 and arm64 descriptors and hash the exact registry response bytes and require its digest header to match. - Verify the immutable image and sign it with GitHub artifact attestations. - Run the contract tests in trusted PR verification and document the consumer contract. ## Verification - `node --test scripts/__tests__/release-verify-workflow.test.mjs scripts/cloud-source-verification.test.mjs scripts/standard-image-contract.test.mjs`: 37 passed. - A read-only check against an existing published index returned its exact expected digest. - `actionlint -shellcheck='' .github/workflows/docker.yml .github/workflows/pr-trusted.yml`: passed. Normal ShellCheck reports only existing `ls` and word-splitting warnings. - `pnpm build`: passed locally with Cargo available. - `pnpm -r typecheck`: passed locally. - Full local Vitest was attempted: 8,260 passed, 14 failed, with 34 failing suites. The failures were missing embedded-PostgreSQL library aliases in this fresh install and existing macOS runtime-skill-cache rename errors. Native aliases are now restored. Rerunning the 33 affected database suites produced 577 passes and two unrelated AgentMail skill-root lookup failures (32 suites passed). The four directly failing database tests also pass independently. This is not a claim that the full local suite passed. - All final-head CI checks pass. One unrelated routine-route mock assertion passed on the single-shard retry; its 15 tests also pass locally. Greptile is 5/5 on this exact head, with no unresolved threads. - Actual signing requires a canonical master push. This draft PR does not publish trusted provenance. ## Risks The new attestation step requires OIDC and attestation write permissions in the merge job. Signing failure leaves the image available but without the new admission proof. Consumers must fail closed when proof is missing. Existing release tags, the legacy producer, and image retention remain unchanged. No database or application behavior changes. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, shell execution and test tools. The session does not expose a more specific model variant or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Standard production image provenance
|
||||
|
||||
Canonical `master` pushes publish the standard multi-platform `production`
|
||||
image at `ghcr.io/paperclipai/paperclip:sha-<full-40-character-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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user