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/); + } +});