diff --git a/.github/scripts/comment-verification-results.js b/.github/scripts/comment-verification-results.js index 51c56d43..68c7b041 100644 --- a/.github/scripts/comment-verification-results.js +++ b/.github/scripts/comment-verification-results.js @@ -5,14 +5,11 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); module.exports = async function commentVerificationResults(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; + // Read PR number from env (works for both workflow_run and workflow_dispatch + // contexts, unlike context.payload.pull_request which is null for workflow_run) + const prNumber = parseInt(process.env.PR_NUMBER, 10); if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > 99999) { - throw new Error('Invalid PR number'); + throw new Error(`Invalid PR number: ${process.env.PR_NUMBER}`); } const verificationStatus = process.env.VERIFICATION_STATUS; diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml new file mode 100644 index 00000000..b5cf2e62 --- /dev/null +++ b/.github/workflows/pr-preview-build.yml @@ -0,0 +1,126 @@ +# PR Preview Build (Stage 1 - Untrusted Context) +# This workflow runs for ALL pull requests, including those from forks +# It has NO access to secrets and only collects PR metadata +# +# SECURITY NOTE: This workflow runs the FORK's version of this file. +# A malicious fork can modify this workflow arbitrarily. Therefore: +# - The artifact produced here is UNTRUSTED data +# - Stage 2 must independently verify all security-critical claims +# - The security check here is informational only (for logging/summaries) +name: PR Preview Build + +on: + pull_request: + branches: ["main"] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read # Needed to list PR changed files + +jobs: + collect-metadata: + runs-on: ubuntu-latest + steps: + - name: Check for executable code changes + id: security-check + uses: actions/github-script@v7 + with: + script: | + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100 + } + ); + + const dangerousPatterns = [ + /^_plugins\//, + /^\.github\//, + ]; + + const dangerousFiles = files + .map(f => f.filename) + .filter(f => dangerousPatterns.some(p => p.test(f))); + + const hasDangerousChanges = dangerousFiles.length > 0; + + core.setOutput('has_dangerous_changes', hasDangerousChanges.toString()); + core.setOutput('dangerous_files', dangerousFiles.join(', ')); + + if (hasDangerousChanges) { + core.warning(`Executable code modified: ${dangerousFiles.join(', ')}`); + } else { + console.log('No executable code changes detected'); + } + + - name: Save PR metadata + # Pass attacker-controlled values via env to prevent shell injection + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_REF: ${{ github.event.pull_request.head.ref }} + PR_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_USER: ${{ github.event.pull_request.user.login }} + DANGEROUS_FILES: ${{ steps.security-check.outputs.dangerous_files }} + run: | + # Determine if this is a fork PR + if [ "$PR_REPO" != "${{ github.repository }}" ]; then + IS_FORK=true + else + IS_FORK=false + fi + + # Use jq to construct JSON safely (prevents injection via PR title etc.) + jq -n \ + --argjson number "${{ github.event.pull_request.number }}" \ + --arg sha "${{ github.event.pull_request.head.sha }}" \ + --arg ref "$PR_REF" \ + --arg title "$PR_TITLE" \ + --arg repo "$PR_REPO" \ + --arg base_ref "${{ github.event.pull_request.base.ref }}" \ + --arg user "$PR_USER" \ + --argjson is_fork "$IS_FORK" \ + --argjson has_dangerous_changes "${{ steps.security-check.outputs.has_dangerous_changes }}" \ + --arg dangerous_files "$DANGEROUS_FILES" \ + '{ + number: $number, + sha: $sha, + ref: $ref, + title: $title, + repo: $repo, + base_ref: $base_ref, + user: $user, + is_fork: $is_fork, + has_dangerous_changes: $has_dangerous_changes, + dangerous_files: $dangerous_files + }' > pr-context.json + + echo "đŸ“Ļ Saved PR metadata:" + cat pr-context.json + + - name: Upload PR context artifact + uses: actions/upload-artifact@v4 + with: + name: pr-context-${{ github.event.pull_request.number }} + path: pr-context.json + retention-days: 1 + + - name: Summary + run: | + echo "✅ PR metadata collected successfully" + echo "📊 PR #${{ github.event.pull_request.number }}" + echo "🔗 From: ${{ github.event.pull_request.head.repo.full_name }}" + echo "đŸŽ¯ SHA: ${{ github.event.pull_request.head.sha }}" + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "🌍 External contributor (fork PR)" + else + echo "🏠 Internal contributor" + fi + if [ "${{ steps.security-check.outputs.has_dangerous_changes }}" = "true" ]; then + echo "âš ī¸ Executable code changes: ${{ steps.security-check.outputs.dangerous_files }}" + else + echo "✅ No executable code changes" + fi diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml new file mode 100644 index 00000000..107f0113 --- /dev/null +++ b/.github/workflows/pr-preview-deploy.yml @@ -0,0 +1,589 @@ +# PR Preview Deploy (Stage 2 - Trusted Context) +# This workflow is triggered by workflow_run after Stage 1 completes +# It has FULL access to secrets and runs from the base branch (trusted) +# +# SECURITY MODEL: This workflow does NOT trust Stage 1's artifact for +# security decisions. Stage 1 runs the fork's version of its workflow, +# so a malicious fork could tamper with the artifact. Instead, this +# workflow independently verifies security-critical data via the GitHub +# API before proceeding with the build. +name: PR Preview Deploy + +on: + workflow_run: + workflows: ["PR Preview Build"] + types: [completed] + +permissions: + contents: write + pull-requests: write + deployments: write + actions: read # Needed to download artifacts + +# Prevent concurrent deployments - cancel old runs when new commits pushed +concurrency: + group: pr-preview-deployment-${{ github.event.workflow_run.pull_requests[0].number }} + cancel-in-progress: true + +jobs: + deploy-preview: + # Only run if Stage 1 succeeded and there's a PR associated + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.pull_requests[0].number != null + runs-on: ubuntu-latest + + steps: + - name: Download PR context artifact + uses: actions/download-artifact@v4 + with: + name: pr-context-${{ github.event.workflow_run.pull_requests[0].number }} + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Read and validate PR metadata + id: pr + run: | + echo "đŸ“Ļ Reading PR metadata..." + cat pr-context.json | jq . + + # Extract and validate PR number (must be positive integer) + PR_NUMBER=$(jq -r .number pr-context.json) + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || [ "$PR_NUMBER" -lt 1 ] || [ "$PR_NUMBER" -gt 99999 ]; then + echo "❌ Invalid PR number: $PR_NUMBER" + exit 1 + fi + + # Extract and validate SHA (must be 40-character hex) + PR_SHA=$(jq -r .sha pr-context.json) + if ! [[ "$PR_SHA" =~ ^[a-f0-9]{40}$ ]]; then + echo "❌ Invalid SHA format: $PR_SHA" + exit 1 + fi + + # Extract title (truncate to 100 chars, sanitize) + PR_TITLE=$(jq -r .title pr-context.json | head -c 100 | tr -d '\n\r') + + # Set validated outputs (used for non-security-critical purposes) + echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "sha=$PR_SHA" >> $GITHUB_OUTPUT + echo "title=$PR_TITLE" >> $GITHUB_OUTPUT + + echo "✅ Validated PR #$PR_NUMBER" + + # SECURITY: Independent verification via GitHub API + # Do NOT trust Stage 1 artifact for security decisions - a malicious + # fork can modify the Stage 1 workflow to tamper with artifact data. + - name: Verify PR security independently + id: security-check + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.workflow_run.pull_requests[0].number; + + // Fetch PR details from GitHub API (trusted source) + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const isFork = pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`; + core.setOutput('is_fork', isFork.toString()); + core.setOutput('pr_sha', pr.head.sha); + + console.log(`PR #${prNumber} from ${pr.head.repo.full_name} (fork: ${isFork})`); + + // Validate artifact SHA matches API SHA to detect tampering + const fs = require('fs'); + const metadata = JSON.parse(fs.readFileSync('pr-context.json', 'utf8')); + if (metadata.sha !== pr.head.sha) { + core.setFailed(`SHA mismatch: artifact=${metadata.sha}, API=${pr.head.sha}. Possible artifact tampering.`); + return; + } + console.log(`✅ SHA verified: ${pr.head.sha}`); + + // Internal PRs: always allowed + if (!isFork) { + core.setOutput('deployment_allowed', 'true'); + console.log('✅ Internal PR — deployment always allowed'); + return; + } + + // Fork PRs: check for dangerous file changes + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100 + } + ); + + const dangerousPatterns = [ + /^_plugins\//, + /^\.github\//, + ]; + + const dangerousFiles = files + .map(f => f.filename) + .filter(f => dangerousPatterns.some(p => p.test(f))); + + if (dangerousFiles.length > 0) { + core.setOutput('deployment_allowed', 'false'); + core.setOutput('dangerous_files', dangerousFiles.join('\n')); + core.warning(`đŸšĢ Fork PR modifies executable code: ${dangerousFiles.join(', ')}`); + } else { + core.setOutput('deployment_allowed', 'true'); + console.log('✅ Fork PR — content-only changes — deployment allowed'); + } + + - name: Checkout PR code from git + if: steps.security-check.outputs.deployment_allowed == 'true' + uses: actions/checkout@v4 + with: + # Use API-verified SHA (not artifact SHA) to close the trust chain + ref: ${{ steps.security-check.outputs.pr_sha }} + submodules: false # We'll handle submodules separately + + # SECURITY: For fork PRs, replace all executable files with trusted + # versions from main. This prevents three attack vectors: + # 1. _config.yml redirecting plugins_dir to execute attacker code + # 2. Pre-existing fork modifications to .github/scripts/ that don't + # appear in the PR diff but are present in the checked-out code + # 3. Pre-existing fork modifications to _plugins/ (Ruby code executed + # by Jekyll during build) that don't appear in the PR diff + - name: Apply security overrides for fork PRs + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.security-check.outputs.is_fork == 'true' + run: | + set -e + echo "🔒 Applying security overrides for fork PR..." + + # Force plugins_dir to _plugins to prevent _config.yml redirection + # (a malicious _config.yml could set plugins_dir to a directory + # containing attacker-supplied Ruby code outside _plugins/) + cat > _config_security.yml << 'SECURITY_EOF' + # Security overrides for fork PR builds - do not modify + plugins_dir: _plugins + SECURITY_EOF + + git fetch origin main + + # Completely replace _plugins/ with trusted version from main. + # This prevents both modifications to existing plugins AND + # addition of new malicious plugin files by the fork. + rm -rf _plugins/ + git checkout origin/main -- _plugins/ + echo "✅ Replaced _plugins/ with trusted version from main" + + # Replace executable scripts with trusted versions from main + # to prevent pre-existing fork modifications from executing + for script in \ + .github/scripts/generate-pr-directory-name.js \ + .github/scripts/generate-preview-index.sh; do + if git cat-file -e "origin/main:$script" 2>&1; then + git show "origin/main:$script" > "$script" + echo "✅ Replaced $script with trusted version from main" + fi + done + + echo "✅ Security overrides applied" + + # Use API-verified is_fork (not artifact) for security decisions + - name: Use trusted Gemfile for fork PRs (security) + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.security-check.outputs.is_fork == 'true' + id: gemfile-check + run: | + echo "🔒 Fork PR detected - using trusted Gemfile from main branch" + + # Check if Gemfile was modified in this PR + git fetch origin main + if ! git diff --quiet origin/main HEAD -- Gemfile Gemfile.lock; then + echo "gemfile_modified=true" >> $GITHUB_OUTPUT + echo "âš ī¸ Gemfile changes detected in fork PR" + else + echo "gemfile_modified=false" >> $GITHUB_OUTPUT + fi + + # Checkout trusted Gemfile and Gemfile.lock from main branch + git show origin/main:Gemfile > Gemfile.trusted + git show origin/main:Gemfile.lock > Gemfile.lock.trusted + + # Replace PR's Gemfile with trusted version + mv Gemfile.trusted Gemfile + mv Gemfile.lock.trusted Gemfile.lock + + echo "✅ Using trusted dependencies for security" + + - name: Initialize private theme submodule with SSH + if: steps.security-check.outputs.deployment_allowed == 'true' + run: | + echo "🔐 Checking out private theme submodule..." + # Setup SSH agent with theme deploy key + eval $(ssh-agent -s) + echo "${{ secrets.JEKYLL_THEME_KEY }}" | ssh-add - + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + # Initialize and update theme submodule + git submodule sync --recursive + git submodule update --init --recursive + + echo "✅ Private theme checked out successfully" + + - name: Setup Ruby and dependencies + if: steps.security-check.outputs.deployment_allowed == 'true' + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + bundler-cache: true + cache-version: 0 + + - name: Generate PR directory name + if: steps.security-check.outputs.deployment_allowed == 'true' + id: pr-directory + uses: actions/github-script@v7 + # Pass title via env to prevent expression injection (title is + # artifact-sourced and attacker-controlled for fork PRs) + env: + PR_TITLE: ${{ steps.pr.outputs.title }} + with: + script: | + const generatePrDirectoryName = require('./.github/scripts/generate-pr-directory-name.js'); + const prNumber = ${{ steps.pr.outputs.number }}; + const prTitle = process.env.PR_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 with private theme + if: steps.security-check.outputs.deployment_allowed == 'true' + run: | + BASEURL="${{ steps.pr-directory.outputs.baseurl_path }}" + export PATH=$PATH:~/.local/share/gem/ruby/3.1.0/bin + echo "🔨 Building Jekyll site..." + # For fork PRs, stack security config to override dangerous settings + CONFIG_FLAG="" + if [ -f _config_security.yml ]; then + CONFIG_FLAG="--config _config.yml,_config_security.yml" + echo "🔒 Using security config override" + fi + bundle exec jekyll build --baseurl "$BASEURL" $CONFIG_FLAG + echo "✅ Site built successfully" + env: + JEKYLL_ENV: production + + - name: Save built site for deployment + if: steps.security-check.outputs.deployment_allowed == 'true' + run: | + DIR_NAME="${{ steps.pr-directory.outputs.directory_name }}" + # Save to temp location + cp -r _site "/tmp/pr-site-${DIR_NAME}" + cp -r .github "/tmp/pr-github-${DIR_NAME}" + + - name: Clone preview repository and deploy + if: steps.security-check.outputs.deployment_allowed == 'true' + run: | + # Assign outputs to shell variables for safe quoting + DIR_NAME="${{ steps.pr-directory.outputs.directory_name }}" + SLUG_NAME="${{ steps.pr-directory.outputs.slugified_name }}" + PR_NUM="${{ steps.pr.outputs.number }}" + PR_SHA="${{ steps.pr.outputs.sha }}" + + echo "🚀 Deploying to preview.wafer.space..." + + # Setup SSH for preview repo + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + mkdir -p ~/.ssh + chmod 700 ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + # Clone preview repository + git clone git@github.com:wafer-space/preview.wafer.space.git preview-repo + cd preview-repo + + # Configure git + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + # Create PR directory and copy built site + mkdir -p "$DIR_NAME" + cp -r "/tmp/pr-site-${DIR_NAME}/"* "$DIR_NAME/" + + # Create redirect page for slugified URL if different + if [ "$SLUG_NAME" != "$DIR_NAME" ]; then + mkdir -p "$SLUG_NAME" + cp "/tmp/pr-github-${DIR_NAME}/templates/redirect-template.html" "$SLUG_NAME/index.html" + sed -i "s|{{TARGET_URL}}|/${DIR_NAME}|g" "$SLUG_NAME/index.html" + echo "Created redirect from $SLUG_NAME to $DIR_NAME" + fi + + # Ensure CNAME file exists + if [ ! -f CNAME ]; then + echo "preview.wafer.space" > CNAME + fi + + # Generate preview index page + if [ -f "/tmp/pr-github-${DIR_NAME}/scripts/generate-preview-index.sh" ]; then + cp "/tmp/pr-github-${DIR_NAME}/scripts/generate-preview-index.sh" ./ + mkdir -p .github/templates + cp "/tmp/pr-github-${DIR_NAME}/templates/preview-index.html" .github/templates/ + chmod +x generate-preview-index.sh + ./generate-preview-index.sh + fi + + # Commit and push + git add . + if git diff --staged --quiet; then + echo "â„šī¸ No changes to commit" + exit 0 + fi + + git commit -m "Deploy preview for PR #${PR_NUM} - ${PR_SHA}" + + # SSH agent is still active from earlier setup + git push origin main + + echo "✅ Deployment complete" + + - name: Wait for Pages deployment + if: steps.security-check.outputs.deployment_allowed == 'true' + id: verify-deployment + run: | + PREVIEW_URL="https://preview.wafer.space/pr-${{ steps.pr.outputs.number }}/" + EXPECTED_COMMIT="${{ steps.pr.outputs.sha }}" + + echo "âŗ Waiting for GitHub Pages deployment..." + echo "Preview URL: $PREVIEW_URL" + echo "Expected commit: $EXPECTED_COMMIT" + + # Wait for basic HTTP response (3 minutes max) + for i in {1..18}; do + sleep 10 + echo "Attempt $i/18: Checking HTTP response..." + + if curl -s --fail --head "$PREVIEW_URL" > /dev/null; then + echo "✅ Site is responding" + break + fi + + if [ $i -eq 18 ]; then + echo "deployment_status=failed" >> $GITHUB_OUTPUT + echo "error_message=No HTTP response after 3 minutes" >> $GITHUB_OUTPUT + exit 1 + fi + done + + # Verify content is correct (2 minutes max) + for i in {1..24}; do + sleep 5 + echo "Content verification attempt $i/24..." + + PAGE_CONTENT=$(curl -s "$PREVIEW_URL") + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL") + + if [ "$HTTP_STATUS" = "200" ] && echo "$PAGE_CONTENT" | grep -q "wafer.space"; then + echo "✅ Deployment verified successfully" + echo "deployment_status=success" >> $GITHUB_OUTPUT + echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT + exit 0 + fi + done + + echo "âš ī¸ Verification timeout - deployment may still be propagating" + echo "deployment_status=partial" >> $GITHUB_OUTPUT + echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT + echo "error_message=Content verification timeout" >> $GITHUB_OUTPUT + + - name: Create GitHub deployment + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.verify-deployment.outputs.deployment_status == 'success' + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr.outputs.number }}; + const sha = '${{ steps.pr.outputs.sha }}'; + const previewUrl = '${{ steps.verify-deployment.outputs.preview_url }}'; + + try { + const { data: deployment } = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + environment: `pr-preview-${prNumber}`, + transient_environment: true, + production_environment: false, + required_contexts: [], + description: `PR #${prNumber} preview`, + auto_merge: false + }); + + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'success', + environment_url: previewUrl, + description: 'Preview deployment successful' + }); + + console.log(`Created deployment ${deployment.id} for PR #${prNumber}`); + } catch (error) { + console.error('Failed to create deployment:', error.message); + // Don't fail the workflow if deployment creation fails + } + + - name: Comment on PR with preview URL + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.verify-deployment.outputs.deployment_status == 'success' + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr.outputs.number }}; + const previewUrl = '${{ steps.verify-deployment.outputs.preview_url }}'; + const commitSha = '${{ steps.pr.outputs.sha }}'.substring(0, 7); + const isFork = ${{ steps.security-check.outputs.is_fork }}; + const gemfileModified = '${{ steps.gemfile-check.outputs.gemfile_modified }}' === 'true'; + + const forkBadge = isFork ? '🌍 **External Contributor** (fork PR)' : '🏠 Internal PR'; + + // Add Gemfile security notice for fork PRs with dependency changes + let securityNotice = ''; + if (isFork && gemfileModified) { + securityNotice = `\n\n> **🔒 Security Notice:** This PR includes changes to \`Gemfile\` or \`Gemfile.lock\`. For security, the preview was built using the trusted dependencies from the \`main\` branch. If you need to update dependencies, please work with a maintainer to submit those changes separately from an internal branch.\n`; + } + + const commentBody = [ + '## ✅ Preview Deployment Ready!', + '', + forkBadge, + '', + '| Preview URL | Commit |', + '|-------------|--------|', + `| [View Preview](${previewUrl}) | \`${commitSha}\` |`, + '', + '**🎉 Your preview has been deployed successfully!**', + '', + `The preview site is available at: ${previewUrl}${securityNotice}`, + '', + '---', + '⚡ Deployed via workflow_run (two-stage deployment) â€ĸ Preview will be removed when PR is closed', + ].join('\n'); + + // Find and update existing comment or create new one + 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') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + console.log(`Updated 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}`); + } + + - name: Comment on PR if deployment failed + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.verify-deployment.outputs.deployment_status == 'failed' + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr.outputs.number }}; + const errorMessage = '${{ steps.verify-deployment.outputs.error_message }}'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `## ❌ Preview Deployment Failed\n\n**Error:** ${errorMessage}\n\nPlease check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.` + }); + + - name: Comment on PR about blocked deployment + if: steps.security-check.outputs.deployment_allowed == 'false' + uses: actions/github-script@v7 + env: + DANGEROUS_FILES: ${{ steps.security-check.outputs.dangerous_files }} + with: + script: | + const prNumber = ${{ steps.pr.outputs.number }}; + const dangerousFiles = process.env.DANGEROUS_FILES; + + const commentBody = [ + '## đŸšĢ Preview Deployment Blocked', + '', + 'This pull request modifies executable code that runs during the preview build:', + '', + '```', + dangerousFiles, + '```', + '', + '**Why?** For security, fork PRs that modify files in `_plugins/` or `.github/` cannot receive automatic preview deployments. These files execute during the build process with access to repository secrets.', + '', + '**What to do:**', + '- If you only need content changes previewed, please move executable code changes to a separate PR', + '- A maintainer can review the executable changes and deploy a preview manually', + '', + '---', + '🔒 Blocked by security gate (two-stage deployment)', + ].join('\n'); + + // Find and update existing comment or create new one + 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') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: commentBody + }); + } diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml.old similarity index 100% rename from .github/workflows/pr-preview.yml rename to .github/workflows/pr-preview.yml.old diff --git a/.github/workflows/preview-verification.yml b/.github/workflows/preview-verification.yml index f89da5d4..9cdcf887 100644 --- a/.github/workflows/preview-verification.yml +++ b/.github/workflows/preview-verification.yml @@ -5,10 +5,11 @@ name: Preview Site Verification on: - pull_request: - branches: ["main"] - types: [opened, synchronize, reopened] - + # Trigger after preview deployment completes + workflow_run: + workflows: ["PR Preview Deploy"] + types: [completed] + # Manual trigger for testing workflow_dispatch: inputs: @@ -28,68 +29,63 @@ permissions: jobs: verify-preview: + # Only run if deployment succeeded and there's a PR associated + if: > + (github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch') && + (github.event.workflow_run.pull_requests[0].number != null || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest - + steps: - name: Checkout repository uses: actions/checkout@v4 - + - name: Get PR information id: pr-info uses: actions/github-script@v7 + # Pass workflow_dispatch inputs via env to prevent expression injection + env: + INPUT_PR_NUMBER: ${{ github.event.inputs.pr_number }} + INPUT_PREVIEW_URL: ${{ github.event.inputs.preview_url }} with: script: | // Handle manual workflow dispatch if (context.eventName === 'workflow_dispatch') { - const prNumber = '${{ github.event.inputs.pr_number }}'; - const previewUrl = '${{ github.event.inputs.preview_url }}'; - + const prNumber = process.env.INPUT_PR_NUMBER; + const previewUrl = process.env.INPUT_PREVIEW_URL; + console.log(`Manual trigger - PR Number: ${prNumber}`); console.log(`Manual trigger - Preview URL: ${previewUrl}`); - + core.setOutput('pr_number', prNumber); core.setOutput('preview_url', previewUrl); core.setOutput('pr_title', 'Manual Verification'); core.setOutput('head_sha', context.sha); - + return; } - - // Handle pull_request trigger - fetch current PR state from API to avoid stale data - const prNumber = context.payload.pull_request.number; + + // Handle workflow_run trigger - get PR info from event + const prNumber = context.payload.workflow_run.pull_requests[0].number; const previewUrl = `https://preview.wafer.space/pr-${prNumber}/`; - - console.log(`PR Number: ${prNumber}`); + + console.log(`Workflow run trigger - PR Number: ${prNumber}`); console.log(`Preview URL: ${previewUrl}`); - - // Always fetch current PR data from GitHub API to get the latest commit hash - // This avoids issues with stale data in context.payload after force pushes + + // Fetch current PR data from GitHub API const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); - + const currentHeadSha = pullRequest.head.sha; const prTitle = pullRequest.title; - + console.log(`PR Title: ${prTitle}`); - console.log(`Current HEAD SHA from API: ${currentHeadSha}`); - console.log(`Original HEAD SHA from payload: ${context.payload.pull_request.head.sha}`); - - if (currentHeadSha !== context.payload.pull_request.head.sha) { - console.log(`âš ī¸ Detected stale commit hash in payload`); - console.log(`Payload hash: ${context.payload.pull_request.head.sha}`); - console.log(`Current hash: ${currentHeadSha}`); - console.log(`A newer commit has been pushed - aborting this workflow run`); - console.log(`A new workflow run should be triggered for the newer commit`); - core.setFailed('Workflow aborted due to stale commit hash - newer commit detected'); - return; - } - - console.log(`✅ Commit hash is current - proceeding with verification`); - - // Set outputs for subsequent steps using current API data + console.log(`Current HEAD SHA: ${currentHeadSha}`); + console.log(`✅ Proceeding with verification`); + + // Set outputs for subsequent steps core.setOutput('pr_number', prNumber); core.setOutput('preview_url', previewUrl); core.setOutput('pr_title', prTitle); @@ -157,10 +153,17 @@ jobs: sudo apt-get update sudo apt-get install -y lynx html-xml-utils python3-pip jq bc - # Install muffet - comprehensive link and asset checker - wget -qO- https://github.com/raviqqe/muffet/releases/latest/download/muffet_linux_amd64.tar.gz | tar -xzf - -C /tmp - sudo mv /tmp/muffet /usr/local/bin/ - + # Install muffet at pinned version with checksum verification + MUFFET_VERSION="v2.11.2" + MUFFET_SHA256="97d69581cf90c932be3b5622877bc3a60e50b0ea05d1609aaa6b2efdae15acd3" + MUFFET_URL="https://github.com/raviqqe/muffet/releases/download/${MUFFET_VERSION}/muffet_linux_amd64.tar.gz" + + wget -q -O muffet.tar.gz "$MUFFET_URL" + echo "${MUFFET_SHA256} muffet.tar.gz" | sha256sum --check --strict + tar -xzf muffet.tar.gz muffet + sudo mv muffet /usr/local/bin/ + rm muffet.tar.gz + # Verify installation muffet --version @@ -426,14 +429,15 @@ jobs: script: | const commentVerificationResults = require('./.github/scripts/comment-verification-results.js'); const result = await commentVerificationResults(github, context, core); - - const prNumber = context.payload.pull_request?.number || 'unknown'; + + const prNumber = process.env.PR_NUMBER; console.log(`Posted verification results to PR #${prNumber}`); console.log(`Comment ID: ${result.commentId}, Archived: ${result.archivedCount} previous comments`); env: VERIFICATION_STATUS: ${{ steps.verification.outputs.verification_status }} REPORT_CONTENT: ${{ steps.verification.outputs.report_content }} GITHUB_RUN_ID: ${{ github.run_id }} + PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }} - name: Fail workflow if verification failed if: steps.verification.outputs.verification_status == 'failed'