From 4fe566b9f883e16285466f5abe657d3846a439ae Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Tue, 24 Feb 2026 16:26:15 -0500 Subject: [PATCH 1/5] feat: create gh workflow to auto deploy sandbox web pages --- .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/scripts/remove-from-manifest.mjs | 58 + .github/scripts/update-manifest.mjs | 88 + .github/workflows/preview-cleanup.yml | 122 + .github/workflows/preview-deploy.yml | 259 ++ .github/workflows/preview-manual-cleanup.yml | 92 + README.md | 8 + apps/docs/docs/home/Home.tsx | 12 +- apps/docs/docusaurus.config.ts | 3 +- apps/preview-selector/.gitignore | 26 + apps/preview-selector/README.md | 203 ++ apps/preview-selector/index.html | 13 + apps/preview-selector/package.json | 24 + apps/preview-selector/project.json | 55 + apps/preview-selector/public/manifest.json | 61 + apps/preview-selector/src/App.tsx | 259 ++ apps/preview-selector/src/main.tsx | 13 + apps/preview-selector/src/types.ts | 21 + apps/preview-selector/src/utils.ts | 20 + apps/preview-selector/src/vite-env.d.ts | 1 + apps/preview-selector/tsconfig.json | 29 + apps/preview-selector/vite.config.ts | 12 + docs/pr-preview/README.md | 747 +++++ package.json | 7 +- yarn.lock | 2934 +++++++++++------- 25 files changed, 3946 insertions(+), 1128 deletions(-) create mode 100755 .github/scripts/remove-from-manifest.mjs create mode 100755 .github/scripts/update-manifest.mjs create mode 100644 .github/workflows/preview-cleanup.yml create mode 100644 .github/workflows/preview-deploy.yml create mode 100644 .github/workflows/preview-manual-cleanup.yml create mode 100644 apps/preview-selector/.gitignore create mode 100644 apps/preview-selector/README.md create mode 100644 apps/preview-selector/index.html create mode 100644 apps/preview-selector/package.json create mode 100644 apps/preview-selector/project.json create mode 100644 apps/preview-selector/public/manifest.json create mode 100644 apps/preview-selector/src/App.tsx create mode 100644 apps/preview-selector/src/main.tsx create mode 100644 apps/preview-selector/src/types.ts create mode 100644 apps/preview-selector/src/utils.ts create mode 100644 apps/preview-selector/src/vite-env.d.ts create mode 100644 apps/preview-selector/tsconfig.json create mode 100644 apps/preview-selector/vite.config.ts create mode 100644 docs/pr-preview/README.md 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..03c6f2cf58 --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,122 @@ +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] + branches: master + +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 + + # Pull latest so concurrent cleanups/deploys don't overwrite each other + - name: Pull latest gh-pages + working-directory: gh-pages-checkout + run: git pull origin gh-pages + + # Remove PR directory + - name: Remove preview directory + run: | + PR_DIR="gh-pages-checkout/pr-${{ steps.pr-number.outputs.pr_number }}" + + if [ -d "$PR_DIR" ]; then + echo "Removing preview directory: $PR_DIR" + rm -rf "$PR_DIR" + echo "✅ Directory removed" + else + echo "⚠️ Directory does not exist: $PR_DIR" + fi + + # Update manifest + - name: Update manifest + working-directory: gh-pages-checkout + run: | + node ../.github/scripts/remove-from-manifest.mjs "${{ steps.pr-number.outputs.pr_number }}" + + # Commit and push to gh-pages (retry on race with concurrent deploy/cleanup) + - name: Commit changes + working-directory: gh-pages-checkout + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add . + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Remove preview for PR #${{ steps.pr-number.outputs.pr_number }}" + for i in 1 2 3; do + if git push origin gh-pages; then + echo "✅ Changes pushed to gh-pages" + exit 0 + fi + echo "Push failed (concurrent update?), pulling and retrying ($i/3)..." + git pull origin gh-pages --rebase + done + echo "❌ Failed to push after retries" + exit 1 + fi + + # 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..9d0b161a0c --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,259 @@ +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] + branches: [master] + +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: + deploy: + name: Deploy Previews + 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 and deploy options (from PR body when pull_request, from inputs when manual) + - 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 + PR_BODY=$(gh pr view $PR_NUMBER --json body -q '.body' 2>/dev/null || echo "") + if echo "$PR_BODY" | grep -i "Deploy documentation preview" | grep -q "\[x\]"; then + echo "deploy_docs=true" >> $GITHUB_OUTPUT + else + echo "deploy_docs=false" >> $GITHUB_OUTPUT + fi + if echo "$PR_BODY" | grep -i "Deploy Storybook preview" | grep -q "\[x\]"; then + echo "deploy_storybook=true" >> $GITHUB_OUTPUT + else + echo "deploy_storybook=false" >> $GITHUB_OUTPUT + fi + 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 + + # Require at least one preview selected + - name: Check deploy options + run: | + if [ "${{ steps.pr-metadata.outputs.deploy_docs }}" != "true" ] && [ "${{ steps.pr-metadata.outputs.deploy_storybook }}" != "true" ]; then + echo "::error::Check at least one preview box in the PR description (Preview deployment) to deploy, or run the workflow manually." + exit 1 + 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 + + # Build selector page (BEFORE checking out gh-pages) + - name: Build selector page + run: yarn nx run preview-selector:build + + # Checkout gh-pages branch to separate directory + - name: Checkout gh-pages + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: gh-pages + path: gh-pages-checkout + + # Pull latest so concurrent deploys don't overwrite each other's manifest entries + - name: Pull latest gh-pages + working-directory: gh-pages-checkout + run: git pull origin gh-pages + + # Copy builds and selector to gh-pages + - name: Copy builds and selector to gh-pages + env: + DEPLOY_DOCS: ${{ steps.pr-metadata.outputs.deploy_docs }} + DEPLOY_STORYBOOK: ${{ steps.pr-metadata.outputs.deploy_storybook }} + run: | + PR_DIR="gh-pages-checkout/pr-${{ steps.pr-metadata.outputs.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 + + cp -r apps/preview-selector/dist/* gh-pages-checkout/ + + if [ ! -f "gh-pages-checkout/manifest.json" ]; then + echo '{"previews":[],"lastUpdated":"'$(date -u +%Y-%m-%dT%H:%M:%S.000Z)'"}' > gh-pages-checkout/manifest.json + fi + echo "✅ Selector deployed" + + # Update manifest + - name: Update manifest + working-directory: gh-pages-checkout + run: | + node ../.github/scripts/update-manifest.mjs \ + "${{ steps.pr-metadata.outputs.pr_number }}" \ + "${{ steps.pr-metadata.outputs.pr_title }}" \ + "${{ steps.pr-metadata.outputs.pr_branch }}" \ + "${{ steps.pr-metadata.outputs.pr_author }}" \ + "${{ steps.pr-metadata.outputs.pr_commit }}" \ + "true" \ + "true" + + # Commit and push to gh-pages (retry on race with concurrent deploy) + - name: Deploy to gh-pages + working-directory: gh-pages-checkout + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add . + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Deploy preview for PR #${{ steps.pr-metadata.outputs.pr_number }}" + for i in 1 2 3; do + if git push origin gh-pages; then + echo "✅ Deployed to gh-pages" + exit 0 + fi + echo "Push failed (concurrent update?), pulling and retrying ($i/3)..." + git pull origin gh-pages --rebase + done + echo "❌ Failed to push after retries" + exit 1 + fi + + # 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..7ac54af8f6 --- /dev/null +++ b/.github/workflows/preview-manual-cleanup.yml @@ -0,0 +1,92 @@ +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 + + # Remove PR directory + - name: Remove preview directory + run: | + PR_DIR="gh-pages-checkout/pr-${{ github.event.inputs.pr_number }}" + + if [ -d "$PR_DIR" ]; then + echo "Removing preview directory: $PR_DIR" + rm -rf "$PR_DIR" + echo "✅ Directory removed" + else + echo "⚠️ Directory does not exist: $PR_DIR" + fi + + # Update manifest + - name: Update manifest + run: | + cd gh-pages-checkout + + if [ ! -f "manifest.json" ]; then + echo "⚠️ manifest.json does not exist, nothing to update" + exit 0 + fi + + # Copy script from main branch + cp ../.github/scripts/remove-from-manifest.mjs . + + node remove-from-manifest.mjs "${{ github.event.inputs.pr_number }}" + + rm remove-from-manifest.mjs + + # Commit and push to gh-pages + - name: Commit changes + run: | + cd gh-pages-checkout + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add . + + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Manual cleanup: Remove preview for PR #${{ github.event.inputs.pr_number }}" + git push origin gh-pages + echo "✅ Changes pushed to gh-pages" + fi + + # 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/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..6bc3923ffa --- /dev/null +++ b/apps/preview-selector/README.md @@ -0,0 +1,203 @@ +# 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.json` file contains mock PR data for local development. + +**Edit this file 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.json` at `/cds/manifest.json`. + +## 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.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.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/public/manifest.json b/apps/preview-selector/public/manifest.json new file mode 100644 index 0000000000..40da3fce8c --- /dev/null +++ b/apps/preview-selector/public/manifest.json @@ -0,0 +1,61 @@ +{ + "previews": [ + { + "pr": 123, + "title": "Add Button component with docs and stories", + "branch": "feature/button", + "author": "octocat", + "baseUrl": "/cds/pr-123/", + "previews": { + "docs": "/cds/pr-123/docs/", + "storybook": "/cds/pr-123/storybook/" + }, + "createdAt": "2026-02-12T10:00:00Z", + "updatedAt": "2026-02-12T15:30:00Z", + "commit": "abc1234" + }, + { + "pr": 456, + "title": "Update theme colors (docs only)", + "branch": "feature/colors", + "author": "github-user", + "baseUrl": "/cds/pr-456/", + "previews": { + "docs": "/cds/pr-456/docs/", + "storybook": null + }, + "createdAt": "2026-02-11T10:00:00Z", + "updatedAt": "2026-02-12T12:00:00Z", + "commit": "def5678" + }, + { + "pr": 789, + "title": "Refactor components (storybook only)", + "branch": "refactor/components", + "author": "contributor", + "baseUrl": "/cds/pr-789/", + "previews": { + "docs": null, + "storybook": "/cds/pr-789/storybook/" + }, + "createdAt": "2026-02-10T09:00:00Z", + "updatedAt": "2026-02-11T14:00:00Z", + "commit": "ghi9012" + }, + { + "pr": 234, + "title": "Add new TextField component", + "branch": "feature/textfield", + "author": "designer", + "baseUrl": "/cds/pr-234/", + "previews": { + "docs": "/cds/pr-234/docs/", + "storybook": "/cds/pr-234/storybook/" + }, + "createdAt": "2026-02-13T08:00:00Z", + "updatedAt": "2026-02-13T16:00:00Z", + "commit": "jkl3456" + } + ], + "lastUpdated": "2026-02-13T16:00:00Z" +} diff --git a/apps/preview-selector/src/App.tsx b/apps/preview-selector/src/App.tsx new file mode 100644 index 0000000000..93edd5d942 --- /dev/null +++ b/apps/preview-selector/src/App.tsx @@ -0,0 +1,259 @@ +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'; + +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(() => { + fetch('/cds/manifest.json') + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then((data: Manifest) => { + setManifest(data); + setLoading(false); + }) + .catch((err) => { + console.error('Failed to load previews:', err); + setError(true); + setLoading(false); + }); + }, []); + + 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 &&