From 3c9fcee20e1a94456e0c38fd0d3d96107d94b1ed Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 4 Jul 2025 23:53:03 -0700 Subject: [PATCH 1/2] Add working directory verification guidance to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add critical guidance about verifying working directory before running commands due to complex submodule architecture and multiple working directories. Key guidance: • Always verify current working directory with pwd before commands • Main repository root: ./ (top-level directory with _config.yml) • Theme submodule: ./_theme/ (contains Jekyll theme files) • Preview cleanup: /tmp/preview-cleanup/ (temporary cleanup workspace) • Temporary directories: _tmp/preview-repo/ (relative to repository root) Common mistakes addressed: • Running Jekyll commands from wrong directory • Editing theme files when not in _theme/ directory • Git operations in wrong repository context This prevents common development errors and ensures commands are run in the correct context for this complex repository architecture. --- .claude/settings.local.json | 33 ++++++++++++++++++++++++++++++--- CLAUDE.md | 15 ++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 53f20db7..8dbf2849 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,7 @@ "Bash(git commit:*)", "Bash(git submodule:*)", "Bash(ln:*)", - "Bash(export PATH=$PATH:/home/tim/.local/share/gem/ruby/3.2.0/bin)", + "Bash(export PATH=$PATH:~/.local/share/gem/ruby/3.2.0/bin)", "Bash(jekyll --version)", "Bash(bundle exec jekyll:*)", "Bash(git stash:*)", @@ -53,7 +53,7 @@ "Bash(gh workflow:*)", "Bash(git push:*)", "Bash(grep:*)", - "Bash(export PATH=$PATH:/home/tim/.local/share/gem/ruby/3.2.0/bin)", + "Bash(export PATH=$PATH:~/.local/share/gem/ruby/3.2.0/bin)", "Bash(make:*)", "Bash(export:*)", "Bash(bundle install)", @@ -61,7 +61,34 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(bundle exec:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(gh pr create:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(ruby --version)", + "Bash(gh pr checks:*)", + "Bash(gh run watch:*)", + "Bash(curl:*)", + "Bash(gh pr:*)", + "Bash(nslookup:*)", + "Bash(git config:*)", + "Bash(git rm:*)", + "Bash(./fix_asset_paths.sh:*)", + "Bash(./fix_relative_url.sh:*)", + "Bash(rg:*)", + "Bash(yamllint:*)", + "WebFetch(domain:preview.wafer.space)", + "Bash(for file in nav-*.html one-page-nav-*.html _offcanvas-info.html)", + "Bash(do if [ -f \"$file\" ])", + "Bash(then sed -i \"s/{{site.url}}/{{ ''\\/'' | relative_url }}/g\" \"$file\")", + "Bash(fi)", + "Bash(done)", + "Bash(for file in nav-*.html one-page-nav*.html)", + "Bash(then sed -i 's/href=\"\"{{menu\\.url}}\"\"/href=\"\"{{menu.url | relative_url}}\"\"/g' \"$file\")", + "Bash(for file in _includes/layouts/nav/nav-*.html _includes/layouts/nav/one-page-nav*.html)", + "Bash(then echo \"Processing $file\")", + "Bash(node:*)", + "WebFetch(domain:github.com)" ], "deny": [] } diff --git a/CLAUDE.md b/CLAUDE.md index 138b690b..4cafd35f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands +### CRITICAL: Working Directory Verification +**ALWAYS verify your current working directory before running any commands.** This repository has a complex architecture with theme submodules and various working directories. Always check `pwd` and ensure you're in the expected location: + +- **Main repository root**: `./` (top-level directory with _config.yml) +- **Theme submodule**: `./_theme/` (contains Jekyll theme files) +- **Preview cleanup**: `/tmp/preview-cleanup/` (temporary cleanup workspace) +- **Temporary directories**: `_tmp/preview-repo/` (relative to repository root) + +Common mistakes: +- Running Jekyll commands from wrong directory +- Editing theme files when not in `_theme/` directory +- Git operations in wrong repository context + ### Environment Setup Set Ruby path before running any Jekyll commands: ```bash -export PATH=$PATH:/home/tim/.local/share/gem/ruby/3.2.0/bin +export PATH=$PATH:~/.local/share/gem/ruby/3.2.0/bin ``` ### Essential Commands From aacf4a7dd3424c8c9a043f421123863579441dd4 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 4 Jul 2025 23:53:15 -0700 Subject: [PATCH 2/2] Add PR preview deployment system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a comprehensive GitHub Actions workflow for automatically deploying pull request previews to a custom domain at preview.wafer.space. Core features: • Automatic PR preview deployments at https://preview.wafer.space/pr-{number}/ • GitHub Deployments API integration with status tracking • Secure SSH authentication for private theme submodule access • Custom domain deployment using dedicated preview repository • Automatic cleanup when PRs are closed • Professional PR comments with deployment status • Central preview index page listing all active previews Architecture: • Modular JavaScript modules for GitHub API interactions • Reusable Markdown templates for consistent messaging • Jekyll builds with PR-specific baseurl configuration • Dedicated preview.wafer.space repository avoids CNAME conflicts • Memory-only SSH key handling for enhanced security This provides a solid foundation for PR preview functionality with enterprise-grade security practices and maintainable code organization. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/scripts/cleanup-deployments.js | 45 +++ .github/scripts/comment-pr-cleanup.js | 40 ++ .github/scripts/comment-pr-preview.js | 86 ++++ .github/scripts/create-deployment.js | 47 +++ .github/scripts/generate-pr-directory-name.js | 32 ++ .github/scripts/generate-preview-index.sh | 94 +++++ .github/scripts/update-deployment-status.js | 65 +++ .github/templates/pr-cleanup-template.md | 3 + .github/templates/pr-comment-template.md | 12 + .github/templates/preview-index.html | 104 +++++ .github/templates/redirect-template.html | 13 + .github/workflows/pr-preview.md | 55 +++ .github/workflows/pr-preview.yml | 370 ++++++++++++++++++ 13 files changed, 966 insertions(+) create mode 100644 .github/scripts/cleanup-deployments.js create mode 100644 .github/scripts/comment-pr-cleanup.js create mode 100644 .github/scripts/comment-pr-preview.js create mode 100644 .github/scripts/create-deployment.js create mode 100644 .github/scripts/generate-pr-directory-name.js create mode 100755 .github/scripts/generate-preview-index.sh create mode 100644 .github/scripts/update-deployment-status.js create mode 100644 .github/templates/pr-cleanup-template.md create mode 100644 .github/templates/pr-comment-template.md create mode 100644 .github/templates/preview-index.html create mode 100644 .github/templates/redirect-template.html create mode 100644 .github/workflows/pr-preview.md create mode 100644 .github/workflows/pr-preview.yml diff --git a/.github/scripts/cleanup-deployments.js b/.github/scripts/cleanup-deployments.js new file mode 100644 index 00000000..83b7167c --- /dev/null +++ b/.github/scripts/cleanup-deployments.js @@ -0,0 +1,45 @@ +// Mark GitHub deployments as inactive when PR is closed +// This module exports a function that can be safely called by actions/github-script + +module.exports = async function cleanupDeployments(github, context, core) { + // Validate inputs + if (!context.payload.pull_request) { + throw new Error('No pull request data available'); + } + + const prNumber = context.payload.pull_request.number; + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + const environment = `pr-preview-${prNumber}`; + + // Get all deployments for this PR environment + const { data: deployments } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: environment, + }); + + console.log(`Found ${deployments.length} deployments for environment: ${environment}`); + + // Mark each deployment as inactive + for (const deployment of deployments) { + // Validate deployment ID + if (!deployment.id || !Number.isInteger(deployment.id)) { + console.log(`Skipping invalid deployment ID: ${deployment.id}`); + continue; + } + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'inactive', + description: 'PR closed - preview removed' + }); + console.log(`Marked deployment ${deployment.id} as inactive`); + } + + console.log(`Cleanup completed for PR #${prNumber}`); +}; \ No newline at end of file diff --git a/.github/scripts/comment-pr-cleanup.js b/.github/scripts/comment-pr-cleanup.js new file mode 100644 index 00000000..9228520c --- /dev/null +++ b/.github/scripts/comment-pr-cleanup.js @@ -0,0 +1,40 @@ +// Comment on PR when preview is cleaned up +// This module exports a function that can be safely called by actions/github-script + +module.exports = async function commentPrCleanup(github, context, core) { + // Validate inputs + if (!context.payload.pull_request) { + throw new Error('No pull request data available'); + } + + const prNumber = context.payload.pull_request.number; + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + // Read comment template with path validation + const fs = require('fs'); + const path = require('path'); + const templatePath = '.github/templates/pr-cleanup-template.md'; + + // Validate template path to prevent directory traversal + const resolvedPath = path.resolve(templatePath); + if (!resolvedPath.includes('.github/templates/pr-cleanup-template.md')) { + throw new Error('Invalid template path'); + } + + if (!fs.existsSync(templatePath)) { + throw new Error(`Template file ${templatePath} not found`); + } + + const commentBody = fs.readFileSync(templatePath, 'utf8'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody + }); + + console.log(`Posted cleanup comment on PR #${prNumber}`); +}; \ No newline at end of file diff --git a/.github/scripts/comment-pr-preview.js b/.github/scripts/comment-pr-preview.js new file mode 100644 index 00000000..49b57e87 --- /dev/null +++ b/.github/scripts/comment-pr-preview.js @@ -0,0 +1,86 @@ +// Comment on PR with preview deployment information +// This module exports a function that can be safely called by actions/github-script + +module.exports = async function commentPrPreview(github, context, core) { + // Validate inputs + if (!context.payload.pull_request) { + throw new Error('No pull request data available'); + } + + const prNumber = context.payload.pull_request.number; + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + const rawCommitSha = context.payload.pull_request.head.sha; + if (!rawCommitSha || !rawCommitSha.match(/^[a-f0-9]{40}$/)) { + throw new Error('Invalid commit SHA format'); + } + + const previewUrl = `https://preview.wafer.space/pr-${prNumber}/`; + const commitSha = rawCommitSha.substring(0, 7); + + // Read comment template with path validation + const fs = require('fs'); + const path = require('path'); + const templatePath = '.github/templates/pr-comment-template.md'; + + // Validate template path to prevent directory traversal + const resolvedPath = path.resolve(templatePath); + if (!resolvedPath.includes('.github/templates/pr-comment-template.md')) { + throw new Error('Invalid template path'); + } + + if (!fs.existsSync(templatePath)) { + throw new Error(`Template file ${templatePath} not found`); + } + + let commentBody = fs.readFileSync(templatePath, 'utf8'); + + // Sanitize and replace placeholders in template + const sanitizedPreviewUrl = previewUrl.replace(/[<>&"']/g, (match) => { + const entityMap = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }; + return entityMap[match]; + }); + + const sanitizedCommitSha = commitSha.replace(/[<>&"']/g, (match) => { + const entityMap = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }; + return entityMap[match]; + }); + + commentBody = commentBody + .replace(/\{\{PREVIEW_URL\}\}/g, sanitizedPreviewUrl) + .replace(/\{\{COMMIT_SHA\}\}/g, sanitizedCommitSha); + + // Find existing comment + 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 Deployment Ready!') || + comment.body.includes('Preview Deployment Partially Ready') || + comment.body.includes('Preview Deployment Failed')) + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + console.log(`Updated existing comment ${botComment.id} on PR #${prNumber}`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody + }); + console.log(`Created new comment on PR #${prNumber}`); + } +}; \ No newline at end of file diff --git a/.github/scripts/create-deployment.js b/.github/scripts/create-deployment.js new file mode 100644 index 00000000..495d6397 --- /dev/null +++ b/.github/scripts/create-deployment.js @@ -0,0 +1,47 @@ +// Create GitHub deployment for PR preview +// This module exports a function that can be safely called by actions/github-script + +module.exports = async function createDeployment(github, context, core) { + // Validate inputs + if (!context.payload.pull_request) { + throw new Error('No pull request data available'); + } + + const prNumber = context.payload.pull_request.number; + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + // Sanitize PR title to prevent code injection + const rawTitle = context.payload.pull_request.title || ''; + const prTitle = rawTitle.replace(/[^\w\s-_.]/g, '').substring(0, 100); + + // Validate ref format + const ref = context.payload.pull_request.head.ref; + if (!ref || ref.length > 255) { + throw new Error('Invalid ref format'); + } + + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + environment: `pr-preview-${prNumber}`, + transient_environment: true, + production_environment: false, + required_contexts: [], + description: `PR #${prNumber}: ${prTitle}`, + auto_merge: false + }); + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.data.id, + state: 'in_progress', + description: 'Building preview...' + }); + + core.setOutput('deployment_id', deployment.data.id); + console.log(`Created deployment ${deployment.data.id} for PR #${prNumber}`); +}; \ No newline at end of file diff --git a/.github/scripts/generate-pr-directory-name.js b/.github/scripts/generate-pr-directory-name.js new file mode 100644 index 00000000..9a2b557b --- /dev/null +++ b/.github/scripts/generate-pr-directory-name.js @@ -0,0 +1,32 @@ +// Generate user-friendly directory name for PR previews +// This module exports a function that creates a slugified directory name from PR title + +module.exports = function generatePrDirectoryName(prNumber, prTitle) { + // Validate inputs + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + if (typeof prTitle !== 'string') { + throw new Error('PR title must be a string'); + } + + // Slugify the PR title + const slugifiedTitle = prTitle + .toLowerCase() + .trim() + // Replace special characters and spaces with hyphens + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + // Limit length to reasonable size + .substring(0, 50) + .replace(/-+$/, ''); // Remove trailing hyphens after truncation + + // Ensure we have a valid slug + const finalSlug = slugifiedTitle || 'untitled'; + + // Format: pr-{number}-{slug} + return `pr-${prNumber}-${finalSlug}`; +}; \ No newline at end of file diff --git a/.github/scripts/generate-preview-index.sh b/.github/scripts/generate-preview-index.sh new file mode 100755 index 00000000..bbc3cf84 --- /dev/null +++ b/.github/scripts/generate-preview-index.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -e # Exit on any error + +# Generate the preview index page +TEMPLATE_FILE=".github/templates/preview-index.html" +OUTPUT_FILE="index.html" + +# Validate template file exists +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "ERROR: Template file $TEMPLATE_FILE not found!" + echo "This indicates a problem with the workflow setup." + exit 1 +fi + +# Start with the template +if ! cp "$TEMPLATE_FILE" "$OUTPUT_FILE"; then + echo "ERROR: Failed to copy template file to $OUTPUT_FILE" + exit 1 +fi + +# Generate preview items HTML +PREVIEW_ITEMS="" +FOUND_PREVIEWS=false +PREVIEW_COUNT=0 + +for dir in pr-*/; do + if [ -d "$dir" ] && [ "$dir" != "pr-preview/" ]; then + # Validate directory contains expected files + if [ ! -f "$dir/index.html" ]; then + echo "Warning: $dir appears to be incomplete (no index.html), skipping..." + continue + fi + + # Extract PR number from directory name (handles both old pr-123 and new pr-123-title formats) + pr_num=$(basename "$dir" | sed 's/pr-\([0-9][0-9]*\).*/\1/') + # Validate PR number is numeric + if ! [[ "$pr_num" =~ ^[0-9]+$ ]]; then + echo "Warning: $dir has invalid PR number format, skipping..." + continue + fi + + # Extract title part if it exists (for display purposes) + title_part=$(basename "$dir" | sed 's/pr-[0-9][0-9]*-\(.*\)/\1/' | sed 's/-/ /g') + if [ "$title_part" = "$(basename "$dir")" ]; then + # No title part found, use empty string + title_part="" + fi + + # Additional validation: PR number must be reasonable range + if [ "$pr_num" -lt 1 ] || [ "$pr_num" -gt 99999 ]; then + echo "Warning: $dir has PR number out of valid range, skipping..." + continue + fi + + # HTML escape the directory name to prevent injection + dir_escaped=$(echo "$dir" | sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g') + pr_num_escaped=$(echo "$pr_num" | sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g') + title_part_escaped=$(echo "$title_part" | sed 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g') + + # Create display title + if [ -n "$title_part" ]; then + display_title="PR #$pr_num_escaped: $title_part_escaped" + else + display_title="PR #$pr_num_escaped Preview" + fi + + PREVIEW_ITEMS+="
  • " + PREVIEW_ITEMS+="$display_title" + PREVIEW_ITEMS+="
    https://preview.wafer.space/$dir_escaped
    " + PREVIEW_ITEMS+="
  • " + FOUND_PREVIEWS=true + PREVIEW_COUNT=$((PREVIEW_COUNT + 1)) + fi +done + +# If no previews found, show empty state +if [ "$FOUND_PREVIEWS" = false ]; then + PREVIEW_ITEMS="" +fi + +# Replace placeholder with actual items +if ! sed -i "s||$PREVIEW_ITEMS|g" "$OUTPUT_FILE"; then + echo "ERROR: Failed to replace placeholder in template" + echo "Check template file format and placeholder existence" + exit 1 +fi + +# Validate output file was created successfully +if [ ! -f "$OUTPUT_FILE" ] || [ ! -s "$OUTPUT_FILE" ]; then + echo "ERROR: Output file $OUTPUT_FILE was not created or is empty" + exit 1 +fi + +echo "Generated preview index with $PREVIEW_COUNT active previews" \ No newline at end of file diff --git a/.github/scripts/update-deployment-status.js b/.github/scripts/update-deployment-status.js new file mode 100644 index 00000000..d5660cd4 --- /dev/null +++ b/.github/scripts/update-deployment-status.js @@ -0,0 +1,65 @@ +// Update GitHub deployment status for PR preview +// This module exports a function that can be safely called by actions/github-script + +module.exports = async function updateDeploymentStatus(github, context, core) { + const deploymentId = process.env.DEPLOYMENT_ID; + const jobStatus = process.env.JOB_STATUS; + const verificationStatus = process.env.VERIFICATION_STATUS; + + // Validate inputs + if (!deploymentId) { + throw new Error('DEPLOYMENT_ID environment variable is required'); + } + + if (!deploymentId.match(/^\d+$/)) { + throw new Error('Invalid deployment ID format'); + } + + if (!context.payload.pull_request) { + throw new Error('No pull request data available'); + } + + const prNumber = context.payload.pull_request.number; + if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { + throw new Error('Invalid PR number'); + } + + // Determine deployment state based on job status and verification results + let state, description; + + if (jobStatus === 'failure') { + state = 'failure'; + description = 'Preview deployment failed'; + } else if (verificationStatus === 'success') { + state = 'success'; + description = 'Preview deployed and verified'; + } else if (verificationStatus === 'partial') { + state = 'success'; // GitHub deployment API doesn't have 'partial' state + description = 'Preview deployed but verification incomplete'; + } else if (verificationStatus === 'failed') { + state = 'failure'; + description = 'Preview deployment verification failed'; + } else { + // Fallback to old behavior if verification status not available + state = jobStatus === 'success' ? 'success' : 'failure'; + description = state === 'success' ? 'Preview deployed to custom domain' : 'Preview deployment failed'; + } + + const validStates = ['success', 'failure', 'error', 'pending']; + if (!validStates.includes(state)) { + throw new Error(`Invalid deployment state: ${state}`); + } + + const previewUrl = `https://preview.wafer.space/pr-${prNumber}/`; + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deploymentId, + state: state, + environment_url: previewUrl, + description: description + }); + + console.log(`Updated deployment ${deploymentId} status to: ${state} (${description})`); +}; \ No newline at end of file diff --git a/.github/templates/pr-cleanup-template.md b/.github/templates/pr-cleanup-template.md new file mode 100644 index 00000000..c4354758 --- /dev/null +++ b/.github/templates/pr-cleanup-template.md @@ -0,0 +1,3 @@ +## 🧹 Preview Deployment Removed + +The preview deployment for this PR has been removed from https://preview.wafer.space \ No newline at end of file diff --git a/.github/templates/pr-comment-template.md b/.github/templates/pr-comment-template.md new file mode 100644 index 00000000..f0dff0fc --- /dev/null +++ b/.github/templates/pr-comment-template.md @@ -0,0 +1,12 @@ +## 🚀 Preview Deployment Ready! + +| Status | Preview URL | Commit | +|--------|-------------|--------| +| ✅ Success | [View Preview]({{PREVIEW_URL}}) | `{{COMMIT_SHA}}` | + +This preview will be automatically updated when you push new commits to this PR. + +**Browse all previews:** https://preview.wafer.space + +--- +⚡ Deployed to custom domain • Preview will be removed when PR is closed \ No newline at end of file diff --git a/.github/templates/preview-index.html b/.github/templates/preview-index.html new file mode 100644 index 00000000..c8ad8029 --- /dev/null +++ b/.github/templates/preview-index.html @@ -0,0 +1,104 @@ + + + + PR Previews - Wafer Space + + + +
    +
    +

    🚀 Wafer Space PR Previews

    +

    Active preview deployments for pull requests

    + ← Back to wafer.space +
    + +
      + +
    + + +
    + + \ No newline at end of file diff --git a/.github/templates/redirect-template.html b/.github/templates/redirect-template.html new file mode 100644 index 00000000..cd7de17d --- /dev/null +++ b/.github/templates/redirect-template.html @@ -0,0 +1,13 @@ + + + + + Redirecting... + + + + + +

    Redirecting to {{TARGET_URL}}/...

    + + \ No newline at end of file diff --git a/.github/workflows/pr-preview.md b/.github/workflows/pr-preview.md new file mode 100644 index 00000000..99f35ef1 --- /dev/null +++ b/.github/workflows/pr-preview.md @@ -0,0 +1,55 @@ +# PR Preview Deployment + +Automatic preview deployments for pull requests using GitHub Pages with custom domain. + +## Features + +- **Live preview URLs** - `https://preview.wafer.space/pr-123/` +- **Automatic updates** - Rebuilds on every commit +- **Preview index** - All active previews at `https://preview.wafer.space/` +- **Deployment tracking** - GitHub deployment status integration +- **Automatic cleanup** - Removed when PR closes + +## How It Works + +When you open a pull request: +1. Jekyll site is built automatically +2. Deployed to custom domain at `/pr-[number]/` +3. PR is commented with preview URL +4. Updates on new commits +5. Cleaned up when PR closes + +## Viewing Previews + +Click the preview URL in the PR comment: +``` +https://preview.wafer.space/pr-123/ +``` + +Browse all active previews: +``` +https://preview.wafer.space/ +``` + +## Troubleshooting + +### Preview not appearing +- Check GitHub Actions tab for workflow status +- Ensure custom domain DNS is configured for `preview.wafer.space` +- Wait up to 2 minutes for GitHub Pages deployment to complete +- Verify SSH deploy key is configured for theme submodule access + +### Preview shows 404 error +- Check if Jekyll build completed successfully in workflow logs +- Verify PR base URL configuration in workflow +- Ensure theme submodule initialized correctly + +### Preview index missing PRs +- Check script validation warnings in workflow logs +- Ensure preview directories contain `index.html` file +- Verify PR numbers are numeric format only + +### Deployment failures +- Check for merge conflicts in gh-pages branch +- Verify GitHub Pages is enabled for repository +- Ensure workflow has required permissions (contents, pages, deployments) \ No newline at end of file diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 00000000..d9f7a08c --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,370 @@ +# PR Preview Deployment using GitHub Pages with Custom Domain +# This workflow builds the Jekyll site and deploys it to GitHub Pages for pull request previews +name: PR Preview Deployment + +on: + pull_request: + branches: ["main"] + types: [opened, synchronize, reopened, closed] + +permissions: + contents: write + pull-requests: write + deployments: write + pages: write + id-token: write + +# Prevent concurrent deployments to avoid gh-pages conflicts +concurrency: + group: pr-preview-deployment + cancel-in-progress: false + +jobs: + deploy-preview: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + submodules: false + + - name: Create deployment + id: deployment + uses: actions/github-script@v7 + with: + script: | + const createDeployment = require('./.github/scripts/create-deployment.js'); + await createDeployment(github, context, core); + + - name: Initialize submodules with SSH + run: | + # Setup SSH agent and add key for this step only + eval $(ssh-agent -s) + echo "${{ secrets.JEKYLL_THEME_KEY }}" | ssh-add - + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # Initialize submodules using SSH + git submodule sync --recursive + git submodule update --init --recursive + + - name: Setup Ruby + uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 + with: + ruby-version: '3.1' # Match main workflow - will use .ruby-version if present + bundler-cache: true + cache-version: 0 + + - name: Generate PR directory name and slugified redirect + id: pr-directory + uses: actions/github-script@v7 + with: + script: | + const generatePrDirectoryName = require('./.github/scripts/generate-pr-directory-name.js'); + const prNumber = context.payload.pull_request.number; + const prTitle = context.payload.pull_request.title; + + // Main directory uses just PR number for simplicity + const directoryName = `pr-${prNumber}`; + // Slugified version for user-friendly redirect + const slugifiedName = generatePrDirectoryName(prNumber, prTitle); + + console.log(`Main directory: ${directoryName}`); + console.log(`Slugified redirect: ${slugifiedName}`); + + // Set outputs + core.setOutput('directory_name', directoryName); + core.setOutput('slugified_name', slugifiedName); + core.setOutput('baseurl_path', `/${directoryName}`); + + return directoryName; + + - name: Build Jekyll site + run: | + # Build site with PR-specific baseurl + bundle exec jekyll build --baseurl "${{ steps.pr-directory.outputs.baseurl_path }}" + env: + JEKYLL_ENV: production + + - name: Save built site and templates + run: | + # Copy built site and workflow files to temp location before switching branches + cp -r _site /tmp/pr-site-${{ steps.pr-directory.outputs.directory_name }} + cp -r .github /tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }} + + - name: Setup SSH and clone preview repository + run: | + # Setup SSH for deploying to dedicated preview repository (memory-only) + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # Clone using SSH with ssh-agent authentication + git clone git@github.com:wafer-space/preview.wafer.space.git preview-repo + + cd preview-repo + # Configure git for commits + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Create PR directory and user-friendly redirect + run: | + cd preview-repo + # Create main directory for this PR (pr-123) + mkdir -p ${{ steps.pr-directory.outputs.directory_name }} + # Copy the built site from temp location to the preview directory + cp -r /tmp/pr-site-${{ steps.pr-directory.outputs.directory_name }}/* ${{ steps.pr-directory.outputs.directory_name }}/ + + # Create redirect page for slugified URL if different from main directory + if [ "${{ steps.pr-directory.outputs.slugified_name }}" != "${{ steps.pr-directory.outputs.directory_name }}" ]; then + mkdir -p ${{ steps.pr-directory.outputs.slugified_name }} + # Copy redirect template and replace placeholders + cp /tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/templates/redirect-template.html ${{ steps.pr-directory.outputs.slugified_name }}/index.html + sed -i "s|{{TARGET_URL}}|/${{ steps.pr-directory.outputs.directory_name }}|g" ${{ steps.pr-directory.outputs.slugified_name }}/index.html + echo "Created user-friendly redirect from ${{ steps.pr-directory.outputs.slugified_name }} to ${{ steps.pr-directory.outputs.directory_name }}" + fi + + - name: Ensure CNAME file exists + run: | + cd preview-repo + # Ensure CNAME file exists (should already be configured in preview repo) + if [ ! -f CNAME ]; then + echo "preview.wafer.space" > CNAME + fi + + - name: Create index page for preview listing + run: | + cd preview-repo + # Copy script and template from temp location, then generate preview index + if [ ! -f "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/scripts/generate-preview-index.sh" ]; then + echo "ERROR: Preview generation script not found in temp location" + exit 1 + fi + if [ ! -f "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/templates/preview-index.html" ]; then + echo "ERROR: Preview template not found in temp location" + exit 1 + fi + + cp "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/scripts/generate-preview-index.sh" ./ + mkdir -p .github/templates + cp "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/templates/preview-index.html" .github/templates/ + chmod +x generate-preview-index.sh + ./generate-preview-index.sh + + - name: Deploy to preview repository + run: | + cd preview-repo + git add . + if git diff --staged --quiet; then + echo "No changes to commit for preview deployment" + exit 1 + else + git commit -m "Deploy preview for PR #${{ github.event.pull_request.number }} (${{ steps.pr-directory.outputs.directory_name }}) - ${{ github.event.pull_request.head.sha }}" + fi + + # Re-setup SSH agent for push (ssh-agent doesn't persist between steps) + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + git push origin main + + - name: Wait for Pages deployment and verify accessibility + id: verify-deployment + run: | + # Poll for Pages deployment completion with comprehensive verification + echo "Waiting for GitHub Pages deployment to complete..." + PREVIEW_URL="https://preview.wafer.space/pr-${{ github.event.pull_request.number }}/" + + # First wait for basic HTTP response (shorter attempts, more frequent) + echo "Phase 1: Waiting for HTTP response..." + for i in {1..18}; do + sleep 10 + echo "Attempt $i/18: Checking basic HTTP response at $PREVIEW_URL..." + + if curl -s --fail --head "$PREVIEW_URL"; then + echo "Basic HTTP response received. Moving to content verification..." + break + fi + + if [ $i -eq 18 ]; then + echo "ERROR: No HTTP response after 3 minutes." + echo "deployment_status=failed" >> $GITHUB_OUTPUT + echo "error_message=Pages deployment failed - no HTTP response" >> $GITHUB_OUTPUT + exit 1 + fi + done + + # Second phase: Verify actual content is served correctly + echo "Phase 2: Verifying content delivery..." + for i in {1..12}; do + sleep 5 + echo "Content verification attempt $i/12..." + + # Check that the page returns expected content (not error pages) + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL") + CONTENT_CHECK=$(curl -s "$PREVIEW_URL" | grep -q "wafer.space" && echo "found" || echo "missing") + + if [ "$HTTP_STATUS" = "200" ] && [ "$CONTENT_CHECK" = "found" ]; then + echo "✅ Deployment verification successful!" + echo "Preview is fully accessible at: $PREVIEW_URL" + echo "HTTP Status: $HTTP_STATUS" + echo "Content verification: passed" + echo "deployment_status=success" >> $GITHUB_OUTPUT + echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT + exit 0 + else + echo "Content check failed - HTTP: $HTTP_STATUS, Content: $CONTENT_CHECK" + fi + done + + echo "⚠️ WARNING: Deployment may not be fully ready after 4 minutes total." + echo "HTTP response received but content verification incomplete." + echo "deployment_status=partial" >> $GITHUB_OUTPUT + echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT + echo "error_message=Content verification incomplete after 4 minutes" >> $GITHUB_OUTPUT + + - name: Update deployment status + if: always() + uses: actions/github-script@v7 + env: + DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }} + JOB_STATUS: ${{ job.status }} + VERIFICATION_STATUS: ${{ steps.verify-deployment.outputs.deployment_status }} + with: + script: | + const updateDeploymentStatus = require('./.github/scripts/update-deployment-status.js'); + await updateDeploymentStatus(github, context, core); + + - name: Comment PR with preview URL + if: steps.verify-deployment.outputs.deployment_status == 'success' + uses: actions/github-script@v7 + with: + script: | + const commentPrPreview = require('./.github/scripts/comment-pr-preview.js'); + await commentPrPreview(github, context, core); + + - name: Comment PR with deployment warning + if: steps.verify-deployment.outputs.deployment_status == 'partial' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const previewUrl = '${{ steps.verify-deployment.outputs.preview_url }}'; + const errorMessage = '${{ steps.verify-deployment.outputs.error_message }}'; + + const warningBody = `## ⚠️ Preview Deployment Partially Ready + + | Status | Preview URL | Issue | + |--------|-------------|-------| + | 🟡 Partial | [View Preview](${previewUrl}) | ${errorMessage} | + + The preview has been deployed but content verification is incomplete. The site may still be propagating or there may be loading issues. + + **Manual verification recommended:** ${previewUrl} + + --- + ⚡ Deployed to custom domain • Preview will be removed when PR is closed`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: warningBody + }); + + - name: Comment PR with deployment failure + if: steps.verify-deployment.outputs.deployment_status == 'failed' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const errorMessage = '${{ steps.verify-deployment.outputs.error_message }}'; + + const failureBody = `## ❌ Preview Deployment Failed + + | Status | Error | + |--------|-------| + | 🔴 Failed | ${errorMessage} | + + The preview deployment failed to complete successfully. This may be due to: + - GitHub Pages service issues + - DNS propagation delays + - Build or deployment errors + + Please check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. + + --- + ⚡ Deployment failed • Check workflow logs for troubleshooting`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: failureBody + }); + + cleanup-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout main repo for scripts + uses: actions/checkout@v4 + + - name: Setup SSH and cleanup preview repository + run: | + # Setup SSH for deploying to dedicated preview repository (memory-only) + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # Clone using SSH with ssh-agent authentication + git clone git@github.com:wafer-space/preview.wafer.space.git preview-repo + cd preview-repo + + # Configure git for commits + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + # Remove main PR directory + rm -rf pr-${{ github.event.pull_request.number }} + + # Remove all user-friendly redirect directories for this PR (pr-123-*) + # Note: This may not find any directories if none exist, which is expected + find . -maxdepth 1 -type d -name "pr-${{ github.event.pull_request.number }}-*" -exec rm -rf {} + || echo "No redirect directories found for PR #${{ github.event.pull_request.number }} (this is normal)" + + echo "Cleaned up preview and redirects for PR #${{ github.event.pull_request.number }}" + + # Copy script and template, then regenerate index + cp ${{ github.workspace }}/.github/scripts/generate-preview-index.sh ./ + mkdir -p .github/templates + cp ${{ github.workspace }}/.github/templates/preview-index.html .github/templates/ + chmod +x generate-preview-index.sh + ./generate-preview-index.sh + + # Commit and push cleanup + git add . + if ! git diff --staged --quiet; then + git commit -m "Remove preview for closed PR #${{ github.event.pull_request.number }}" + + # Re-setup SSH agent for push (ssh-agent doesn't persist between steps) + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + git push origin main + fi + + - name: Mark deployment as inactive + uses: actions/github-script@v7 + with: + script: | + const cleanupDeployments = require('./.github/scripts/cleanup-deployments.js'); + await cleanupDeployments(github, context, core); + + - name: Comment PR about cleanup + uses: actions/github-script@v7 + with: + script: | + const commentPrCleanup = require('./.github/scripts/comment-pr-cleanup.js'); + await commentPrCleanup(github, context, core); \ No newline at end of file