diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 41004653b7..733ce555c0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -31,6 +31,13 @@ ### Testing instructions +## Preview deployment + +Check to deploy previews for this PR. Leave unchecked to skip. + +- [ ] Deploy documentation preview +- [ ] Deploy Storybook preview + ## Illustrations/Icons Checklist Required if this PR changes files under `packages/illustrations/**` or `packages/icons/**` diff --git a/.github/scripts/remove-from-manifest.mjs b/.github/scripts/remove-from-manifest.mjs new file mode 100755 index 0000000000..2aecef3cd9 --- /dev/null +++ b/.github/scripts/remove-from-manifest.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +/** + * Remove a PR preview entry from manifest.json + * Usage: node remove-from-manifest.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Parse command line arguments +const [prNumber] = process.argv.slice(2); + +if (!prNumber) { + console.error('Usage: node remove-from-manifest.mjs '); + process.exit(1); +} + +const manifestPath = path.join(process.cwd(), 'manifest.json'); + +// Read existing manifest +if (!fs.existsSync(manifestPath)) { + console.log('⚠️ Manifest does not exist, nothing to remove'); + process.exit(0); +} + +let manifest; +try { + const content = fs.readFileSync(manifestPath, 'utf-8'); + manifest = JSON.parse(content); +} catch (error) { + console.error('❌ Failed to parse manifest:', error.message); + process.exit(1); +} + +// Remove preview entry +const prNum = parseInt(prNumber, 10); +const initialLength = manifest.previews.length; +manifest.previews = manifest.previews.filter((p) => p.pr !== prNum); + +if (manifest.previews.length === initialLength) { + console.log(`⚠️ PR #${prNum} not found in manifest`); +} else { + console.log(`✅ Removed preview for PR #${prNum}`); +} + +// Update last updated timestamp +manifest.lastUpdated = new Date().toISOString(); + +// Write manifest +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + +console.log(`📝 Manifest updated successfully`); +console.log(` Total previews: ${manifest.previews.length}`); diff --git a/.github/scripts/update-manifest.mjs b/.github/scripts/update-manifest.mjs new file mode 100755 index 0000000000..e77566e5db --- /dev/null +++ b/.github/scripts/update-manifest.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +/** + * Update manifest.json with a new or updated PR preview entry + * Usage: node update-manifest.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Parse command line arguments +const [prNumber, prTitle, branch, author, commit, hasDocs, hasStorybook] = process.argv.slice(2); + +if (!prNumber || !prTitle || !branch || !author || !commit) { + console.error( + 'Usage: node update-manifest.mjs ', + ); + process.exit(1); +} + +const manifestPath = path.join(process.cwd(), 'manifest.json'); + +// Read existing manifest or create new one +let manifest = { + previews: [], + lastUpdated: new Date().toISOString(), +}; + +if (fs.existsSync(manifestPath)) { + try { + const content = fs.readFileSync(manifestPath, 'utf-8'); + manifest = JSON.parse(content); + } catch (error) { + console.warn('Failed to parse existing manifest, creating new one'); + } +} + +// Find existing preview or create new entry +const prNum = parseInt(prNumber, 10); +const existingIndex = manifest.previews.findIndex((p) => p.pr === prNum); + +const previewEntry = { + pr: prNum, + title: prTitle, + branch: branch, + author: author, + baseUrl: `/cds/pr-${prNum}/`, + previews: { + docs: hasDocs === 'true' ? `/cds/pr-${prNum}/docs/` : null, + storybook: hasStorybook === 'true' ? `/cds/pr-${prNum}/storybook/` : null, + }, + createdAt: + existingIndex >= 0 ? manifest.previews[existingIndex].createdAt : new Date().toISOString(), + updatedAt: new Date().toISOString(), + commit: commit, +}; + +if (existingIndex >= 0) { + // Update existing preview + manifest.previews[existingIndex] = previewEntry; + console.log(`✅ Updated preview for PR #${prNum}`); +} else { + // Add new preview + manifest.previews.push(previewEntry); + console.log(`✅ Added preview for PR #${prNum}`); +} + +// Log which previews are available +const availablePreviews = []; +if (previewEntry.previews.docs) availablePreviews.push('docs'); +if (previewEntry.previews.storybook) availablePreviews.push('storybook'); +console.log(` Available previews: ${availablePreviews.join(', ') || 'none'}`); + +// Sort by PR number (descending) +manifest.previews.sort((a, b) => b.pr - a.pr); + +// Update last updated timestamp +manifest.lastUpdated = new Date().toISOString(); + +// Write manifest +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); + +console.log(`📝 Manifest updated successfully`); +console.log(` Total PRs: ${manifest.previews.length}`); diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 0000000000..711ee5d27b --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,125 @@ +name: Cleanup PR Preview + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to clean up (for testing)' + required: true + type: number + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + +jobs: + cleanup: + name: Cleanup Preview + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + # Checkout main branch to get scripts + - name: Checkout main branch + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: master + + # Get PR number + - name: Get PR number + id: pr-number + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "pr_number=${{ github.event.inputs.pr_number }}" >> $GITHUB_OUTPUT + else + echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + fi + + # Checkout gh-pages branch + - name: Checkout gh-pages + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: gh-pages + path: gh-pages-checkout + fetch-depth: 1 + + # Remove PR directory, update manifest, and push using an orphan commit so + # the branch history stays at depth 1 and doesn't accumulate binary blobs. + - name: Remove preview and push to gh-pages + working-directory: gh-pages-checkout + env: + PR_NUMBER: ${{ steps.pr-number.outputs.pr_number }} + run: | + set -e + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + apply_changes() { + PR_DIR="pr-${PR_NUMBER}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + echo "✅ Directory removed" + else + echo "⚠️ Directory does not exist: $PR_DIR" + fi + node ../.github/scripts/remove-from-manifest.mjs "$PR_NUMBER" + } + + git fetch origin gh-pages + git reset --hard origin/gh-pages + + apply_changes + git add -A + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + for i in 1 2 3; do + SQUASHED=$(git commit-tree HEAD^{tree} -m "Remove preview for PR #${PR_NUMBER}") + if git push --force-with-lease origin "${SQUASHED}:refs/heads/gh-pages"; then + echo "✅ Changes pushed to gh-pages" + exit 0 + fi + echo "Push failed (concurrent update?), re-merging and retrying ($i/3)..." + sleep $((RANDOM % 10 + 1)) + git fetch origin gh-pages + git reset --hard origin/gh-pages + apply_changes + git add -A + done + + echo "❌ Failed to push after 3 retries" + exit 1 + + # Comment on PR + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const prNumber = ${{ steps.pr-number.outputs.pr_number }}; + + const comment = `## 🧹 Preview Cleaned Up + + The documentation preview for this PR has been removed. + + **Removed:** \`/cds/pr-${prNumber}/\` + + --- + + 🕐 Cleaned up at: \`${new Date().toISOString()}\``; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment, + }); diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 0000000000..0977106f37 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,316 @@ +name: Deploy PR Preview + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to deploy' + required: true + type: number + deploy_docs: + description: 'Deploy documentation preview' + required: false + default: false + type: boolean + deploy_storybook: + description: 'Deploy Storybook preview' + required: false + default: false + type: boolean + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: preview-deploy-${{ github.event.pull_request.number || github.event.inputs.pr_number }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +env: + CI: true + NODE_ENV: production + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + +jobs: + # Lightweight pre-check: reads the PR body directly from the event payload. + # No checkout or yarn install needed — completes in ~5s and gates the expensive deploy job. + check: + name: Check deploy options + runs-on: ubuntu-latest + outputs: + deploy_docs: ${{ steps.parse.outputs.deploy_docs }} + deploy_storybook: ${{ steps.parse.outputs.deploy_storybook }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Parse deploy options from PR body + id: parse + if: github.event_name == 'pull_request' + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + DEPLOY_DOCS=false + DEPLOY_STORYBOOK=false + if echo "$PR_BODY" | grep -i "Deploy documentation preview" | grep -q "\[x\]"; then + DEPLOY_DOCS=true + fi + if echo "$PR_BODY" | grep -i "Deploy Storybook preview" | grep -q "\[x\]"; then + DEPLOY_STORYBOOK=true + fi + echo "deploy_docs=$DEPLOY_DOCS" >> $GITHUB_OUTPUT + echo "deploy_storybook=$DEPLOY_STORYBOOK" >> $GITHUB_OUTPUT + if [ "$DEPLOY_DOCS" = "false" ] && [ "$DEPLOY_STORYBOOK" = "false" ]; then + echo "ℹ️ No preview checkboxes checked — skipping deploy." + fi + + deploy: + name: Deploy Previews + needs: check + if: | + github.event_name == 'workflow_dispatch' || + needs.check.outputs.deploy_docs == 'true' || + needs.check.outputs.deploy_storybook == 'true' + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + # Checkout the PR branch + - name: Checkout PR + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || format('refs/pull/{0}/head', github.event.inputs.pr_number) }} + + # Setup Node and dependencies + - name: Setup + uses: ./.github/actions/setup + + # Get PR metadata (deploy options already resolved by the check job or inputs) + - name: Get PR metadata and deploy options + id: pr-metadata + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "pr_title=${{ github.event.pull_request.title }}" >> $GITHUB_OUTPUT + echo "pr_branch=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT + echo "pr_author=${{ github.event.pull_request.user.login }}" >> $GITHUB_OUTPUT + echo "pr_commit=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT + echo "deploy_docs=${{ needs.check.outputs.deploy_docs }}" >> $GITHUB_OUTPUT + echo "deploy_storybook=${{ needs.check.outputs.deploy_storybook }}" >> $GITHUB_OUTPUT + else + PR_NUMBER="${{ github.event.inputs.pr_number }}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + PR_TITLE=$(gh pr view $PR_NUMBER --json title -q '.title') + PR_BRANCH=$(gh pr view $PR_NUMBER --json headRefName -q '.headRefName') + PR_AUTHOR=$(gh pr view $PR_NUMBER --json author -q '.author.login') + PR_COMMIT=$(gh pr view $PR_NUMBER --json headRefOid -q '.headRefOid') + echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT + echo "pr_branch=$PR_BRANCH" >> $GITHUB_OUTPUT + echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT + echo "pr_commit=$PR_COMMIT" >> $GITHUB_OUTPUT + echo "deploy_docs=${{ github.event.inputs.deploy_docs }}" >> $GITHUB_OUTPUT + echo "deploy_storybook=${{ github.event.inputs.deploy_storybook }}" >> $GITHUB_OUTPUT + fi + + # Build docs with custom baseUrl + - name: Build Docs + if: steps.pr-metadata.outputs.deploy_docs == 'true' + run: BASE_URL=/cds/pr-${{ steps.pr-metadata.outputs.pr_number }}/docs/ yarn nx run docs:build + + # Build Storybook + - name: Build Storybook + if: steps.pr-metadata.outputs.deploy_storybook == 'true' + run: yarn nx run storybook:build + + # Check if selector has changes + - name: Check selector changes + id: check-selector + run: | + # Check if any files in apps/preview-selector/ changed + if git diff --name-only ${{ github.event.pull_request.base.sha || 'origin/master' }} HEAD | grep -q '^apps/preview-selector/'; then + echo "selector_changed=true" >> $GITHUB_OUTPUT + echo "✅ Selector has changes, will rebuild" + else + echo "selector_changed=false" >> $GITHUB_OUTPUT + echo "⏭️ Selector unchanged, will skip rebuild" + fi + + # Build selector page (only if it has changes) + - name: Build selector page + if: steps.check-selector.outputs.selector_changed == 'true' + run: yarn nx run preview-selector:build + + # Checkout gh-pages branch to separate directory. + # fetch-depth: 1 since we squash history on every push — full history is never needed. + - name: Checkout gh-pages + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: gh-pages + path: gh-pages-checkout + fetch-depth: 1 + + # Copy builds, update manifest, and push to gh-pages. + # Uses orphan commits (git commit-tree) so the branch always has a single root commit — + # no binary blobs accumulate in history. On concurrent push conflicts, re-fetches the + # latest state, re-applies this PR's changes on top, and retries with --force-with-lease. + - name: Deploy previews to gh-pages + working-directory: gh-pages-checkout + env: + PR_NUMBER: ${{ steps.pr-metadata.outputs.pr_number }} + PR_TITLE: ${{ steps.pr-metadata.outputs.pr_title }} + PR_BRANCH: ${{ steps.pr-metadata.outputs.pr_branch }} + PR_AUTHOR: ${{ steps.pr-metadata.outputs.pr_author }} + PR_COMMIT: ${{ steps.pr-metadata.outputs.pr_commit }} + DEPLOY_DOCS: ${{ steps.pr-metadata.outputs.deploy_docs }} + DEPLOY_STORYBOOK: ${{ steps.pr-metadata.outputs.deploy_storybook }} + SELECTOR_CHANGED: ${{ steps.check-selector.outputs.selector_changed }} + run: | + set -e + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Copies this PR's build artifacts and updates manifest.json. + # Safe to call multiple times — idempotent for this PR's files. + apply_changes() { + PR_DIR="pr-${PR_NUMBER}" + mkdir -p "${PR_DIR}/docs" "${PR_DIR}/storybook" + + if [ "$DEPLOY_DOCS" = "true" ]; then + cp -r ../apps/docs/dist/* "${PR_DIR}/docs/" + echo "✅ Docs copied" + fi + if [ "$DEPLOY_STORYBOOK" = "true" ]; then + cp -r ../apps/storybook/dist/* "${PR_DIR}/storybook/" + echo "✅ Storybook copied" + fi + if [ "$SELECTOR_CHANGED" = "true" ]; then + cp -r ../apps/preview-selector/dist/* ./ + echo "✅ Selector updated" + else + echo "⏭️ Selector unchanged, keeping existing version" + fi + + if [ ! -f "manifest.json" ]; then + echo '{"previews":[],"lastUpdated":"'$(date -u +%Y-%m-%dT%H:%M:%S.000Z)'"}' > manifest.json + fi + + node ../.github/scripts/update-manifest.mjs \ + "$PR_NUMBER" "$PR_TITLE" "$PR_BRANCH" "$PR_AUTHOR" "$PR_COMMIT" \ + "$DEPLOY_DOCS" "$DEPLOY_STORYBOOK" + } + + # Start from the authoritative remote state + git fetch origin gh-pages + git reset --hard origin/gh-pages + + apply_changes + git add -A + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + for i in 1 2 3; do + # Create an orphan root commit from the current tree. + # This replaces history entirely — the branch stays at depth 1 forever. + SQUASHED=$(git commit-tree HEAD^{tree} -m "Deploy preview for PR #${PR_NUMBER}") + + # --force-with-lease ensures we don't overwrite a concurrent push we haven't seen + if git push --force-with-lease origin "${SQUASHED}:refs/heads/gh-pages"; then + echo "✅ Deployed to gh-pages" + exit 0 + fi + + echo "Push failed (concurrent update?), re-merging and retrying ($i/3)..." + # Random jitter (1-10s) desynchronizes concurrent retries so they don't + # collide again on the next attempt (thundering herd mitigation). + sleep $((RANDOM % 10 + 1)) + git fetch origin gh-pages + git reset --hard origin/gh-pages + apply_changes + git add -A + done + + echo "❌ Failed to push after 3 retries" + exit 1 + + # Comment on PR with preview link + - name: Comment on PR + if: success() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const prNumber = ${{ steps.pr-metadata.outputs.pr_number }}; + const deployDocs = '${{ steps.pr-metadata.outputs.deploy_docs }}' === 'true'; + const deployStorybook = '${{ steps.pr-metadata.outputs.deploy_storybook }}' === 'true'; + + const docsUrl = `https://coinbase.github.io/cds/pr-${prNumber}/docs/`; + const storybookUrl = `https://coinbase.github.io/cds/pr-${prNumber}/storybook/`; + + let previewLinks = ''; + if (deployDocs) { + previewLinks += `\n📄 **Documentation:** [View Docs →](${docsUrl})`; + } + if (deployStorybook) { + previewLinks += `\n📚 **Storybook:** [View Storybook →](${storybookUrl})`; + } + + if (!deployDocs && !deployStorybook) { + console.log('No previews deployed, skipping comment'); + return; + } + + const comment = `## 🚀 Preview${deployDocs && deployStorybook ? 's' : ''} Deployed + + Your preview${deployDocs && deployStorybook ? 's are' : ' is'} ready! + ${previewLinks} + + --- + + 📝 Updated: \`${new Date().toISOString()}\` + 🔨 Commit: \`${{ steps.pr-metadata.outputs.pr_commit }}\` + + > Preview${deployDocs && deployStorybook ? 's' : ''} will be automatically removed when the PR is closed. + > To change what's deployed, edit the checkboxes in the PR description.`; + + // Check if we already commented + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('🚀 Preview Deployed') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment, + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment, + }); + } diff --git a/.github/workflows/preview-manual-cleanup.yml b/.github/workflows/preview-manual-cleanup.yml new file mode 100644 index 0000000000..3b5ef0c3cc --- /dev/null +++ b/.github/workflows/preview-manual-cleanup.yml @@ -0,0 +1,100 @@ +name: Manual Preview Cleanup + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to clean up' + required: true + type: number + +permissions: + contents: write + +jobs: + cleanup: + name: Manual Cleanup + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + # Checkout main branch to get scripts + - name: Checkout main branch + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: master + + # Checkout gh-pages branch + - name: Checkout gh-pages + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: gh-pages + path: gh-pages-checkout + fetch-depth: 1 + + # Remove PR directory, update manifest, and push using an orphan commit so + # the branch history stays at depth 1. Retries with --force-with-lease on conflict. + - name: Remove preview and push to gh-pages + working-directory: gh-pages-checkout + env: + PR_NUMBER: ${{ github.event.inputs.pr_number }} + run: | + set -e + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + apply_changes() { + PR_DIR="pr-${PR_NUMBER}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + echo "✅ Directory removed" + else + echo "⚠️ Directory does not exist: $PR_DIR" + fi + if [ -f "manifest.json" ]; then + node ../.github/scripts/remove-from-manifest.mjs "$PR_NUMBER" + else + echo "⚠️ manifest.json does not exist, nothing to update" + fi + } + + git fetch origin gh-pages + git reset --hard origin/gh-pages + + apply_changes + git add -A + + if git diff --staged --quiet; then + echo "No changes to commit" + exit 0 + fi + + for i in 1 2 3; do + SQUASHED=$(git commit-tree HEAD^{tree} -m "Manual cleanup: Remove preview for PR #${PR_NUMBER}") + if git push --force-with-lease origin "${SQUASHED}:refs/heads/gh-pages"; then + echo "✅ Changes pushed to gh-pages" + exit 0 + fi + echo "Push failed (concurrent update?), re-merging and retrying ($i/3)..." + sleep $((RANDOM % 10 + 1)) + git fetch origin gh-pages + git reset --hard origin/gh-pages + apply_changes + git add -A + done + + echo "❌ Failed to push after 3 retries" + exit 1 + + # Summary + - name: Summary + run: | + echo "## Cleanup Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Cleaned up preview for PR #${{ github.event.inputs.pr_number }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Removed:** \`/cds/pr-${{ github.event.inputs.pr_number }}/\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/preview-squash-ghpages.yml b/.github/workflows/preview-squash-ghpages.yml new file mode 100644 index 0000000000..277b6d32a1 --- /dev/null +++ b/.github/workflows/preview-squash-ghpages.yml @@ -0,0 +1,51 @@ +name: Squash gh-pages History + +# Safety net that squashes the gh-pages branch to a single root commit weekly. +# Under normal operation the deploy/cleanup workflows already produce orphan commits, +# so this workflow is essentially a no-op. It handles any history that accumulated +# before the orphan-commit strategy was adopted. +on: + schedule: + - cron: '0 2 * * 0' # Sundays at 02:00 UTC + workflow_dispatch: + +permissions: + contents: write + +jobs: + squash: + name: Squash gh-pages history + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout gh-pages + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: gh-pages + fetch-depth: 0 # full history needed to count commits + + - name: Squash to single root commit + run: | + set -e + + COMMIT_COUNT=$(git rev-list --count HEAD) + echo "Current commit count: $COMMIT_COUNT" + + if [ "$COMMIT_COUNT" -le 1 ]; then + echo "Already a single commit — nothing to squash." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + SQUASHED=$(git commit-tree HEAD^{tree} -m "Squash gh-pages history [$(date -u +%Y-%m-%dT%H:%M:%SZ)]") + git push --force origin "${SQUASHED}:refs/heads/gh-pages" + + echo "✅ Squashed $COMMIT_COUNT commits into 1" + echo "## gh-pages History Squashed" >> $GITHUB_STEP_SUMMARY + echo "Reduced from **$COMMIT_COUNT** commits to **1**." >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 802c816002..7bc3839168 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ yarn nx run mobile-app:launch:ios-debug yarn nx run mobile-app:launch:android-debug ``` +## PR Preview Deployments + +All pull requests automatically get a live preview of the documentation site deployed to GitHub Pages. This makes it easy to review documentation changes before merging. + +**For PR Authors:** No setup required! Just open a PR and check the comments for your preview link. + +**For Maintainers:** See [docs/pr-preview/](docs/pr-preview/) for setup and configuration. + ## Contributing We welcome contributions to the Coinbase Design System! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our development process, coding standards, and how to submit pull requests. diff --git a/apps/docs/docs/home/Home.tsx b/apps/docs/docs/home/Home.tsx index 53feb996d6..a57581f340 100644 --- a/apps/docs/docs/home/Home.tsx +++ b/apps/docs/docs/home/Home.tsx @@ -4,6 +4,7 @@ import { Text } from '@coinbase/cds-web/typography'; import type { PropSidebarItemLink } from '@docusaurus/plugin-content-docs'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/lib/client/docsSidebar.js'; import type { PropSidebarItemCategory } from '@docusaurus/plugin-content-docs/lib/sidebars/types.js'; +import useBaseUrl from '@docusaurus/useBaseUrl'; import { AnimatedHeroGrid } from '@site/src/components/home/AnimatedHero/HeroGrid'; import type { ComponentCardProps } from '@site/src/components/home/ComponentCard'; import { ComponentCard } from '@site/src/components/home/ComponentCard'; @@ -79,6 +80,7 @@ const componentCardLinks = { export default function Home() { const { items } = useDocsSidebar() || {}; + const baseUrl = useBaseUrl('/'); const componentCards: ComponentCardProps[] = useMemo(() => { const componentCategories = items?.find( (item) => item.type === 'category' && item.label === 'Components', @@ -93,10 +95,10 @@ export default function Home() { return { name: item.label, count: item.items?.length, - bannerLightSrc: `/img/componentCardBanners/${categoryName}_light.svg`, - bannerLightOverlaySrc: `/img/componentCardBanners/${categoryName}_light_hover.svg`, - bannerDarkSrc: `/img/componentCardBanners/${categoryName}_dark.svg`, - bannerDarkOverlaySrc: `/img/componentCardBanners/${categoryName}_dark_hover.svg`, + bannerLightSrc: `${baseUrl}img/componentCardBanners/${categoryName}_light.svg`, + bannerLightOverlaySrc: `${baseUrl}img/componentCardBanners/${categoryName}_light_hover.svg`, + bannerDarkSrc: `${baseUrl}img/componentCardBanners/${categoryName}_dark.svg`, + bannerDarkOverlaySrc: `${baseUrl}img/componentCardBanners/${categoryName}_dark_hover.svg`, to: componentCardLinks[categoryName as keyof typeof componentCardLinks] ?? firstItem?.href, @@ -105,7 +107,7 @@ export default function Home() { return null; }) .filter(Boolean) as ComponentCardProps[]; - }, [items]); + }, [items, baseUrl]); return ( diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 63ce097a96..e725ef72ad 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -100,7 +100,8 @@ const config: Config = { url: 'https://cds.coinbase.com', // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' - baseUrl: '/', + // Can be overridden with BASE_URL environment variable for PR previews + baseUrl: process.env.BASE_URL || '/', // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. diff --git a/apps/preview-selector/.gitignore b/apps/preview-selector/.gitignore new file mode 100644 index 0000000000..e597cf2a62 --- /dev/null +++ b/apps/preview-selector/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules + +# Build output +dist + +# Local env files +.env +.env.local +.env.*.local + +# Editor directories +.vscode +.idea +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/apps/preview-selector/README.md b/apps/preview-selector/README.md new file mode 100644 index 0000000000..9990479542 --- /dev/null +++ b/apps/preview-selector/README.md @@ -0,0 +1,210 @@ +# PR Preview Selector + +Modern React-based selector page for browsing PR preview deployments of the CDS documentation. + +## Overview + +This app provides a unified interface for viewing all active PR previews. It's deployed to the root of GitHub Pages and allows users to search, filter, and navigate to specific PR documentation and Storybook previews. + +## Development + +```bash +# Start dev server (with hot reload) +yarn nx run preview-selector:dev +# Opens at http://localhost:5173 + +# Build for production +yarn nx run preview-selector:build + +# Preview production build +yarn nx run preview-selector:preview + +# Type check +yarn nx run preview-selector:typecheck + +# Test locally with mock data +yarn nx run preview-selector:test-local +``` + +## Mock Data for Development + +The `public/manifest_mock.json` file contains mock PR data for local development. + +The selector automatically uses: + +- **Production:** `/cds/manifest.json` (real PR data from workflow) +- **Development:** `/cds/manifest_mock.json` (fallback when real manifest not found) + +**Edit `public/manifest_mock.json` to test different scenarios:** + +```json +{ + "previews": [ + { + "pr": 123, + "title": "Your PR title here", + "previews": { + "docs": "/cds/pr-123/docs/", + "storybook": "/cds/pr-123/storybook/" + } + } + ] +} +``` + +**Set `null` to hide a preview type:** + +```json +"previews": { + "docs": "/cds/pr-123/docs/", + "storybook": null ← Docs only, no storybook button +} +``` + +The dev server automatically serves `public/manifest_mock.json` at `/cds/manifest_mock.json`. + +The selector tries to fetch `/cds/manifest.json` first (production), and falls back to `/cds/manifest_mock.json` (development) if not found. + +## Tech Stack + +- **React 18** - UI library +- **TypeScript** - Type safety +- **Vite** - Build tool and dev server +- **CDS Components** - Uses `@coinbase/cds-web` for styling +- **Nx** - Integrated with monorepo tooling + +## Structure + +``` +apps/preview-selector/ +├── public/ +│ └── manifest.json # Mock data for local dev (EDIT THIS!) +├── src/ +│ ├── main.tsx # Entry point +│ ├── App.tsx # Main component +│ ├── App.css # Styles +│ ├── index.css # Global styles +│ ├── types.ts # TypeScript types +│ ├── utils.ts # Helper functions +│ └── vite-env.d.ts # Vite type definitions +├── index.html # HTML template +├── vite.config.ts # Vite configuration +├── tsconfig.json # TypeScript config +├── project.json # Nx project config +├── package.json # Dependencies +└── README.md # This file +``` + +## Features + +- 🔍 **Real-time search** - Filter PRs by number, title, branch, or author +- 📊 **Flexible sorting** - Sort by date or PR number +- 📱 **Responsive design** - Works on mobile and desktop +- 🎨 **CDS-styled** - Uses real CDS components (ThemeProvider, VStack, Button, etc.) +- ⚡ **Fast** - Built with Vite for optimal performance +- 🎯 **Dual previews** - Separate buttons for docs and storybook + +## How It Works + +1. Fetches `manifest.json` from `/cds/manifest.json` +2. Displays all active PR previews +3. Provides search and sort functionality +4. Shows separate buttons for docs and storybook previews +5. Links to individual PR preview URLs + +In production, the manifest is automatically maintained by GitHub Actions workflows. + +## Local Development Tips + +### Testing Different Scenarios + +Edit `public/manifest_mock.json` to test: + +**Scenario 1: PR with both previews** + +```json +{ + "pr": 100, + "previews": { + "docs": "/cds/pr-100/docs/", + "storybook": "/cds/pr-100/storybook/" + } +} +``` + +Shows: 📄 Docs and 📚 Storybook buttons + +**Scenario 2: Docs only** + +```json +{ + "pr": 200, + "previews": { + "docs": "/cds/pr-200/docs/", + "storybook": null + } +} +``` + +Shows: 📄 Docs button only + +**Scenario 3: Storybook only** + +```json +{ + "pr": 300, + "previews": { + "docs": null, + "storybook": "/cds/pr-300/storybook/" + } +} +``` + +Shows: 📚 Storybook button only + +**Scenario 4: Many PRs** +Add 10-20 PRs to test search/sort performance. + +**Scenario 5: Special characters** + +```json +{ + "pr": 400, + "title": "Test \"quotes\" & 🚀 special chars" +} +``` + +Tests XSS prevention and rendering. + +### Hot Reload + +The dev server watches for changes: + +- Edit `public/manifest_mock.json` → Page auto-refreshes with new data +- Edit `src/App.tsx` → Hot module replacement +- Edit `src/App.css` → Instant style updates + +## Deployment + +The app is automatically built and deployed by GitHub Actions: + +- **Source:** `apps/preview-selector/` +- **Build command:** `yarn nx run preview-selector:build` +- **Output:** `apps/preview-selector/dist/` +- **Deployed to:** gh-pages branch root +- **URL:** `https://coinbase.github.io/cds/` + +## CDS Components Used + +- `ThemeProvider` - Provides CDS theme +- `MediaQueryProvider` - Responsive breakpoints +- `VStack` / `HStack` / `Box` - Layout primitives +- `Text` - Typography with semantic colors +- `Button` - Primary and Secondary variants +- `TextInput` - Search input + +All properly themed with `defaultTheme` and dark mode. + +## Documentation + +For complete system documentation, see: [../../docs/preview/README.md](../../docs/preview/README.md) diff --git a/apps/preview-selector/index.html b/apps/preview-selector/index.html new file mode 100644 index 0000000000..5604f6d4a3 --- /dev/null +++ b/apps/preview-selector/index.html @@ -0,0 +1,13 @@ + + + + + + + CDS PR Previews + + +
+ + + diff --git a/apps/preview-selector/package.json b/apps/preview-selector/package.json new file mode 100644 index 0000000000..78e765568e --- /dev/null +++ b/apps/preview-selector/package.json @@ -0,0 +1,24 @@ +{ + "name": "preview-selector", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@coinbase/cds-icons": "workspace:^", + "@coinbase/cds-web": "workspace:^", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.9.2", + "vite": "^6.0.11" + } +} diff --git a/apps/preview-selector/project.json b/apps/preview-selector/project.json new file mode 100644 index 0000000000..f38b730197 --- /dev/null +++ b/apps/preview-selector/project.json @@ -0,0 +1,55 @@ +{ + "name": "preview-selector", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/preview-selector/src", + "projectType": "application", + "tags": [ + "npm:private" + ], + "targets": { + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "vite", + "cwd": "apps/preview-selector" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "command": "vite build", + "cwd": "apps/preview-selector" + }, + "outputs": [ + "{projectRoot}/dist" + ] + }, + "preview": { + "executor": "nx:run-commands", + "options": { + "command": "vite preview", + "cwd": "apps/preview-selector" + }, + "dependsOn": [ + "build" + ] + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit", + "cwd": "apps/preview-selector" + } + }, + "test-local": { + "executor": "nx:run-commands", + "options": { + "command": "node ../../.github/scripts/test-selector-locally.mjs", + "cwd": "apps/preview-selector" + }, + "dependsOn": [ + "build" + ] + } + } +} diff --git a/apps/preview-selector/src/App.tsx b/apps/preview-selector/src/App.tsx new file mode 100644 index 0000000000..58497d6bbd --- /dev/null +++ b/apps/preview-selector/src/App.tsx @@ -0,0 +1,302 @@ +import { useEffect, useState, useMemo } from 'react'; +import { ThemeProvider } from '@coinbase/cds-web'; +import { VStack, HStack, Box } from '@coinbase/cds-web/layout'; +import { Link, Text } from '@coinbase/cds-web/typography'; +import { Button } from '@coinbase/cds-web/buttons'; +import { SearchInput } from '@coinbase/cds-web/controls'; +import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; +import type { Manifest, Preview, SortOption } from './types'; +import { SelectChip } from '@coinbase/cds-web/alpha/select-chip'; +import { formatRelativeTime } from './utils'; +import { MediaQueryProvider } from '@coinbase/cds-web/system'; +import { Icon } from '@coinbase/cds-web/icons/Icon'; +import { Tag } from '@coinbase/cds-web/tag'; +import { Tooltip } from '@coinbase/cds-web/overlays/tooltip/Tooltip'; +import { mockManifest } from './mockData'; + +function App() { + const [manifest, setManifest] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [sortOption, setSortOption] = useState('updated-desc'); + + useEffect(() => { + const fetchManifest = async () => { + try { + const response = await fetch('/cds/manifest.json'); + + if (response.ok) { + const data = await response.json(); + setManifest(data); + setLoading(false); + return; + } + } catch (err) { + // Fall back to mock data only in development + if (import.meta.env.DEV) { + setManifest(mockManifest); + setLoading(false); + } else { + // In production, show error if manifest not found + setError(true); + setLoading(false); + } + } + }; + + fetchManifest(); + }, []); + + const filteredAndSortedPreviews = useMemo(() => { + if (!manifest) return []; + + let filtered = manifest.previews; + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + filtered = filtered.filter( + (preview) => + preview.pr.toString().includes(lowerQuery) || + preview.title.toLowerCase().includes(lowerQuery) || + preview.branch.toLowerCase().includes(lowerQuery) || + preview.author.toLowerCase().includes(lowerQuery), + ); + } + + const sorted = [...filtered].sort((a, b) => { + switch (sortOption) { + case 'updated-desc': + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + case 'updated-asc': + return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + case 'pr-desc': + return b.pr - a.pr; + case 'pr-asc': + return a.pr - b.pr; + default: + return 0; + } + }); + + return sorted; + }, [manifest, searchQuery, sortOption]); + + return ( + + + + +
+ + {loading && } + {error && } + {!loading && !error && (!manifest || manifest.previews.length === 0) && } + + {!loading && !error && manifest && manifest.previews.length > 0 && ( + + + + setSortOption(value as SortOption)} + label="Sort by" + options={[ + { label: 'Recently Updated', value: 'updated-desc' }, + { label: 'Oldest Updated', value: 'updated-asc' }, + { label: 'PR Number (High to Low)', value: 'pr-desc' }, + { label: 'PR Number (Low to High)', value: 'pr-asc' }, + ]} + /> + + + {filteredAndSortedPreviews.length === 0 ? ( + + ) : ( + + {filteredAndSortedPreviews.map((preview) => ( + + ))} + + )} + + )} + + {manifest &&