From a20ecce40965d9fab197d8c85e5011a88060e4d1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:55 -0500 Subject: [PATCH] feat: publish CODEOWNER-approved Storybook branch previews (#13226) ## Thinking Path > - Paperclip helps people manage AI agents for work. > - Maintainers use Storybook to review the board UI. > - Reviews need public previews of selected repository branches. > - Each branch needs its own URL so previews do not replace each other. > - This pull request adds manual, CODEOWNER-controlled publishing to S3 and CloudFront. > - The action returns stable branch links and permanent build links in its summary and a Markdown artifact. ## Linked Issues or Issue Description **What existing behavior does this improve?** The existing Storybook build and manual visual-review workflow. **Current behavior** The repository has no manual branch-preview publisher. A single GitHub Pages site cannot support independent publishers without combining their output. **Proposed behavior** A CODEOWNER selects a source branch and approves publication. Each branch has a stable CloudFront URL. A completed build becomes the branch target only after its upload succeeds. The action attaches `storybook-deployment.md` with the preview links and source commit. **Reason and benefit** Maintainers can share multiple branch previews at the same time. Branch builds have no repository token permissions or AWS credentials. Dependency caching and install hooks are disabled. The publisher cannot write runner dashboard files or delete objects. **Breaking changes** None. Normal visual checks keep their existing behavior. This does not change application code or GitHub Pages settings. **Additional context** Searched public issues and PRs for Storybook deployment work. No duplicate deployment proposal was found. This is maintainer infrastructure, not a roadmap-level core feature. ## What Changed - Add `Storybook Deploy` with a source-branch input and a manual entry through `Storybook Visual`. - Check the original actor and rerunner against default-branch CODEOWNERS. Require a protected deployment environment with CODEOWNER reviewers. - Separate public-source builds with no repository permissions from an OIDC publisher restricted to the Storybook S3 prefix. - Publish distinct branch URLs and retain build URLs. Preserve Storybook deep links across the branch redirect. - Add the run summary, a downloadable Markdown deployment report, focused tests, and operator setup docs and IAM policies. ## Verification - `node --test scripts/__tests__/storybook-deploy.test.mjs`: 19 tests pass. - `actionlint .github/workflows/storybook-deploy.yml .github/workflows/storybook-visual.yml`: passes. - [Feature branch live publication and deployment-only rerun](https://github.com/paperclipai/paperclip/actions/runs/34533202273): passed. - [Master branch live publication](https://github.com/paperclipai/paperclip/actions/runs/34533204743): passed. - Both public branch URLs render a component story without browser errors. A deployment-only rerun updates only the selected branch entry and preserves the previous build URL. - AWS policy simulation allows Storybook uploads and denies dashboard writes and object deletion. - Full local typechecking passes. Full local tests, build, and current-head PR checks are running. - [Revised build and Markdown artifact validation](https://github.com/paperclipai/paperclip/actions/runs/34605623088): passed. Downloaded the report and verified its branch URL, build URL, and source commit. - The public verifier also checks that the stable branch URL points to this build and rejects stale targets. ## Risks - Storybook previews are public. Maintainers must publish only public UI fixtures. - Retained builds accumulate until an operator prunes them. - Environment reviewers must stay synchronized with CODEOWNERS. The workflow fails closed if its environment loses required protection. - The existing CloudFront distribution is shared with runner reports. Separate S3 prefixes and a dedicated role prevent the publisher from overwriting those reports. ## Model Used OpenAI GPT-6 via Codex, with reasoning, shell tools, and browser verification. The exact runtime model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../scripts/authorize-storybook-deploy.cjs | 50 ++++ .github/scripts/publish-storybook.cjs | 44 ++++ .github/scripts/storybook-destination.cjs | 35 +++ .github/scripts/verify-storybook.cjs | 26 +++ .../cloudfront-read-statement.json | 10 + .github/storybook-deploy/trust-policy.json | 12 + .github/storybook-deploy/upload-policy.json | 8 + .github/workflows/storybook-deploy.yml | 169 ++++++++++++++ .github/workflows/storybook-visual.yml | 24 +- doc/DEVELOPING.md | 57 +++++ doc/STORYBOOK-DEPLOYMENT.md | 79 +++++++ scripts/__tests__/storybook-deploy.test.mjs | 217 ++++++++++++++++++ 12 files changed, 729 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/authorize-storybook-deploy.cjs create mode 100644 .github/scripts/publish-storybook.cjs create mode 100644 .github/scripts/storybook-destination.cjs create mode 100644 .github/scripts/verify-storybook.cjs create mode 100644 .github/storybook-deploy/cloudfront-read-statement.json create mode 100644 .github/storybook-deploy/trust-policy.json create mode 100644 .github/storybook-deploy/upload-policy.json create mode 100644 .github/workflows/storybook-deploy.yml create mode 100644 doc/STORYBOOK-DEPLOYMENT.md create mode 100644 scripts/__tests__/storybook-deploy.test.mjs 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/); + } +});