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/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/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/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 6de6cbc7ba..2b8fdc6f75 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/`.
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/scripts/__tests__/storybook-deploy.test.mjs b/scripts/__tests__/storybook-deploy.test.mjs
new file mode 100644
index 0000000000..edbe830f13
--- /dev/null
+++ b/scripts/__tests__/storybook-deploy.test.mjs
@@ -0,0 +1,217 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+import authorize from "../../.github/scripts/authorize-storybook-deploy.cjs";
+
+const ownerFile = ".github/** @cryppadotta @devinfoley @nickyleach @forgottendev\n";
+function fixture(overrides = {}) {
+ const calls = [];
+ const context = {
+ repo: { owner: "paperclipai", repo: "paperclip" },
+ eventName: "workflow_dispatch",
+ ref: "refs/heads/codex/example",
+ actor: "cryppadotta",
+ ...overrides.context,
+ };
+ const environment = {
+ can_admins_bypass: false,
+ protection_rules: [{
+ type: "required_reviewers",
+ reviewers: [{ type: "User", reviewer: { login: "cryppadotta" } }],
+ }],
+ ...overrides.environment,
+ };
+ const github = { rest: { repos: {
+ get: async () => ({ data: { default_branch: "master" } }),
+ getContent: async (params) => {
+ calls.push(params);
+ if (overrides.apiError) throw new Error("GitHub unavailable");
+ return { data: { encoding: "base64", content: Buffer.from(overrides.codeowners ?? ownerFile).toString("base64") } };
+ },
+ getEnvironment: async () => ({ data: environment }),
+ } } };
+ return { github, context, calls };
+}
+
+// Tests are serial because the Actions rerunner is an environment variable.
+process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
+test("allows each current CODEOWNER on a feature branch; reads policy from master", async () => {
+ for (const actor of ["cryppadotta", "devinfoley", "nickyleach", "forgottendev"]) {
+ const f = fixture({ context: { actor } });
+ await authorize(f);
+ assert.equal(f.calls[0].ref, "master");
+ assert.equal(f.calls[0].path, ".github/CODEOWNERS");
+ }
+});
+test("rejects non-owner initiators", async () => {
+ await assert.rejects(authorize(fixture({ context: { actor: "contributor" } })), /Only default-branch CODEOWNERS/);
+});
+test("rejects non-owner and missing rerunners, including deployment-only reruns", async () => {
+ for (const actor of ["contributor", ""]) {
+ process.env.GITHUB_TRIGGERING_ACTOR = actor;
+ await assert.rejects(authorize(fixture()), /Only default-branch CODEOWNERS/);
+ }
+ process.env.GITHUB_TRIGGERING_ACTOR = "cryppadotta";
+});
+test("comments, teams, emails and partial account matches do not grant access", async () => {
+ for (const codeowners of [
+ "# @cryppadotta\n.github/** @other",
+ ".github/** @other # @cryppadotta",
+ ".github/** @paperclipai/cryppadotta",
+ ".github/** cryppadotta@example.com",
+ ".github/** @cryppadotta-extra",
+ "",
+ ]) await assert.rejects(authorize(fixture({ codeowners })), /CODEOWNERS/);
+});
+test("case-insensitive GitHub login matching", async () => {
+ await authorize(fixture({ context: { actor: "CryppaDotta" } }));
+});
+test("rejects forks, PR events, automatic events and tags", async () => {
+ for (const context of [
+ { repo: { owner: "outsider", repo: "paperclip" } },
+ { eventName: "pull_request" }, { eventName: "push" },
+ { eventName: "workflow_call" }, { ref: "refs/tags/release" },
+ ]) await assert.rejects(authorize(fixture({ context })));
+});
+test("fails closed when GitHub cannot return authoritative CODEOWNERS", async () => {
+ await assert.rejects(authorize(fixture({ apiError: true })), /GitHub unavailable/);
+});
+test("requires CODEOWNER environment reviewers with administrator bypass disabled", async () => {
+ for (const environment of [
+ { can_admins_bypass: true },
+ { protection_rules: [] },
+ { protection_rules: [{ type: "required_reviewers", reviewers: [] }] },
+ { protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "User", reviewer: { login: "contributor" } }] }] },
+ { protection_rules: [{ type: "required_reviewers", reviewers: [{ type: "Team", reviewer: { login: "cryppadotta" } }] }] },
+ ]) await assert.rejects(authorize(fixture({ environment })), /must require CODEOWNER reviewers/);
+});
+test("workflow keeps branch build read-only and reauthorizes the protected deploy", () => {
+ const workflow = readFileSync(new URL("../../.github/workflows/storybook-deploy.yml", import.meta.url), "utf8");
+ const [build, deploy] = workflow.split(" build:")[1].split(" deploy:");
+ assert.doesNotMatch(build, /pages: write|id-token: write|secrets\./);
+ assert.match(build, /permissions: \{\}/);
+ assert.doesNotMatch(build, /actions\/checkout|cache: pnpm/);
+ assert.match(build, /package-manager-cache: false/);
+ assert.match(build, /pnpm install --frozen-lockfile --ignore-scripts/);
+ assert.match(deploy, /name: storybook-deploy/);
+ assert.match(deploy, /authorize-storybook-deploy.cjs/);
+ assert.match(deploy, /name: \$\{\{ needs.build.outputs.artifact_name \}\}/);
+ assert.doesNotMatch(workflow.split("permissions:")[0], /push:|pull_request:/);
+});
+
+import { storybookDestination, branchIndex } from '../../.github/scripts/storybook-destination.cjs';
+const input = { branch: 'feature/foo', sha: 'a'.repeat(40), runId: 123, runAttempt: 1,
+ bucket: 'storybook-test', baseUrl: 'https://example.cloudfront.net' };
+test('different branches have distinct stable URLs, including names that sanitize alike', () => {
+ const branches = ['feature/foo', 'feature-foo', 'Feature/foo', 'master', 'feature_foo', 'a'.repeat(100), 'a'.repeat(101)];
+ const urls = branches.map(branch => storybookDestination({ ...input, branch }).url);
+ assert.equal(new Set(urls).size, branches.length);
+ assert.ok(urls.every(url => /^https:\/\/example.cloudfront.net\/storybook\/branches\/[a-z0-9-]+\/index.html$/.test(url)));
+});
+test('redeploying a branch preserves its entry URL and creates a new build URL', () => {
+ const a = storybookDestination(input);
+ const b = storybookDestination({ ...input, sha: 'b'.repeat(40), runId: 124 });
+ assert.equal(a.url, b.url);
+ assert.notEqual(a.buildUrl, b.buildUrl);
+ assert.notEqual(a.buildUrl, storybookDestination({ ...input, runAttempt: 2 }).buildUrl);
+});
+test('invalid source and destination inputs fail closed', () => {
+ for (const change of [{ branch: '' }, { branch: 'a\nb' }, { sha: 'master' }, { runId: '../x' },
+ { runAttempt: 0 }, { bucket: '../bucket' }, { baseUrl: 'http://example.com' },
+ { baseUrl: 'https://user:password@example.com' }, { baseUrl: 'https://example.com/path' },
+ { baseUrl: 'https://example.com?x=y' }]) {
+ assert.throws(() => storybookDestination({ ...input, ...change }));
+ }
+});
+test('branch entry preserves Storybook query and fragment deep links', () => {
+ const html = branchIndex(storybookDestination(input).buildUrl);
+ assert.match(html, /target.search = location.search/);
+ assert.match(html, /target.hash = location.hash/);
+ assert.match(html, /location.replace/);
+});
+
+import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+const publisher = fileURLToPath(new URL('../../.github/scripts/publish-storybook.cjs', import.meta.url));
+function publishFixture(options = {}) {
+ const dir = mkdtempSync(path.join(tmpdir(), 'storybook-publish-test-'));
+ mkdirSync(path.join(dir, 'storybook-static'));
+ mkdirSync(path.join(dir, 'bin'));
+ for (const name of ['index.html', 'iframe.html', 'index.json']) writeFileSync(path.join(dir, 'storybook-static', name), 'fixture');
+ if (options.symlink) symlinkSync('/etc/passwd', path.join(dir, 'storybook-static', 'unsafe'));
+ const stub = path.join(dir, 'bin', 'aws');
+ writeFileSync(stub, `#!${process.execPath}\nconst fs=require('node:fs');fs.appendFileSync(process.env.UPLOAD_LOG,JSON.stringify(process.argv.slice(2))+'\\n');if(process.env.FAIL_UPLOAD==='1')process.exit(1);\n`);
+ chmodSync(stub, 0o755);
+ const result = spawnSync(process.execPath, [publisher], { cwd: dir, encoding: 'utf8', env: {
+ ...process.env, PATH: `${path.join(dir, 'bin')}:${process.env.PATH}`, RUNNER_TEMP: dir,
+ SOURCE_BRANCH: input.branch, SOURCE_SHA: input.sha, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1',
+ STORYBOOK_S3_BUCKET: input.bucket, STORYBOOK_PUBLIC_BASE_URL: input.baseUrl,
+ GITHUB_OUTPUT: path.join(dir, 'output'), GITHUB_STEP_SUMMARY: path.join(dir, 'summary'),
+ UPLOAD_LOG: path.join(dir, 'uploads'), FAIL_UPLOAD: options.fail ? '1' : '0',
+ } });
+ let uploads = [];
+ try { uploads = readFileSync(path.join(dir, 'uploads'), 'utf8').trim().split('\n').map(JSON.parse); } catch {}
+ let report = '';
+ let summary = '';
+ if (result.status === 0) {
+ report = readFileSync(path.join(dir, 'storybook-deployment.md'), 'utf8');
+ summary = readFileSync(path.join(dir, 'summary'), 'utf8');
+ }
+ rmSync(dir, { recursive: true, force: true });
+ return { result, uploads, report, summary };
+}
+test('publisher uploads a complete build then updates only that branch entry', () => {
+ const { result, uploads } = publishFixture();
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(uploads.length, 2);
+ const d = storybookDestination(input);
+ assert.ok(uploads[0].includes(`s3://${input.bucket}/${d.buildPrefix}/`));
+ assert.ok(uploads[1].includes(`s3://${input.bucket}/${d.prefix}/index.html`));
+ assert.ok(uploads[0].includes('--no-follow-symlinks'));
+ assert.doesNotMatch(JSON.stringify(uploads), /--delete/);
+});
+test('a failed build upload never changes the stable branch entry', () => {
+ const { result, uploads } = publishFixture({ fail: true });
+ assert.notEqual(result.status, 0);
+ assert.equal(uploads.length, 1);
+ assert.ok(uploads[0].includes('--recursive'));
+});
+test('artifact symlinks fail before any upload', () => {
+ const { result, uploads } = publishFixture({ symlink: true });
+ assert.notEqual(result.status, 0);
+ assert.equal(uploads.length, 0);
+});
+
+test('successful publication produces a downloadable Markdown report matching the run summary', () => {
+ const { result, report, summary } = publishFixture();
+ assert.equal(result.status, 0, result.stderr);
+ const d = storybookDestination(input);
+ assert.ok(report.includes(`[Branch Storybook](${d.url})`));
+ assert.ok(report.includes(`[This build](${d.buildUrl})`));
+ assert.ok(report.includes(d.sha));
+ assert.equal(report, summary);
+});
+
+import { verifyStorybook } from '../../.github/scripts/verify-storybook.cjs';
+test('public verification retries a stale stable branch entry until it points to the new build', async () => {
+ const d = storybookDestination(input);
+ let indexReads = 0;
+ await verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
+ sleep: async () => {}, attempts: 2, fetch: async (url) => String(url).endsWith('deployment.json')
+ ? new Response(JSON.stringify({ sha: d.sha }))
+ : new Response(branchIndex(++indexReads === 1 ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) });
+ assert.equal(indexReads, 2);
+});
+test('public verification rejects a permanently stale branch URL or wrong source commit', async () => {
+ const d = storybookDestination(input);
+ for (const wrong of ['branch', 'sha']) {
+ await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha,
+ attempts: 1, fetch: async (url) => String(url).endsWith('deployment.json')
+ ? new Response(JSON.stringify({ sha: wrong === 'sha' ? 'b'.repeat(40) : d.sha }))
+ : new Response(branchIndex(wrong === 'branch' ? d.buildUrl.replace('123-1', '122-1') : d.buildUrl)) }),
+ wrong === 'branch' ? /does not point to this build/ : /wrong source commit/);
+ }
+});