diff --git a/.github/scripts/authorize-storybook-deploy.cjs b/.github/scripts/authorize-storybook-deploy.cjs
new file mode 100644
index 0000000000..73d8ae7b83
--- /dev/null
+++ b/.github/scripts/authorize-storybook-deploy.cjs
@@ -0,0 +1,50 @@
+// Run before building, and again inside the protected deployment job on reruns.
+module.exports = async function authorizeStorybookDeploy({ github, context }) {
+ const fail = (message) => { throw new Error(message); };
+ if (context.repo.owner !== "paperclipai" || context.repo.repo !== "paperclip") {
+ fail("Storybook publishing is restricted to paperclipai/paperclip.");
+ }
+ if (context.eventName !== "workflow_dispatch" || !context.ref.startsWith("refs/heads/")) {
+ fail("Storybook publishing requires a manual run from a repository branch.");
+ }
+
+ // The selected branch must never be able to add itself to the allowlist.
+ const { data: repository } = await github.rest.repos.get(context.repo);
+ const { data: file } = await github.rest.repos.getContent({
+ ...context.repo,
+ path: ".github/CODEOWNERS",
+ ref: repository.default_branch,
+ });
+ if (file.encoding !== "base64" || typeof file.content !== "string") {
+ fail("Cannot read the default branch CODEOWNERS file.");
+ }
+ const owners = new Set();
+ for (const line of Buffer.from(file.content, "base64").toString("utf8").split(/\r?\n/)) {
+ const fields = line.split("#", 1)[0].trim().split(/\s+/);
+ for (const owner of fields.slice(1)) {
+ // Individual GitHub accounts only. Teams/email entries do not grant access.
+ if (/^@[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner)) {
+ owners.add(owner.slice(1).toLowerCase());
+ }
+ }
+ }
+ if (owners.size === 0) fail("CODEOWNERS has no individual GitHub accounts.");
+ for (const actor of [context.actor, process.env.GITHUB_TRIGGERING_ACTOR]) {
+ if (!actor || !owners.has(actor.toLowerCase())) {
+ fail(`Only default-branch CODEOWNERS may publish Storybook (${actor || "missing actor"}).`);
+ }
+ }
+
+ // A branch can edit its workflow. Require a GitHub-enforced CODEOWNER review
+ // as well, so editing this check cannot grant an outsider deployment access.
+ const { data: environment } = await github.rest.repos.getEnvironment({
+ ...context.repo,
+ environment_name: "storybook-deploy",
+ });
+ const reviewers = environment.protection_rules
+ ?.find((rule) => rule.type === "required_reviewers")?.reviewers;
+ if (environment.can_admins_bypass !== false || !reviewers?.length ||
+ reviewers.some(({ type, reviewer }) => type !== "User" || !owners.has(reviewer.login.toLowerCase()))) {
+ fail("storybook-deploy must require CODEOWNER reviewers and disable administrator bypass.");
+ }
+};
diff --git a/.github/scripts/publish-storybook.cjs b/.github/scripts/publish-storybook.cjs
new file mode 100644
index 0000000000..e0578a4bc7
--- /dev/null
+++ b/.github/scripts/publish-storybook.cjs
@@ -0,0 +1,44 @@
+const { execFileSync } = require('node:child_process');
+const fs = require('node:fs');
+const path = require('node:path');
+const { storybookDestination, branchIndex } = require('./storybook-destination.cjs');
+
+const destination = storybookDestination({
+ branch: process.env.SOURCE_BRANCH, sha: process.env.SOURCE_SHA,
+ runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
+ bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL,
+});
+const source = path.resolve('storybook-static');
+// Treat the artifact as public files, never as executable publisher code.
+function validateTree(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isSymbolicLink() || entry.name.startsWith('.') || (!entry.isDirectory() && !entry.isFile())) {
+ throw new Error(`Unsupported artifact entry: ${path.join(dir, entry.name)}`);
+ }
+ if (entry.isDirectory()) validateTree(path.join(dir, entry.name));
+ }
+}
+validateTree(source);
+for (const name of ['index.html', 'iframe.html', 'index.json']) {
+ if (!fs.statSync(path.join(source, name)).isFile() || fs.statSync(path.join(source, name)).size === 0) {
+ throw new Error(`Missing Storybook output: ${name}`);
+ }
+}
+fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n');
+const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' });
+// Complete a unique build before changing the branch's entry point. No deletion
+// permissions, shared root writes or mixed-version branch assets are needed.
+aws(['s3', 'cp', source, `s3://${destination.bucket}/${destination.buildPrefix}/`,
+ '--recursive', '--no-follow-symlinks', '--only-show-errors',
+ '--cache-control', 'public,max-age=31536000,immutable']);
+const indexFile = path.join(process.env.RUNNER_TEMP, 'storybook-branch-index.html');
+fs.writeFileSync(indexFile, branchIndex(destination.buildUrl));
+aws(['s3', 'cp', indexFile, `s3://${destination.bucket}/${destination.prefix}/index.html`,
+ '--content-type', 'text/html; charset=utf-8', '--cache-control', 'no-cache,max-age=0,must-revalidate', '--only-show-errors']);
+const report = `[Branch Storybook](${destination.url})\n\n[This build](${destination.buildUrl})\n\nCommit: \`${destination.sha}\`\n`;
+const reportPath = path.join(process.env.RUNNER_TEMP, 'storybook-deployment.md');
+fs.writeFileSync(reportPath, report);
+if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
+ `url=${destination.url}\nbuild_url=${destination.buildUrl}\nreport_path=${reportPath}\n`);
+if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
+console.log(JSON.stringify(destination));
diff --git a/.github/scripts/storybook-destination.cjs b/.github/scripts/storybook-destination.cjs
new file mode 100644
index 0000000000..681fe4bb36
--- /dev/null
+++ b/.github/scripts/storybook-destination.cjs
@@ -0,0 +1,35 @@
+const { createHash } = require('node:crypto');
+
+function storybookDestination({ branch, sha, runId, runAttempt, bucket, baseUrl }) {
+ if (typeof branch !== 'string' || !branch || /[\x00-\x20\x7f]/.test(branch)) {
+ throw new Error('A non-empty repository branch name is required.');
+ }
+ if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error('A full source commit SHA is required.');
+ if (![runId, runAttempt].every((value) => /^[1-9]\d*$/.test(String(value)))) {
+ throw new Error('A valid workflow run and attempt are required.');
+ }
+ if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) throw new Error('Invalid Storybook S3 bucket.');
+ const base = new URL(baseUrl);
+ if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') {
+ throw new Error('Storybook base URL must be a credential-free HTTPS origin.');
+ }
+ const label = branch.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0,60) || 'branch';
+ const digest = createHash('sha256').update(branch).digest('hex').slice(0,16);
+ const branchKey = `${label}-${digest}`;
+ const prefix = `storybook/branches/${branchKey}`;
+ const buildPrefix = `${prefix}/builds/${runId}-${runAttempt}`;
+ return {
+ branch, sha, bucket, branchKey, prefix, buildPrefix,
+ url: `${base.origin}/${prefix}/index.html`,
+ buildUrl: `${base.origin}/${buildPrefix}/index.html`,
+ };
+}
+
+function branchIndex(buildUrl) {
+ // The target is generated from a validated origin and ASCII path segments.
+ const target = JSON.stringify(buildUrl).replace(/
Storybook preview
+
+\n`;
+}
+module.exports = { storybookDestination, branchIndex };
diff --git a/.github/scripts/tests/cloud-readiness.test.mjs b/.github/scripts/tests/cloud-readiness.test.mjs
new file mode 100644
index 0000000000..25233a335b
--- /dev/null
+++ b/.github/scripts/tests/cloud-readiness.test.mjs
@@ -0,0 +1,91 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { waitForCloudArtifacts } from "../../../scripts/cloud-readiness.mjs";
+import { previewManifest } from "../../../scripts/preview-artifacts.mjs";
+
+const sha = "a".repeat(40);
+const digest = `sha256:${"b".repeat(64)}`;
+const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
+function registry({ missing = new Set(), failure, wrongImage = false, wrongPackage = false } = {}) {
+ return async (url) => {
+ if (failure) return json({}, failure);
+ if (url.startsWith("https://registry.npmjs.org/")) {
+ const name = decodeURIComponent(new URL(url).pathname.split("/")[1]);
+ if (missing.has(name.split("/")[1])) return json({}, 404);
+ const pkg = previewManifest({ name, version: "0.0.0" }, sha);
+ return json({ ...pkg, ...(wrongPackage ? { gitHead: "c".repeat(40) } : {}), dist: { integrity: "sha512-fixture", tarball: "https://registry.npmjs.org/fixture.tgz" } });
+ }
+ if (url.includes("/token?")) return json({ token: "fixture" });
+ if (url.includes("/manifests/")) return missing.has("image") ? json({}, 404) : json({ config: { digest } });
+ if (url.includes("/blobs/")) return json({ config: { Labels: { "org.opencontainers.image.revision": wrongImage ? "c".repeat(40) : sha } } });
+ throw new Error(`Unexpected request: ${url}`);
+ };
+}
+
+test("readiness requires the image and both exact-source packages on the successful poll", async () => {
+ const missing = new Set(["image", "shared", "db"]);
+ let clock = 0;
+ const states = [];
+ const result = await waitForCloudArtifacts(sha, {
+ fetchImpl: registry({ missing }), now: () => clock, intervalMs: 10, timeoutMs: 100, log: (message) => states.push(message),
+ sleep: async (ms) => {
+ clock += ms;
+ if (clock === 10) missing.delete("image");
+ if (clock === 20) missing.delete("shared");
+ if (clock === 30) { missing.delete("db"); missing.add("image"); }
+ if (clock === 40) missing.delete("image");
+ },
+ });
+ assert.equal(clock, 40, "an artifact disappearing before the final poll must prevent readiness");
+ assert.deepEqual(result, { version: 1, sha, packageVersion: `0.0.0-preview.g${sha}` });
+ assert.match(states.at(-1), /Cloud artifacts available/);
+});
+
+test("missing artifacts time out with a precise inventory and bounded sleep", async () => {
+ let clock = 0;
+ const sleeps = [];
+ await assert.rejects(waitForCloudArtifacts(sha, {
+ fetchImpl: registry({ missing: new Set(["db"]) }), now: () => clock, timeoutMs: 25, intervalMs: 20, log: () => {},
+ sleep: async (ms) => { sleeps.push(ms); clock += ms; },
+ }), /timed out.*missing: db/);
+ assert.deepEqual(sleeps, [20, 5]);
+});
+
+for (const fixture of [{ failure: 403 }, { failure: 503 }, { wrongImage: true }, { wrongPackage: true }]) {
+ test(`registry errors and identity mismatches fail without waiting: ${JSON.stringify(fixture)}`, async () => {
+ await assert.rejects(waitForCloudArtifacts(sha, {
+ fetchImpl: registry(fixture), sleep: async () => assert.fail("must not retry an invalid artifact or upstream error"), log: () => {},
+ }));
+ });
+}
+
+test("invalid source and timing configuration are rejected before registry access", async () => {
+ const fetchImpl = async () => assert.fail("invalid inputs must not reach a registry");
+ await assert.rejects(waitForCloudArtifacts("master", { fetchImpl }), /full immutable commit SHA/);
+ for (const options of [{ timeoutMs: 0 }, { intervalMs: -1 }, { timeoutMs: Infinity }]) {
+ await assert.rejects(waitForCloudArtifacts(sha, { ...options, fetchImpl }), /positive finite/);
+ }
+});
+
+test("the versioned readiness job requires successful source, image and artifact jobs", () => {
+ const workflow = readFileSync(new URL("../../workflows/cloud-readiness.yml", import.meta.url), "utf8");
+ assert.match(workflow, /push:\s*\n\s*branches: \[master\]/);
+ assert.match(workflow, /group: cloud-readiness-\$\{\{ github.sha \}\}/);
+ assert.match(workflow, /uses: \.\/\.github\/workflows\/release-verify.yml\s+with:\s+ref: \$\{\{ github.sha \}\}/);
+ assert.match(workflow, /uses: \.\/\.github\/workflows\/docker-cloud.yml/);
+ const ready = workflow.split(" ready:")[1];
+ assert.match(ready, /name: Cloud deployable v1/);
+ assert.match(ready, /needs: \[verify, image, artifacts\]/);
+ assert.match(ready, /if: github.repository == 'paperclipai\/paperclip' && github.ref == 'refs\/heads\/master'/);
+ assert.doesNotMatch(ready, /^\s*(?:if:.*always\(|continue-on-error:)/m);
+ assert.doesNotMatch(workflow, /secrets: inherit|id-token: write|actions: write|checks: write|uses: .*@v\d\b/);
+ const cloud = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
+ assert.doesNotMatch(cloud, /^ push:/m, "the master image must build only once");
+ const migrator = readFileSync(new URL("../../workflows/cloud-artifacts.yml", import.meta.url), "utf8");
+ assert.match(migrator, /push:\s*\n\s*branches: \[master\]/);
+ assert.match(migrator, /SOURCE_SHA: \$\{\{ github.sha \}\}/);
+ assert.match(migrator, /gh workflow run release.yml .*--ref master/);
+ assert.match(migrator, /--field channel=cloud-migrator/);
+ assert.match(migrator, /--field source_ref="\$SOURCE_SHA"/);
+});
diff --git a/.github/scripts/tests/docker-disk-workflow.test.mjs b/.github/scripts/tests/docker-disk-workflow.test.mjs
new file mode 100644
index 0000000000..414af38cfd
--- /dev/null
+++ b/.github/scripts/tests/docker-disk-workflow.test.mjs
@@ -0,0 +1,65 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+
+const workflow = readFileSync(new URL("../../workflows/docker-cloud.yml", import.meta.url), "utf8");
+const step = workflow.split(" - name: Free runner disk")[1].split(" - name: Login to GitHub Container Registry")[0];
+const script = step.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
+const threshold = 64 * 1024 * 1024;
+
+for (const { name, dockerFree, workspaceFree, dfStatus = "0", infoStatus = "0", cleanup } of [
+ { name: "ample free space", dockerFree: threshold + 1, workspaceFree: threshold + 1, cleanup: false },
+ { name: "exactly the headroom threshold", dockerFree: threshold, workspaceFree: threshold, cleanup: false },
+ { name: "Docker filesystem below threshold", dockerFree: threshold - 1, workspaceFree: threshold + 1, cleanup: true },
+ { name: "workspace filesystem below threshold", dockerFree: threshold + 1, workspaceFree: threshold - 1, cleanup: true },
+ { name: "invalid Docker measurement", dockerFree: "unknown", workspaceFree: threshold + 1, cleanup: true },
+ { name: "invalid workspace measurement", dockerFree: threshold + 1, workspaceFree: "unknown", cleanup: true },
+ { name: "failed df command", dockerFree: threshold + 1, workspaceFree: threshold + 1, dfStatus: "1", cleanup: true },
+ { name: "failed Docker inspection", dockerFree: threshold + 1, workspaceFree: threshold + 1, infoStatus: "1", cleanup: true },
+]) {
+ test(`cloud disk cleanup: ${name}`, () => {
+ const dir = mkdtempSync(path.join(tmpdir(), "cloud-disk-test-"));
+ const log = path.join(dir, "commands.log");
+ // Every mutating command is a recording fixture; no real SDKs, caches,
+ // images, or directories are deleted when the workflow shell executes.
+ const fixture = `#!/bin/bash
+printf '%s %s\\n' "\${0##*/}" "$*" >> "$COMMAND_LOG"
+case "\${0##*/}" in
+ df)
+ printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\n'
+ if [ "$1" = '-Pk' ]; then
+ printf '/dev/docker 200000000 1 %s 1%% /docker\\n' "$DOCKER_FREE"
+ printf '/dev/workspace 200000000 1 %s 1%% /workspace\\n' "$WORKSPACE_FREE"
+ exit "$DF_STATUS"
+ fi
+ ;;
+ docker)
+ if [ "$1" = 'info' ]; then
+ printf '/docker-data\\n'
+ exit "$INFO_STATUS"
+ fi
+ ;;
+esac
+`;
+ try {
+ for (const command of ["df", "docker", "pnpm", "sudo"]) {
+ writeFileSync(path.join(dir, command), fixture, { mode: 0o755 });
+ }
+ const result = spawnSync("bash", ["-c", script], {
+ encoding: "utf8",
+ env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_WORKSPACE: "/workspace", COMMAND_LOG: log,
+ DOCKER_FREE: String(dockerFree), WORKSPACE_FREE: String(workspaceFree), DF_STATUS: dfStatus, INFO_STATUS: infoStatus },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ const commands = readFileSync(log, "utf8");
+ if (infoStatus === "0") assert.match(commands, /df -Pk \/docker-data \/workspace/);
+ assert.equal(commands.includes("pnpm store prune"), cleanup);
+ assert.equal(commands.includes("sudo rm -rf /usr/share/dotnet"), cleanup);
+ assert.equal(commands.includes("docker system prune -af"), cleanup);
+ assert.equal(result.stdout.includes("skipping cleanup"), !cleanup);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+ });
+}
diff --git a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs
index 7c19cf2cb9..e0e9775c04 100644
--- a/.github/scripts/tests/lockfile-refresh-workflows.test.mjs
+++ b/.github/scripts/tests/lockfile-refresh-workflows.test.mjs
@@ -6,6 +6,7 @@ const workflows = [
'.github/workflows/refresh-lockfile.yml',
'.github/workflows/pr-trusted.yml',
'.github/workflows/docker.yml',
+ '.github/workflows/docker-cloud.yml',
];
test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => {
diff --git a/.github/scripts/tests/release-runner-cache.test.mjs b/.github/scripts/tests/release-runner-cache.test.mjs
new file mode 100644
index 0000000000..9e4b8fe715
--- /dev/null
+++ b/.github/scripts/tests/release-runner-cache.test.mjs
@@ -0,0 +1,37 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8");
+const runner = workflow.split(" verify_paperclip_runner:")[1].split(" build:")[0];
+
+test("Runner dependency caching selects the package's pinned compiler before computing its key", () => {
+ const select = runner.indexOf(" - name: Select the pinned Runner Rust toolchain");
+ const cache = runner.indexOf(" - name: Cache Runner Rust dependencies");
+ assert.ok(select >= 0 && cache > select);
+ const setup = runner.slice(select, cache);
+ assert.match(setup, /working-directory: packages\/paperclip-runner/);
+ assert.match(setup, /rustup show active-toolchain/);
+ assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/);
+ assert.match(runner, /uses: Swatinem\/rust-cache@[0-9a-f]{40} # v[0-9.]+/);
+ assert.match(runner, /workspaces: packages\/paperclip-runner\/runner -> target/);
+ assert.match(runner, /shared-key: release-runner-v1/);
+});
+
+test("the shared cache excludes workspace artifacts and only restores or saves the exact master-push source", () => {
+ assert.match(runner, /cache-workspace-crates: false/);
+ assert.match(runner, /cache-bin: false/);
+ const saveIf = runner.match(/^\s*save-if: (.+)$/m)?.[1];
+ assert.equal(saveIf, "${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}");
+ const cacheStep = runner.split(" - name: Cache Runner Rust dependencies")[1].split(" - name: Install dependencies")[0];
+ assert.equal(cacheStep.match(/^\s*if: (.+)$/m)?.[1], saveIf);
+ assert.doesNotMatch(runner, /cache-on-failure: true|cache-all-crates: true/);
+});
+
+test("cache hits cannot bypass Runner verification", () => {
+ const verify = runner.split(" - name: Verify Paperclip Runner")[1];
+ assert.match(verify, /run: pnpm --filter @paperclipai\/paperclip-runner check:all/);
+ assert.doesNotMatch(verify, /if:|continue-on-error:/);
+ assert.ok(runner.indexOf("Cache Runner Rust dependencies") < runner.indexOf(" - name: Verify Paperclip Runner\n"));
+ assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/);
+});
diff --git a/.github/scripts/verify-storybook.cjs b/.github/scripts/verify-storybook.cjs
new file mode 100644
index 0000000000..fd70299f6a
--- /dev/null
+++ b/.github/scripts/verify-storybook.cjs
@@ -0,0 +1,26 @@
+const { branchIndex } = require('./storybook-destination.cjs');
+
+async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ const [metadata, index] = await Promise.all([
+ fetch(new URL('deployment.json', buildUrl), { signal: AbortSignal.timeout(15000) }),
+ fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
+ ]);
+ if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
+ if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
+ if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
+ return;
+ } catch (error) {
+ if (attempt === attempts) throw error;
+ await sleep(10000);
+ }
+ }
+}
+
+module.exports = { verifyStorybook };
+if (require.main === module) {
+ verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
+ sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
+}
diff --git a/.github/storybook-deploy/cloudfront-read-statement.json b/.github/storybook-deploy/cloudfront-read-statement.json
new file mode 100644
index 0000000000..9294a97287
--- /dev/null
+++ b/.github/storybook-deploy/cloudfront-read-statement.json
@@ -0,0 +1,10 @@
+{
+ "Sid": "AllowCloudFrontReadStorybook",
+ "Effect": "Allow",
+ "Principal": {"Service": "cloudfront.amazonaws.com"},
+ "Action": "s3:GetObject",
+ "Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*",
+ "Condition": {"StringEquals": {
+ "AWS:SourceArn": "arn:aws:cloudfront::078455283791:distribution/E3GTU28BBO2SFR"
+ }}
+}
diff --git a/.github/storybook-deploy/trust-policy.json b/.github/storybook-deploy/trust-policy.json
new file mode 100644
index 0000000000..dd4e400d9b
--- /dev/null
+++ b/.github/storybook-deploy/trust-policy.json
@@ -0,0 +1,12 @@
+{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Principal": {"Federated": "arn:aws:iam::078455283791:oidc-provider/token.actions.githubusercontent.com"},
+ "Action": "sts:AssumeRoleWithWebIdentity",
+ "Condition": {"StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
+ "token.actions.githubusercontent.com:sub": "repo:paperclipai/paperclip:environment:storybook-deploy"
+ }}
+ }]
+}
diff --git a/.github/storybook-deploy/upload-policy.json b/.github/storybook-deploy/upload-policy.json
new file mode 100644
index 0000000000..a18edf3699
--- /dev/null
+++ b/.github/storybook-deploy/upload-policy.json
@@ -0,0 +1,8 @@
+{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
+ "Resource": "arn:aws:s3:::paperclipai-runner-e2e-history-078455283791-us-east-1/storybook/branches/*"
+ }]
+}
diff --git a/.github/workflows/cloud-artifacts.yml b/.github/workflows/cloud-artifacts.yml
new file mode 100644
index 0000000000..53d8ab710b
--- /dev/null
+++ b/.github/workflows/cloud-artifacts.yml
@@ -0,0 +1,34 @@
+name: Cloud artifacts
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions: {}
+
+jobs:
+ dispatch_migrator:
+ name: Start exact-source cloud migrator publication
+ if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ actions: write
+ steps:
+ # This separate workflow starts at merge, outside the full npm release's
+ # concurrency group. Publication stays in release.yml so npm recognizes
+ # the established trusted-publisher identity and npm-canary environment.
+ # No source checkout or package code runs with the dispatch credential.
+ - name: Dispatch the migrator-only release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ SOURCE_SHA: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ request_id="$(cat /proc/sys/kernel/random/uuid)"
+ gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref master \
+ --field channel=cloud-migrator \
+ --field source_ref="$SOURCE_SHA" \
+ --field request_id="$request_id"
+ echo "Started Cloud migrator $SOURCE_SHA in release.yml (request $request_id)." >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/cloud-readiness.yml b/.github/workflows/cloud-readiness.yml
new file mode 100644
index 0000000000..df6c1dac61
--- /dev/null
+++ b/.github/workflows/cloud-readiness.yml
@@ -0,0 +1,66 @@
+name: Cloud readiness
+run-name: Cloud readiness ${{ github.sha }}
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions: {}
+
+# Source verification must start outside the full npm release's queue.
+concurrency:
+ group: cloud-readiness-${{ github.sha }}
+ cancel-in-progress: false
+
+jobs:
+ image:
+ if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
+ permissions:
+ contents: read
+ packages: write
+ uses: ./.github/workflows/docker-cloud.yml
+
+ verify:
+ if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
+ permissions:
+ contents: read
+ uses: ./.github/workflows/release-verify.yml
+ with:
+ ref: ${{ github.sha }}
+
+ artifacts:
+ if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
+ name: Wait for exact-source cloud artifacts
+ runs-on: ubuntu-latest
+ timeout-minutes: 35
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 24
+ - name: Wait for verified image and exact-source migrator
+ env:
+ SOURCE_SHA: ${{ github.sha }}
+ run: node scripts/cloud-readiness.mjs "$SOURCE_SHA"
+
+ ready:
+ # Versioned consumer contract. Never add always() or continue-on-error:
+ # failed, cancelled, or skipped prerequisites must not report readiness.
+ name: Cloud deployable v1
+ needs: [verify, image, artifacts]
+ if: github.repository == 'paperclipai/paperclip' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Record cloud readiness
+ env:
+ SOURCE_SHA: ${{ github.sha }}
+ run: |
+ echo "Cloud deployable v1: $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
+ echo "Source verification passed; the full-SHA image and exact-source migrator are available." >> "$GITHUB_STEP_SUMMARY"
+ echo "Deployment tooling must still resolve and pin the image and migrator and validate migration compatibility." >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml
new file mode 100644
index 0000000000..14fe69f87f
--- /dev/null
+++ b/.github/workflows/docker-cloud.yml
@@ -0,0 +1,296 @@
+name: Docker cloud
+
+on:
+ workflow_dispatch:
+ workflow_call:
+
+permissions: {}
+
+# Independent SHAs can build immediately on separate existing hosted runners.
+# Repeated requests for the same source serialize without cancelling a build.
+# No mutable canary channel is promoted here; docker.yml owns that operation.
+concurrency:
+ group: docker-cloud-${{ github.sha }}
+ cancel-in-progress: false
+
+jobs:
+ build-and-push-cloud:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ # Full history and tags so `git describe` below can compute the
+ # release version to stamp into the image.
+ fetch-depth: 0
+
+ # `.git` is dockerignored, so a running image cannot derive its own
+ # version and otherwise reports the source package.json placeholder in
+ # analytics and the debug panel. Compute it here from the pristine
+ # checkout (real CalVer drift from the nearest release tag) and pass it
+ # into the build. Empty when no release tag is reachable — the server
+ # then keeps its existing fallbacks.
+ - name: Compute build version
+ id: build-version
+ run: |
+ set -euo pipefail
+ case "${GITHUB_REF}" in
+ refs/tags/nightly/v*)
+ # Lane tags carry the exact published version; stamp it verbatim
+ # instead of describing drift from the nearest stable tag.
+ version="${GITHUB_REF#refs/tags/nightly/v}"
+ ;;
+ refs/tags/beta/v*)
+ version="${GITHUB_REF#refs/tags/beta/v}"
+ ;;
+ *)
+ version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
+ ;;
+ esac
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+ echo "Stamping build version: ${version:-}"
+
+ # ISO week stamp for the Dockerfile's tool layer: the layer caches
+ # across commits and re-pulls the @latest CLI tools when the week rolls
+ # over, instead of on every build.
+ - name: Compute tool cache epoch
+ id: tools-epoch
+ run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
+
+ # Each SHA exports its own cache. Import recent first-parent caches so
+ # a late older build cannot overwrite a newer build's cache manifest.
+ # The legacy ref keeps the first builds warm during the transition.
+ - name: Select cloud cache ancestry
+ id: cloud-cache
+ env:
+ CACHE_IMAGE: ghcr.io/${{ github.repository }}
+ run: |
+ set -euo pipefail
+ {
+ echo 'sources<> "$GITHUB_OUTPUT"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+ with:
+ version: 9.15.4
+ run_install: false
+
+ # No dependency cache here: this workflow publishes release images, and
+ # restoring a shared Actions cache into the build inputs would let a
+ # poisoned cache entry reach the published artifact.
+ - name: Setup Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 24
+
+ - name: Refresh lockfile for Docker build context
+ run: |
+ set -euo pipefail
+ pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
+
+ changed="$(git status --porcelain)"
+ if [ -z "$changed" ]; then
+ echo "Lockfile already matches package metadata."
+ exit 0
+ fi
+
+ if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
+ echo "Unexpected files changed during lockfile refresh:"
+ echo "$changed"
+ exit 1
+ fi
+
+ echo "Using refreshed pnpm-lock.yaml in the Docker build context."
+
+ - name: Free runner disk
+ run: |
+ set -euo pipefail
+ echo "Disk before cleanup:"
+ df -h
+
+ # A measured hosted cloud build started with 86 GB available.
+ # Keep ample headroom for BuildKit and image verification, but
+ # avoid minutes deleting SDKs when neither filesystem needs space.
+ minimum_free_kib=$((64 * 1024 * 1024))
+ if docker_root="$(docker info --format '{{.DockerRootDir}}')" \
+ && available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \
+ && [[ "$available_kib" =~ ^[0-9]+$ ]] \
+ && (( available_kib >= minimum_free_kib )); then
+ echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup."
+ exit 0
+ fi
+
+ pnpm store prune || true
+ sudo apt-get clean || true
+ sudo rm -rf \
+ /usr/share/dotnet \
+ /usr/share/swift \
+ /usr/local/lib/android \
+ /usr/local/share/boost \
+ /usr/local/share/powershell \
+ /opt/ghc \
+ /opt/hostedtoolcache/CodeQL \
+ /opt/hostedtoolcache/PyPy \
+ /opt/hostedtoolcache/Ruby || true
+ docker system prune -af || true
+
+ echo "Disk after cleanup:"
+ df -h
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+
+ # Deployment tooling reads these labels from the registry to verify an
+ # image's schema expectations against a migrator before deploying it,
+ # without pulling the image. The server refuses to start when the
+ # database is missing bundled migrations, so orchestrators need a cheap
+ # way to check image/migrator compatibility up front.
+ - name: Compute schema migration labels
+ id: schema
+ run: |
+ set -euo pipefail
+ last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
+ count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
+ echo "last=${last}" >> "$GITHUB_OUTPUT"
+ echo "count=${count}" >> "$GITHUB_OUTPUT"
+
+ # Published under the same lane tag set as the self-hosted image, with a
+ # `-cloud` suffix (nightly-cloud, latest-cloud, -cloud,
+ # sha--cloud). `:canary-cloud` follows the same retag-step
+ # ownership rule as `:canary` above.
+ - name: Docker meta (cloud)
+ id: meta-cloud
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ with:
+ images: ghcr.io/${{ github.repository }}
+ flavor: |
+ suffix=-cloud,onlatest=true
+ tags: |
+ type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
+ type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ 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
+ labels: |
+ io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
+ io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
+
+ - name: Build and push (cloud)
+ id: build-cloud
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ target: cloud
+ # Space-separated sandbox-provider directory names to build into
+ # the variant; add here when managed deployments need another.
+ # CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
+ # variant installs from server/package.json's declared version;
+ # add another name there when a managed tenant needs it.
+ build-args: |
+ USER_UID=1001
+ USER_GID=1001
+ CLOUD_BUNDLED_PLUGINS=daytona
+ CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
+ PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
+ PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
+ CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
+ # amd64 only, unlike the self-hosted image above: the cloud variant
+ # is consumed exclusively by managed-deployment hosts, which run
+ # amd64. The QEMU-emulated arm64 half dominated this job's wall
+ # clock, and dropping it roughly halves time-to-deployable-image.
+ platforms: linux/amd64
+ push: true
+ # Same-SHA builds serialize above; different SHAs never share a
+ # writable cache ref. Registry layers are content-addressed and
+ # shared even when cache manifests have separate tags.
+ cache-from: ${{ steps.cloud-cache.outputs.sources }}
+ cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max
+ tags: ${{ steps.meta-cloud.outputs.tags }}
+ labels: ${{ steps.meta-cloud.outputs.labels }}
+
+ # The cloud target installs @sentry/node at the version
+ # server/package.json declares, into a directory the server's own
+ # module resolution walks. Verify the image this job just pushed, not
+ # a local build, so a build-cache or layer-ordering regression is
+ # caught before any tenant runs the image.
+
+ - name: Verify the pushed image resolves the declared Sentry version
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
+ run: |
+ set -euo pipefail
+
+ expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
+ test -n "$expected"
+
+ installed="$(docker run --rm --pull always \
+ -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
+ --entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)"
+
+ echo "Declared optional peer version: $expected"
+ echo "Installed in the pushed image: $installed"
+ if [ "$installed" != "$expected" ]; then
+ echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
+ exit 1
+ fi
+ echo "The pushed image resolves the declared @sentry/node version."
+
+ # Managed hosts run node as 1001:1001. Bake that identity into the image
+ # so usermod does not walk the mounted home on every container start.
+ # Check before the entrypoint can repair a wrongly built identity.
+ - name: Verify cloud runtime user
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
+ run: |
+ set -euo pipefail
+ docker run --rm --entrypoint sh "$IMAGE" -ec '
+ test "$(id -u node)" = 1001
+ test "$(id -g node)" = 1001
+ test "$USER_UID" = 1001
+ test "$USER_GID" = 1001
+ '
+ docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec '
+ test "$(id -u)" = 1001
+ test "$(id -g)" = 1001
+ test -w "$PAPERCLIP_HOME"
+ '
+
+ # Verify the independently published cloud image without waiting for
+ # the self-hosted manifest job. The Sentry check already pulled it.
+ - name: Verify cloud PID 1 reaps orphaned processes
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
+ run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh
+
+ # Cloud's commit resolver and preview-artifact planner use the full SHA.
+ # Publish that address only after checking this build's exact digest.
+ # Retagging reuses the registry manifest and does not rebuild the image.
+ - name: Publish verified full-SHA cloud tag
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }}
+ FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud
+ run: |
+ set -euo pipefail
+ revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
+ platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')"
+ test "$revision" = "$GITHUB_SHA"
+ test "$platform" = linux/amd64
+ docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"
diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml
new file mode 100644
index 0000000000..e9705c3b5e
--- /dev/null
+++ b/.github/workflows/docker-runner-check.yml
@@ -0,0 +1,37 @@
+name: Docker Runner check
+
+on:
+ pull_request:
+ paths:
+ - .github/workflows/docker-runner-check.yml
+ - Dockerfile
+ - .dockerignore
+ - packages/paperclip-runner/rust-toolchain.toml
+ - packages/paperclip-runner/runner/**
+ - packages/paperclip-runner/protocol/**
+
+permissions: {}
+
+concurrency:
+ group: docker-runner-check-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ runner:
+ name: Compile isolated native Runner
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+ # Compile the real target with the real .dockerignore. This catches new
+ # Cargo or embedded protocol inputs that the isolated COPY set omits.
+ # No registry credentials, cache imports/exports, or image publication.
+ - name: Compile the Runner from its isolated Docker context
+ run: docker buildx build --target runner-build --progress plain .
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 19e9f37935..7f76a3894d 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -349,8 +349,7 @@ jobs:
# until the cgroup pid limit is exhausted and every fork() in the
# container fails. Run against the pushed manifest rather than a local
# build: the legs push by digest, so nothing is loaded into this
- # runner's daemon. The cloud variant is FROM production and inherits the
- # same ENTRYPOINT, so checking this image covers both.
+ # runner's daemon. The independent cloud workflow checks its own image.
- name: Verify PID 1 reaps orphaned processes
env:
# Through the environment, not interpolated into the script body, so
@@ -363,220 +362,15 @@ jobs:
echo "Verifying orphan reaping in $image"
docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh
- # The cloud variant carries built bundled plugins for managed deployments
- # (see the `cloud` stage in the Dockerfile). It runs as its own job with no
- # `needs:` on the stock publish above, so the two builds run in parallel and
- # a failure or slow build in one never gates, delays, or skips the other.
- # Both jobs share only the single top-level concurrency slot. Each job is a
- # separate runner, so this one carries its own copy of the prep steps
- # (checkout through schema labels) — the accepted cost of that isolation.
+ # 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.
build-and-push-cloud:
- runs-on: ubuntu-latest
- timeout-minutes: 60
+ if: github.event_name != 'push' || github.ref != 'refs/heads/master'
+ uses: ./.github/workflows/docker-cloud.yml
permissions:
contents: read
packages: write
- steps:
- - name: Checkout
- uses: actions/checkout@v7
- with:
- # Full history and tags so `git describe` below can compute the
- # release version to stamp into the image.
- fetch-depth: 0
-
- # `.git` is dockerignored, so a running image cannot derive its own
- # version and otherwise reports the source package.json placeholder in
- # analytics and the debug panel. Compute it here from the pristine
- # checkout (real CalVer drift from the nearest release tag) and pass it
- # into the build. Empty when no release tag is reachable — the server
- # then keeps its existing fallbacks.
- - name: Compute build version
- id: build-version
- run: |
- set -euo pipefail
- case "${GITHUB_REF}" in
- refs/tags/nightly/v*)
- # Lane tags carry the exact published version; stamp it verbatim
- # instead of describing drift from the nearest stable tag.
- version="${GITHUB_REF#refs/tags/nightly/v}"
- ;;
- refs/tags/beta/v*)
- version="${GITHUB_REF#refs/tags/beta/v}"
- ;;
- *)
- version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
- ;;
- esac
- echo "version=${version}" >> "$GITHUB_OUTPUT"
- echo "Stamping build version: ${version:-}"
-
- # ISO week stamp for the Dockerfile's tool layer: the layer caches
- # across commits and re-pulls the @latest CLI tools when the week rolls
- # over, instead of on every build.
- - name: Compute tool cache epoch
- id: tools-epoch
- run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
- with:
- version: 9.15.4
- run_install: false
-
- # No dependency cache here: this workflow publishes release images, and
- # restoring a shared Actions cache into the build inputs would let a
- # poisoned cache entry reach the published artifact.
- - name: Setup Node.js
- uses: actions/setup-node@v7
- with:
- node-version: 24
-
- - name: Refresh lockfile for Docker build context
- run: |
- set -euo pipefail
- pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
-
- changed="$(git status --porcelain)"
- if [ -z "$changed" ]; then
- echo "Lockfile already matches package metadata."
- exit 0
- fi
-
- if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
- echo "Unexpected files changed during lockfile refresh:"
- echo "$changed"
- exit 1
- fi
-
- echo "Using refreshed pnpm-lock.yaml in the Docker build context."
-
- - name: Free runner disk
- run: |
- set -euo pipefail
- echo "Disk before cleanup:"
- df -h
-
- pnpm store prune || true
- sudo apt-get clean || true
- sudo rm -rf \
- /usr/share/dotnet \
- /usr/share/swift \
- /usr/local/lib/android \
- /usr/local/share/boost \
- /usr/local/share/powershell \
- /opt/ghc \
- /opt/hostedtoolcache/CodeQL \
- /opt/hostedtoolcache/PyPy \
- /opt/hostedtoolcache/Ruby || true
- docker system prune -af || true
-
- echo "Disk after cleanup:"
- df -h
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v4
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
-
- # Deployment tooling reads these labels from the registry to verify an
- # image's schema expectations against a migrator before deploying it,
- # without pulling the image. The server refuses to start when the
- # database is missing bundled migrations, so orchestrators need a cheap
- # way to check image/migrator compatibility up front.
- - name: Compute schema migration labels
- id: schema
- run: |
- set -euo pipefail
- last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
- count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
- echo "last=${last}" >> "$GITHUB_OUTPUT"
- echo "count=${count}" >> "$GITHUB_OUTPUT"
-
- # Published under the same lane tag set as the self-hosted image, with a
- # `-cloud` suffix (nightly-cloud, latest-cloud, -cloud,
- # sha--cloud). `:canary-cloud` follows the same retag-step
- # ownership rule as `:canary` above.
- - name: Docker meta (cloud)
- id: meta-cloud
- uses: docker/metadata-action@v6
- with:
- images: ghcr.io/${{ github.repository }}
- flavor: |
- suffix=-cloud,onlatest=true
- tags: |
- type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
- type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- 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
- labels: |
- io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
- io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
-
- - name: Build and push (cloud)
- uses: docker/build-push-action@v7
- with:
- context: .
- target: cloud
- # Space-separated sandbox-provider directory names to build into
- # the variant; add here when managed deployments need another.
- # CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the
- # variant installs from server/package.json's declared version;
- # add another name there when a managed tenant needs it.
- build-args: |
- CLOUD_BUNDLED_PLUGINS=daytona
- CLOUD_BUNDLED_SERVER_DEPS=@sentry/node
- PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
- PAPERCLIP_BUILD_COMMIT=${{ github.sha }}
- CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
- # amd64 only, unlike the self-hosted image above: the cloud variant
- # is consumed exclusively by managed-deployment hosts, which run
- # amd64. The QEMU-emulated arm64 half dominated this job's wall
- # clock, and dropping it roughly halves time-to-deployable-image.
- platforms: linux/amd64
- push: true
- # Registry-backed BuildKit cache, separate ref from the self-hosted
- # job so the two parallel builds never clobber each other's cache
- # manifest (see the rationale on the job above).
- cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud
- cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud,mode=max
- tags: ${{ steps.meta-cloud.outputs.tags }}
- labels: ${{ steps.meta-cloud.outputs.labels }}
-
- # The cloud target installs @sentry/node at the version
- # server/package.json declares, into a directory the server's own
- # module resolution walks. Verify the image this job just pushed, not
- # a local build, so a build-cache or layer-ordering regression is
- # caught before any tenant runs the image.
-
- - name: Verify the pushed image resolves the declared Sentry version
- env:
- IMAGE_TAGS: ${{ steps.meta-cloud.outputs.tags }}
- run: |
- set -euo pipefail
- image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)"
- test -n "$image"
-
- expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")"
- test -n "$expected"
-
- installed="$(docker run --rm --pull always \
- -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
- --entrypoint node "$image" /app/server/.ci-sentry-probe.mjs)"
-
- echo "Declared optional peer version: $expected"
- echo "Installed in the pushed image: $installed"
- if [ "$installed" != "$expected" ]; then
- echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2
- exit 1
- fi
- echo "The pushed image resolves the declared @sentry/node version."
# Moves the mutable `:canary` / `:canary-cloud` channel tags. Kept OUT
# of the build jobs and serialized in its own lane, and — the load-
diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml
index 7193f38c84..79adee9229 100644
--- a/.github/workflows/release-verify.yml
+++ b/.github/workflows/release-verify.yml
@@ -58,16 +58,39 @@ jobs:
fail-fast: false
matrix:
include:
- - group: general-server
- group_label: server (1/3)
+ # Split the long chat file by collected test locations, and balance
+ # the remaining server files across five runners. Normal PR/local
+ # invocations retain their complete general-server group.
+ - group: general-server-without-chat
+ group_label: server (1/5)
+ shard_index: 0
+ shard_count: 5
+ - group: general-server-without-chat
+ group_label: server (2/5)
+ shard_index: 1
+ shard_count: 5
+ - group: general-server-without-chat
+ group_label: server (3/5)
+ shard_index: 2
+ shard_count: 5
+ - group: general-server-without-chat
+ group_label: server (4/5)
+ shard_index: 3
+ shard_count: 5
+ - group: general-server-without-chat
+ group_label: server (5/5)
+ shard_index: 4
+ shard_count: 5
+ - group: general-chat
+ group_label: chat (1/3)
shard_index: 0
shard_count: 3
- - group: general-server
- group_label: server (2/3)
+ - group: general-chat
+ group_label: chat (2/3)
shard_index: 1
shard_count: 3
- - group: general-server
- group_label: server (3/3)
+ - group: general-chat
+ group_label: chat (3/3)
shard_index: 2
shard_count: 3
# Keep parity with pr.yml: workspaces-a is split with Vitest's
@@ -215,6 +238,30 @@ jobs:
node-version: 24
cache: pnpm
+ - name: Select the pinned Runner Rust toolchain
+ working-directory: packages/paperclip-runner
+ run: |
+ set -euo pipefail
+ rustup show
+ toolchain="$(rustup show active-toolchain | awk '{print $1}')"
+ echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV"
+
+ - name: Cache Runner Rust dependencies
+ # Restore and save only within trusted master-push verification. GitHub
+ # isolates branch/PR caches from master; other callers compile afresh.
+ if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
+ uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ with:
+ workspaces: packages/paperclip-runner/runner -> target
+ shared-key: release-runner-v1
+ # Rebuild workspace code and rerun every check. Cache only compiled
+ # dependencies; never restore installed executables from cargo/bin.
+ cache-workspace-crates: false
+ cache-bin: false
+ # The step guard also restricts restores. Save only after a successful
+ # master-push verification of that push's exact commit.
+ save-if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
+
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4edc35b836..2f510813fe 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,5 +1,5 @@
name: Release
-run-name: ${{ inputs.channel == 'preview' && format('Stack deploy {0} build', inputs.request_id) || 'Release' }}
+run-name: ${{ inputs.channel == 'preview' && format('Stack deploy {0} build', inputs.request_id) || inputs.channel == 'cloud-migrator' && format('Cloud migrator {0}', inputs.source_ref) || 'Release' }}
on:
push:
@@ -19,14 +19,15 @@ on:
- beta
- nightly
- preview
+ - cloud-migrator
default: stable
source_ref:
- description: Stable source ref, or full immutable SHA for a preview build
+ description: Stable source ref, or full immutable SHA for a preview or cloud migrator build
required: true
type: string
default: master
request_id:
- description: (preview) CLI correlation UUID
+ description: (preview/cloud-migrator) Correlation UUID
type: string
default: ""
preview_migrator:
@@ -56,7 +57,7 @@ on:
default: false
concurrency:
- group: ${{ inputs.channel == 'preview' && format('preview-{0}', inputs.source_ref) || format('release-{0}-{1}', github.event_name, github.ref) }}
+ group: ${{ (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && format('{0}-{1}', inputs.channel, inputs.source_ref) || format('release-{0}-{1}', github.event_name, github.ref) }}
cancel-in-progress: false
env:
@@ -76,7 +77,7 @@ env:
jobs:
plan_preview:
name: Check preview artifacts
- if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && inputs.channel == 'preview' && !inputs.dry_run
+ if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: read
@@ -96,7 +97,8 @@ jobs:
SOURCE_SHA: ${{ inputs.source_ref }}
REQUEST_ID: ${{ inputs.request_id }}
PREVIEW_MIGRATOR: ${{ inputs.preview_migrator }}
- run: node scripts/preview-artifacts.mjs plan "$SOURCE_SHA" "$REQUEST_ID" "$PREVIEW_MIGRATOR"
+ PLAN_COMMAND: ${{ inputs.channel == 'cloud-migrator' && 'plan-migrator' || 'plan' }}
+ run: node scripts/preview-artifacts.mjs "$PLAN_COMMAND" "$SOURCE_SHA" "$REQUEST_ID" "$PREVIEW_MIGRATOR"
package_preview:
name: Build preview migrator
@@ -144,6 +146,12 @@ jobs:
if: github.ref == 'refs/heads/master' && needs.plan_preview.outputs.packages == 'true' && needs.package_preview.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
+ # A manual preview and a merge-triggered migrator may compile in parallel.
+ # Serialize only publication so they cannot race an immutable npm version,
+ # without making the migrator wait for a preview's separate image build.
+ concurrency:
+ group: preview-package-publish-${{ inputs.source_ref }}
+ cancel-in-progress: false
# Reuse release.yml's established npm trusted-publisher identity. This job
# publishes only isolated preview versions; it cannot advance lane tags.
environment: npm-canary
@@ -260,7 +268,7 @@ jobs:
name: Verify preview artifacts
needs: [plan_preview, image_preview, publish_image_preview, package_preview, publish_preview]
if: >-
- always() && needs.plan_preview.result == 'success' &&
+ always() && inputs.channel == 'preview' && needs.plan_preview.result == 'success' &&
(needs.publish_image_preview.result == 'success' || needs.plan_preview.outputs.image == 'false') &&
(needs.publish_preview.result == 'success' || needs.plan_preview.outputs.packages == 'false')
runs-on: ubuntu-latest
diff --git a/.github/workflows/runner-chaos-evals.yml b/.github/workflows/runner-chaos-evals.yml
index 3131959522..42f8d6f307 100644
--- a/.github/workflows/runner-chaos-evals.yml
+++ b/.github/workflows/runner-chaos-evals.yml
@@ -12,7 +12,9 @@ on:
type: string
concurrency:
- group: runner-chaos-evals-${{ inputs.ref || github.ref }}
+ # Reusable calls inherit the caller's workflow name. Cloud readiness and
+ # Release verify the same SHA independently and must not cancel each other.
+ group: runner-chaos-evals-${{ github.workflow }}-${{ inputs.ref || github.ref }}
cancel-in-progress: true
jobs:
diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml
new file mode 100644
index 0000000000..a485d033b5
--- /dev/null
+++ b/.github/workflows/storybook-deploy.yml
@@ -0,0 +1,169 @@
+name: Storybook Deploy
+
+on:
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: "Repository branch to publish (empty uses the selected workflow branch)"
+ type: string
+ default: ""
+ # Also exposed by Storybook Visual, which is already available on master.
+ workflow_call:
+ inputs:
+ branch:
+ type: string
+ default: ""
+
+permissions:
+ contents: read
+
+jobs:
+ authorize:
+ name: Authorize Storybook publisher
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ sha: ${{ steps.source.outputs.sha }}
+ branch: ${{ steps.source.outputs.branch }}
+ branch_key: ${{ steps.source.outputs.branch_key }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ - name: Require CODEOWNER initiator and rerunner
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
+ await authorize({ github, context });
+
+ - name: Pin requested repository branch
+ id: source
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ SOURCE_BRANCH: ${{ inputs.branch }}
+ STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
+ STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
+ with:
+ script: |
+ const branch = process.env.SOURCE_BRANCH || context.ref.slice('refs/heads/'.length);
+ const { data } = await github.rest.git.getRef({ ...context.repo, ref: `heads/${branch}` });
+ if (data.ref !== `refs/heads/${branch}` || data.object.type !== 'commit') {
+ throw new Error('Select an existing branch in this repository.');
+ }
+ // With no source override, preserve the exact dispatched commit.
+ const sha = process.env.SOURCE_BRANCH ? data.object.sha : context.sha;
+ const { storybookDestination } = require('./.github/scripts/storybook-destination.cjs');
+ const destination = storybookDestination({ branch, sha,
+ runId: context.runId, runAttempt: process.env.GITHUB_RUN_ATTEMPT,
+ bucket: process.env.STORYBOOK_S3_BUCKET, baseUrl: process.env.STORYBOOK_PUBLIC_BASE_URL });
+ core.setOutput('sha', sha);
+ core.setOutput('branch_key', destination.branchKey);
+ core.setOutput('branch', branch);
+
+ build:
+ name: Build selected branch Storybook
+ permissions: {}
+ needs: authorize
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ outputs:
+ artifact_name: ${{ steps.artifact.outputs.name }}
+ env:
+ STORYBOOK_DISABLE_TELEMETRY: "1"
+ steps:
+ - name: Download public source without repository credentials
+ env:
+ SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
+ run: |
+ [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
+ curl --fail --silent --show-error --location --retry 3 \
+ "https://codeload.github.com/paperclipai/paperclip/tar.gz/$SOURCE_SHA" \
+ --output "$RUNNER_TEMP/source.tar.gz"
+ tar -xzf "$RUNNER_TEMP/source.tar.gz" --strip-components=1
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
+ with:
+ version: 9.15.4
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ with:
+ node-version: 24
+ package-manager-cache: false
+ - run: pnpm install --frozen-lockfile --ignore-scripts
+ - run: pnpm build-storybook
+ - name: Record source and validate output
+ id: artifact
+ env:
+ SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
+ SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
+ run: |
+ test -s ui/storybook-static/index.html
+ test -s ui/storybook-static/iframe.html
+ test -s ui/storybook-static/index.json
+ jq -n --arg sha "$SOURCE_SHA" --arg branch "$SOURCE_BRANCH" \
+ '{sha: $sha, branch: $branch}' > ui/storybook-static/deployment.json
+ echo "name=storybook-deploy-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: ${{ steps.artifact.outputs.name }}
+ path: ui/storybook-static
+ if-no-files-found: error
+ retention-days: 7
+
+ deploy:
+ name: Publish branch Storybook to S3
+ needs: [authorize, build]
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ concurrency:
+ group: storybook-deploy-${{ needs.authorize.outputs.branch_key }}
+ cancel-in-progress: false
+ permissions:
+ contents: read
+ id-token: write
+ environment:
+ name: storybook-deploy
+ url: ${{ steps.deployment.outputs.url }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+ sparse-checkout: .github/scripts
+ - name: Recheck CODEOWNER access before publishing
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const authorize = require('./.github/scripts/authorize-storybook-deploy.cjs');
+ await authorize({ github, context });
+ - name: Download the successful build artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: ${{ needs.build.outputs.artifact_name }}
+ path: storybook-static
+ - name: Assume the Storybook-only uploader role
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6
+ with:
+ role-to-assume: ${{ vars.STORYBOOK_AWS_ROLE_ARN }}
+ aws-region: ${{ vars.STORYBOOK_AWS_REGION }}
+ role-duration-seconds: 900
+ - name: Publish this branch preview
+ id: deployment
+ env:
+ SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
+ SOURCE_BRANCH: ${{ needs.authorize.outputs.branch }}
+ STORYBOOK_S3_BUCKET: ${{ vars.STORYBOOK_S3_BUCKET }}
+ STORYBOOK_PUBLIC_BASE_URL: ${{ vars.STORYBOOK_PUBLIC_BASE_URL }}
+ run: node .github/scripts/publish-storybook.cjs
+ - name: Verify public build and stable branch URL
+ env:
+ BUILD_URL: ${{ steps.deployment.outputs.build_url }}
+ BRANCH_URL: ${{ steps.deployment.outputs.url }}
+ SOURCE_SHA: ${{ needs.authorize.outputs.sha }}
+ run: node .github/scripts/verify-storybook.cjs
+ - name: Upload deployment links
+ if: ${{ !cancelled() && steps.deployment.outcome == 'success' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: storybook-deployment-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ steps.deployment.outputs.report_path }}
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/storybook-visual.yml b/.github/workflows/storybook-visual.yml
index 9e7b60c132..4b3773615c 100644
--- a/.github/workflows/storybook-visual.yml
+++ b/.github/workflows/storybook-visual.yml
@@ -3,6 +3,16 @@ name: Storybook Visual
on:
workflow_dispatch:
inputs:
+ branch:
+ description: "Repository branch to publish (deployment only; empty uses the selected workflow branch)"
+ required: false
+ type: string
+ default: ""
+ deploy_preview:
+ description: "Publish this branch to S3/CloudFront instead of running visual tests (CODEOWNERS only)"
+ required: false
+ type: boolean
+ default: false
update_snapshots:
description: "Generate updated snapshots and a baseline review bundle"
required: false
@@ -18,7 +28,7 @@ on:
- labeled
concurrency:
- group: storybook-visual-${{ github.event.pull_request.number || github.ref }}
+ group: storybook-visual-${{ inputs.deploy_preview && github.run_id || 'visual' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
@@ -28,7 +38,7 @@ jobs:
visual:
name: Storybook visual regression
if: >-
- github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'workflow_dispatch' && !inputs.deploy_preview) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual'))
runs-on: ubuntu-latest
timeout-minutes: 35
@@ -100,3 +110,13 @@ jobs:
path: tests/storybook-visual/baseline-review/snapshots.tgz
retention-days: 30
if-no-files-found: error
+
+ preview:
+ name: Deploy selected branch Storybook
+ if: github.event_name == 'workflow_dispatch' && inputs.deploy_preview
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/storybook-deploy.yml
+ with:
+ branch: ${{ inputs.branch }}
diff --git a/Dockerfile b/Dockerfile
index b51a2cfa97..b349251238 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -51,7 +51,7 @@ COPY scripts/link-plugin-dev-sdk.mjs scripts/
RUN pnpm install --frozen-lockfile
-FROM base AS build
+FROM base AS rust-toolchain
WORKDIR /app
# Debian's packaged rust lags the ecosystem (trixie ships 1.85) and the
# runner's dependency tree now requires a newer rustc. Install rustup from a
@@ -83,8 +83,31 @@ RUN set -eux; \
chmod +x /tmp/rustup-init; \
/tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain none; \
rm /tmp/rustup-init
+# Install the package-owned compiler before any application source enters the
+# stage. rustup-init above installs rustup itself, not the selected compiler.
+COPY packages/paperclip-runner/rust-toolchain.toml /tmp/runner-toolchain/rust-toolchain.toml
+RUN cd /tmp/runner-toolchain && rustup show
+
+FROM rust-toolchain AS runner-build
+WORKDIR /app/packages/paperclip-runner
+# Rust embeds protocol schemas and fixtures with include_str!. Keep those
+# alongside the complete Cargo workspace so every compile-time input keys
+# this layer. Ordinary server/UI edits can then reuse the native build.
+COPY packages/paperclip-runner/rust-toolchain.toml ./
+COPY packages/paperclip-runner/runner ./runner
+COPY packages/paperclip-runner/protocol ./protocol
+# Cargo fingerprints source mtimes. Normalize them here and after the full
+# source copy below so a fresh checkout cannot invalidate unchanged inputs.
+RUN find runner protocol -type f -exec touch -d @0 {} + \
+ && touch -d @0 rust-toolchain.toml \
+ && cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd
+
+FROM runner-build AS build
+WORKDIR /app
COPY --from=deps /app /app
COPY . .
+RUN find packages/paperclip-runner/runner packages/paperclip-runner/protocol -type f -exec touch -d @0 {} + \
+ && touch -d @0 packages/paperclip-runner/rust-toolchain.toml
RUN pnpm --filter @paperclipai/ui build
RUN pnpm --filter @paperclipai/plugin-sdk build
# The server build runs scripts/write-build-stamp.mjs, which stamps the built
@@ -103,14 +126,6 @@ RUN rm -rf packages/paperclip-runner/runner/target
FROM base AS production
ARG USER_UID=1000
ARG USER_GID=1000
-# Real version for this build, computed from `git describe` on the CI runner
-# (the image has no .git, so the server cannot derive it at runtime). Empty for
-# local `docker build`, which just leaves the server on its normal fallbacks.
-ARG PAPERCLIP_BUILD_VERSION=""
-# The exact commit this image was built from, for the same reason: server-info
-# falls back to PAPERCLIP_BUILD_COMMIT when git is unavailable, which feeds the
-# /api/health `commit` field that deploy tooling verifies. Empty locally.
-ARG PAPERCLIP_BUILD_COMMIT=""
# Refreshes the tool layer below when it changes (CI stamps an ISO week, so
# the @latest CLI tools advance weekly). Without it the cached layer would
# freeze the tools until an unrelated cache bust.
@@ -133,6 +148,13 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY --chown=node:node --from=build /app /app
+# Declare per-build metadata after the stable RUN layers. Docker includes
+# in-scope ARG values in a RUN's environment even when its command does not
+# mention them; declaring these earlier invalidates the weekly tool cache.
+# The build stage still receives the commit before writing dist/build-info.json.
+# Empty for local builds, preserving the server's normal version fallbacks.
+ARG PAPERCLIP_BUILD_VERSION=""
+ARG PAPERCLIP_BUILD_COMMIT=""
ENV NODE_ENV=production \
HOME=/paperclip \
HOST=0.0.0.0 \
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index e28f3dd486..3528a0e4ce 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -110,6 +110,63 @@ workflow manually, to produce downloadable Playwright report/test-result
artifacts. Normal PR visual runs use read-only repository permissions and do not
upload or mutate baseline objects.
+### Publish a branch Storybook
+
+CODEOWNERS can publish a repository branch through **Actions → Storybook Deploy →
+Run workflow**. Keep the workflow branch on `master` and enter the source branch
+in `branch`. The source branch does not need to contain the workflow. Leaving
+`branch` empty publishes the selected workflow branch's dispatched commit.
+
+```sh
+gh workflow run storybook-deploy.yml --ref master -f branch=your-branch
+```
+
+The existing **Storybook Visual** workflow also offers a `deploy_preview` checkbox,
+which publishes through the same workflow instead of running visual tests:
+
+```sh
+gh workflow run storybook-visual.yml --ref master -f deploy_preview=true -f branch=your-branch
+```
+
+Approve the `storybook-deploy` environment as a CODEOWNER. The workflow summary
+links the **stable branch URL** and **this build**. The run also uploads a
+`storybook-deployment--` artifact containing
+`storybook-deployment.md` with both links and the source commit. Different branches have
+different URLs; publishing one never replaces another. Redeploying the same
+branch updates its stable URL only after all files for the new build are uploaded.
+Previous build links keep working. The branch entry preserves Storybook query
+parameters and fragments when redirecting to the completed build.
+
+URLs use `storybook/branches/-/index.html`. The hash preserves
+the distinction between branch names such as `feature/foo`, `feature-foo`, and
+`Feature/foo`. Build files live under that branch's `builds/-/`.
+`deployment.json` in each build records its branch, source commit and URLs.
+Builds run independently; publication is serialized per branch. Retained builds
+are not automatically deleted and will accumulate until an operator prunes them.
+
+Publishing requires both the original actor and the current rerunner to be
+individual GitHub accounts named in `.github/CODEOWNERS` on the current default
+branch. Comments, teams and email entries do not grant access. Authorization runs
+before the build and again before deployment, including deployment-only reruns.
+GitHub also requires a CODEOWNER environment approval, so editing authorization
+code on a branch cannot grant AWS access without an authorized reviewer.
+
+The build downloads the public source archive with no GitHub token permissions,
+AWS credentials or repository secrets. Dependency caching and install lifecycle
+scripts are disabled. The separate publisher uses GitHub OIDC to assume a role limited to
+`storybook/branches/*`. It treats the build artifact as static files and runs only
+the publisher from the workflow checkout. It cannot delete objects, change AWS
+settings, or overwrite the runner dashboard. The Storybook site itself is public.
+Pushes and PR events never publish it.
+
+The existing S3 bucket and CloudFront distribution also serve runner reports in
+separate prefixes. GitHub Pages and its dashboard workflow are independent.
+See [Storybook deployment setup](STORYBOOK-DEPLOYMENT.md) for the environment,
+repository variables, AWS policies and one-time operator setup.
+
+GitHub requires a new dispatch workflow to exist on the default branch before
+it becomes a manual entry point.
+
## UI Fonts And Screenshots
The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`.
@@ -1314,3 +1371,34 @@ See [execution GitHub identity](execution-github-identity.md) for the operation-
See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache,
and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated
Paperclip API URL when running Storybook. Avatar PNGs are generated on demand.
+
+### Investigating polling load
+
+The company heartbeat-run and live-run lists load secret registries in one
+company-scoped query per response. Registry reads project only
+`paperclipSecretRedactions` from the run context. They do not load the full
+prompt/context JSON. Decrypted values live only for that request and each run
+uses its own registry.
+
+Hidden browser tabs suspend the company live-events connection and transcript
+log reads. Returning to a visible tab refreshes active queries once and resumes
+transcript reads from their retained offsets. A queued live-event invalidation
+that flushes after the tab hides marks data stale without starting a refetch.
+The developer-server health poll also stops in hidden tabs.
+
+Workspace detail responses share concurrent Git inspections and reuse their
+results for up to five seconds after completion. The cache holds at most 256
+entries. Close-readiness checks, the terminal-workspace reaper, and the final
+cleanup validation still inspect Git afresh. A display result never authorizes
+worktree removal.
+
+The connection-health sweep selects only due IDs in SQL before applying its
+limit. Legacy `paperclip_plugin` placeholder connections are excluded: their
+tools run in plugin workers and do not have remote MCP endpoints. These rows
+remain available; the sweep does not disable or delete plugin connections.
+
+When investigating an overloaded instance, distinguish request amplification
+from stored configuration problems. Verify connection transport and endpoint
+fields before disabling a connection. Verify workspace ownership, active runs,
+Git state, and runtime-service readiness before closing a workspace. A missing
+URL or old workspace timestamp alone does not prove that a row is disposable.
diff --git a/doc/DOCKER.md b/doc/DOCKER.md
index 01278a8343..757a2c3094 100644
--- a/doc/DOCKER.md
+++ b/doc/DOCKER.md
@@ -18,12 +18,55 @@ Build arguments:
|-----|---------|---------|
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
| `USER_GID` | `1000` | GID for the container `node` group |
+| `CLI_TOOLS_CACHE_EPOCH` | empty | Refresh the CLI-install layer; CI supplies the current ISO week |
+| `PAPERCLIP_BUILD_VERSION` | empty | Runtime version when Git metadata is unavailable |
+| `PAPERCLIP_BUILD_COMMIT` | empty | Source commit written into the server build stamp and runtime environment |
+
+Changing the build version or commit preserves the CLI-install cache. The
+tool layer refreshes when its weekly epoch, base image, installation command,
+or earlier build inputs change. Local builds can set a new epoch explicitly
+to refresh tools without clearing the entire build cache.
```sh
docker build -t paperclip-local \
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
```
+## Cloud image addresses
+
+The Docker workflow publishes the managed deployment image for Linux AMD64.
+`Cloud readiness` starts `Docker cloud` on each master push independently of the
+multi-platform self-hosted build. Different commits use separate concurrency groups and existing
+GitHub-hosted runners, so an older production or cloud build does not hold the
+new commit in a workflow queue. Available GitHub runner capacity still applies.
+Release tags and manual `Docker` dispatches call the same cloud build workflow.
+
+Each commit exports to its own `buildcache-cloud-` registry tag.
+Builds import the current commit and nine first-parent ancestors, plus the
+legacy `buildcache-cloud` fallback. This preserves reusable layers without
+letting concurrent builds overwrite one shared cache manifest. Retain recent
+cache tags if registry cleanup is configured; deleting them makes builds colder.
+
+Cloud CI skips SDK and cache cleanup when both the Docker data filesystem and
+the checkout filesystem have at least 64 GiB available. Below that conservative
+headroom threshold, or when the measurement fails, it retains the existing
+cleanup. The threshold selects the fast path; it is not a new minimum disk
+requirement for local builds or smaller runners.
+
+After the pushed image passes its Sentry and orphan-reaping checks, the workflow verifies its
+commit label and platform and adds `ghcr.io/paperclipai/paperclip:sha--cloud`.
+This address lets commit-based deployment tooling reuse the normal build.
+Existing short-SHA and release tags remain available.
+
+The full-SHA tag identifies the source commit. It does not certify that source
+tests passed or that a compatible database migrator is available. Deployment
+tooling must still check those prerequisites and pin the resolved image digest;
+a rebuild of the same source can update the tag's digest.
+
+The separate [cloud readiness check](cloud-build-readiness.md) combines source
+verification, successful cloud image checks, and exact-source migrator
+availability. It runs outside the full npm release's concurrency queue.
+
## One-liner (build + run)
```sh
@@ -295,3 +338,25 @@ Notes:
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).
+
+## Native Runner build cache
+
+The image compiles the native Runner in `runner-build`, before copying the
+application source. That stage includes the pinned Rust compiler, the complete
+Cargo workspace and lockfile, and the protocol schemas and fixtures embedded
+by Rust. Changes to those inputs rebuild the native binary. Ordinary server or
+UI changes can reuse it through the existing registry cache (`mode=max`). Each
+platform gets its own native build; no cross-architecture binary is reused.
+
+The application build inherits that stage and still runs the normal server
+build, including Cargo, binary staging, and generated-contract checks. Rust
+input file times are normalized in both stages so fresh checkouts do not force
+Cargo to rebuild unchanged source. Changes made by build scripts still reach
+Cargo's normal validation. The final application copy excludes Cargo's target
+directory as before. Cache misses only cost compilation time.
+
+Pull requests that change the Dockerfile, Docker ignore rules, or Runner native
+inputs also build the isolated `runner-build` target in `Docker Runner check`.
+This compiles against the actual reduced context and catches missing embedded
+inputs before the post-merge image build. It uses a GitHub-hosted runner with
+read-only repository access and does not publish images or cache artifacts.
diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md
index e8131595f8..43b6fee7bd 100644
--- a/doc/RELEASE-AUTOMATION-SETUP.md
+++ b/doc/RELEASE-AUTOMATION-SETUP.md
@@ -335,3 +335,59 @@ Check:
- [doc/RELEASING.md](RELEASING.md)
- [doc/PUBLISHING.md](PUBLISHING.md)
- [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md)
+
+## Runner verification dependency cache
+
+`release-verify.yml` caches Cargo dependencies for its `Verify Paperclip Runner`
+job using a pinned Rust Cache action. It selects the compiler from the Runner
+package's `rust-toolchain.toml` before computing the cache key. Compiler and Cargo
+metadata changes select a new cache; the `release-runner-v1` shared key lets
+callers of this reusable verification workflow reuse the same dependency cache.
+
+Workspace crates and installed Cargo binaries are excluded. Every run still
+builds the Runner workspace and runs `check:all`, including the Rust and
+TypeScript tests. Only an own-repository master-push run verifying that push's exact
+SHA can restore the cache, and only a successful run saves it. PR, tag, and
+manual candidate verification compile without this cache. A miss or eviction costs compilation time but does not change the checks.
+To discard old dependency caches, increment the shared-key version and let the
+next successful master verification warm it again.
+
+The trust boundary is the protected master branch, not the cache-key text.
+GitHub does not let master restore caches created by a child branch, sibling
+branch, tag, or PR merge ref. Both permitted restore scopes (current branch and
+default branch) are master here. A workflow with authority to execute arbitrary
+code on master can affect verification directly and is already trusted. The
+cache contains dependency build artifacts, not credentials or workspace output.
+See [GitHub cache access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache).
+
+## Chat integration test shards
+
+Release verification runs the large chat integration file on three independent
+runners. Five other server shards cover every remaining general server file.
+The ordinary local test command and trusted PR workflow keep their complete
+`general-server` group. Each chat case shuts down its services, pauses its own
+still-active endpoints, and retires its active/waiting conversations after
+assertions. This keeps workers in later cases from claiming earlier
+fixtures in the shared test database. Application assertions stay unchanged.
+
+Each chat job collects active tests with Vitest, groups cases by source line,
+and balances those groups by case count. Parameterized cases and loop-generated
+cases on one line stay together. The job re-collects with the exact line filters
+it will execute and fails if the selected case identities differ. Hooks and test
+execution remain sequential inside each runner with its own temporary home.
+
+Run one shard locally with:
+
+```sh
+pnpm test:run:general -- --group general-chat --shard-index 0 --shard-count 3
+```
+
+Use indexes 0, 1, and 2 to run the complete chat suite. The CLI validates that
+each shard has work and that collection includes usable source locations. A
+Vitest collection or filtering change fails verification instead of dropping
+tests. Splitting adds three release-verification jobs and repeats collection and
+fixture setup; it does not make a single test faster.
+
+The file-duration manifest also records the native Codex Runner integration
+suite's measured import and execution cost, so the existing file balancer
+accounts for it in both ordinary PR and release verification.
diff --git a/doc/STORYBOOK-DEPLOYMENT.md b/doc/STORYBOOK-DEPLOYMENT.md
new file mode 100644
index 0000000000..cbac54cdd0
--- /dev/null
+++ b/doc/STORYBOOK-DEPLOYMENT.md
@@ -0,0 +1,79 @@
+# Storybook branch hosting
+
+The `Storybook Deploy` workflow publishes public static Storybook builds to the
+existing private S3 bucket behind CloudFront. It does not deploy to GitHub Pages.
+
+## Current destination
+
+- AWS account: `078455283791`, region `us-east-1`
+- Bucket: `paperclipai-runner-e2e-history-078455283791-us-east-1`
+- Allowed upload prefix: `storybook/branches/`
+- Distribution: `E3GTU28BBO2SFR`
+- Public origin: `https://d1p6rlowie26tp.cloudfront.net`
+- Role: `arn:aws:iam::078455283791:role/paperclip-storybook-github`
+
+The distribution's default behavior disables edge caching and rewrites directory
+URLs to `index.html`. Stable branch indexes send `no-cache`; unique build objects
+send `immutable`. No invalidations or CloudFront write permissions are needed.
+
+## GitHub configuration
+
+Create environment `storybook-deploy` with required reviewers set to the
+individual CODEOWNERS accounts. Disable administrator bypass, allow self-review,
+and allow repository branches. Keep these reviewers synchronized with CODEOWNERS.
+The workflow rejects environments with no required reviewers, non-owner reviewers
+or administrator bypass enabled. The AWS role trusts only this repository and
+this environment, so a branch cannot obtain upload access through an unprotected
+environment.
+
+Set repository variables:
+
+| Variable | Value |
+| --- | --- |
+| `STORYBOOK_AWS_ROLE_ARN` | `arn:aws:iam::078455283791:role/paperclip-storybook-github` |
+| `STORYBOOK_AWS_REGION` | `us-east-1` |
+| `STORYBOOK_S3_BUCKET` | `paperclipai-runner-e2e-history-078455283791-us-east-1` |
+| `STORYBOOK_PUBLIC_BASE_URL` | `https://d1p6rlowie26tp.cloudfront.net` |
+
+No stored AWS access keys are needed. Leave the runner dashboard variables and
+GitHub Pages configuration unchanged.
+
+## Operator setup
+
+Use the `paperclip-dev` operator AWS profile. Review the checked-in policies in
+`.github/storybook-deploy/` before applying them. The existing GitHub OIDC provider
+must be present in this account.
+
+```sh
+aws sts get-caller-identity --profile paperclip-dev
+aws iam create-role --profile paperclip-dev \
+ --role-name paperclip-storybook-github \
+ --assume-role-policy-document file://.github/storybook-deploy/trust-policy.json
+aws iam put-role-policy --profile paperclip-dev \
+ --role-name paperclip-storybook-github --policy-name StorybookBranchUpload \
+ --policy-document file://.github/storybook-deploy/upload-policy.json
+```
+
+For an existing role, use `update-assume-role-policy` instead of `create-role`.
+Add the statement from `cloudfront-read-statement.json` to the existing bucket
+policy's `Statement` array. Preserve every other statement, including the HTTPS
+requirement and runner report access. Keep all S3 public-access blocks enabled;
+only CloudFront receives read access to this public-content prefix.
+
+The role has no delete, bucket policy, IAM, CloudFront, or root-object permissions.
+The workflow never runs `sync --delete`. Builds accumulate; any retention cleanup
+must preserve the build referenced by each branch entry.
+
+## Verification
+
+```sh
+node --test scripts/__tests__/storybook-deploy.test.mjs
+actionlint .github/workflows/storybook-deploy.yml .github/workflows/storybook-visual.yml
+```
+
+Dispatch two source branches, approve each deployment, and check their distinct
+branch URLs and each build's `deployment.json`. Redeploy one branch and confirm
+its stable URL now points to the new build while the other branch is unchanged.
+The publisher checks the public build metadata against the selected source SHA
+and verifies that the public branch entry points to this exact build. It retries
+brief propagation delays and fails if the branch URL remains stale.
diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md
new file mode 100644
index 0000000000..e5c45c1586
--- /dev/null
+++ b/doc/cloud-build-readiness.md
@@ -0,0 +1,106 @@
+# Cloud build readiness
+
+The `Cloud readiness` workflow starts for every master push. Its versioned
+`Cloud deployable v1` job succeeds only after all three prerequisites succeed:
+
+- The existing `Release Verify` workflow checks that exact commit, including
+ typecheck, builds, general and serialized tests, and Runner verification.
+- The reusable `Docker cloud` workflow builds and verifies its Linux AMD64
+ image, including Sentry resolution and orphan reaping, then publishes the
+ full-SHA cloud tag. Cloud readiness owns the master trigger so there is one
+ cloud build per push. Release tags and manual Docker runs retain their callers.
+- The full-SHA image and both exact-source npm packages are visible. The
+ packages are `@paperclipai/shared` and `@paperclipai/db` at
+ `0.0.0-preview.g`, published through the migrator-only release lane.
+ Registry metadata must match the full commit, and the database package must
+ pin the matching shared package.
+
+The Cloud workflow builds the image with `USER_UID=1001` and `USER_GID=1001`,
+matching the managed runtime. This avoids a startup user remap, which can walk
+the mounted home and delay health checks. Before publishing the full-SHA tag,
+the workflow checks the baked identity without running the entrypoint, then
+checks the normal entrypoint's effective user and writable home. Volume ownership
+repair still runs when needed. The Dockerfile defaults remain `1000:1000` for
+self-hosted builds, and runtime identity overrides remain supported. The first
+build with the new identity must rebuild layers that depend on the base image;
+later builds can reuse those layers.
+
+Verification and image building run concurrently, outside the full npm release's
+concurrency group. Different commits have independent groups. Source verification
+is initially duplicated with the normal npm release: this spends existing hosted
+runner capacity to avoid waiting behind an older release. No verification gate is
+removed from npm publication. Watch organization-wide runner queues when measuring
+the result.
+
+The artifact wait runs for up to 30 minutes and reports what is missing. Only
+an HTTP 404 means publication is pending; authorization errors, upstream outages,
+and identity mismatches fail the job. A failed, cancelled, or skipped prerequisite
+cannot produce a successful readiness job. Retry the failed publication or build,
+then rerun the failed readiness workflow jobs to check the same commit again.
+
+## Consumer contract
+
+`Cloud deployable v1` is a source-and-artifact readiness signal. A deployment
+consumer must still resolve and pin the image digest and npm integrity/lockfile,
+validate migration contents and compatibility, and apply its target health gates.
+The check creates no release record and deploys no instance. A full-SHA tag by
+itself, or a successful migrator dispatch, is not this readiness signal.
+
+For automatic selection, accept only a successful job named exactly
+`Cloud deployable v1` in the latest attempt of a successful
+`.github/workflows/cloud-readiness.yml` run in `paperclipai/paperclip`, with
+event `push`, head branch `master`, and the expected full head SHA and repository.
+Do not trust a similarly named check from another workflow or a manual branch run.
+Order candidates by master ancestry, not job completion time: an older commit
+finishing late must not roll a fleet backward. Fail closed on API errors.
+
+Existing npm canary discovery is unchanged by this producer workflow. Consumers
+can adopt the versioned signal separately after the workflow has landed and
+successfully verified a real master commit.
+
+## Timing and rollout
+
+The reusable Runner chaos workflow scopes concurrency to the caller workflow
+and source ref. Cloud readiness and the npm release can verify the same commit
+at the same time. They must not cancel each other's required test job.
+
+Measure the complete path from a master merge to a healthy target running that
+exact commit. Keep readiness and deployment as separate milestones:
+
+| Milestone | Evidence | Elapsed time starts at |
+| --- | --- | --- |
+| Merge | Merged PR timestamp and full merge commit SHA | Merge |
+| Image available | Successful full-SHA image publication and verification | Merge |
+| Cloud deployable | Successful `Cloud deployable v1` job in the accepted push run and attempt | Merge |
+| Canary healthy | Deployment consumer's canary health gate confirms the target commit | Merge |
+| Fleet complete | Campaign succeeds for all eligible targets at that commit | Merge |
+
+Record the source SHA, workflow run ID and attempt, readiness job completion
+time, and deployment campaign identity together. Verify the run against the
+consumer contract above. A manual dispatch can test wiring, but its timestamp
+does not measure automatic merge-to-deploy latency. A preparation-only run
+resolves artifacts without deploying a target and must not be counted as a
+successful deployment.
+
+Record queue time and the image, source-verification, and artifact-wait durations
+separately. The slowest prerequisite determines readiness; shortening an already
+faster prerequisite may have no effect on the total. After readiness, measure
+consumer discovery delay, artifact resolution, canary health, and fleet rollout.
+An automatic consumer that still waits for the full npm canary publication has
+that queue on its critical path even if cloud artifacts are ready earlier.
+
+For a target health measurement, confirm the deployed source SHA as well as
+service health. A proxy health response alone may describe the control plane
+while the tenant still runs the previous image. Report the eligible target count,
+excluded or sleeping targets, retries, and failures with the fleet result. Record
+runner queue conditions and cache state; one warm or cold run is a sample, not a
+latency guarantee.
+
+Land full-SHA image publication, independent cloud builds, and migrator-only
+publication before enabling this workflow. Until those producers are present,
+the artifact wait cannot succeed. A manual dispatch on master can verify the
+wiring, but automatic consumers should use push runs. Source verification and
+registry checks can be rerun without deploying or changing mutable npm channels.
+
+When reverting this workflow, restore the master push trigger in
+`docker-cloud.yml` in the same change so master images continue to build.
diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md
new file mode 100644
index 0000000000..28deab517c
--- /dev/null
+++ b/doc/cloud-ui-snippet.md
@@ -0,0 +1,42 @@
+# Cloud UI snippet
+
+Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet.
+The server inserts it before `