From 1c92926d87c45e372c318b1d605096ea5359a088 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sat, 4 Oct 2025 18:59:02 -0500 Subject: [PATCH 01/15] Add Stage 1 workflow for PR preview metadata collection This workflow runs in untrusted context for ALL pull requests (including forks). It collects PR metadata and saves it as an artifact for Stage 2. - No secrets access (safe for fork PRs) - No building (just metadata collection) - Triggers Stage 2 workflow via workflow_run Part of implementing two-stage workflow pattern for Issue #59 --- .github/workflows/pr-preview-build.yml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/pr-preview-build.yml diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml new file mode 100644 index 00000000..ba8618bf --- /dev/null +++ b/.github/workflows/pr-preview-build.yml @@ -0,0 +1,54 @@ +# 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 +name: PR Preview Build + +on: + pull_request: + branches: ["main"] + types: [opened, synchronize, reopened] + +permissions: + contents: read + actions: write # Needed to upload artifacts + +jobs: + collect-metadata: + runs-on: ubuntu-latest + steps: + - name: Save PR metadata + run: | + cat > pr-context.json << 'EOF' + { + "number": ${{ github.event.pull_request.number }}, + "sha": "${{ github.event.pull_request.head.sha }}", + "ref": "${{ github.event.pull_request.head.ref }}", + "title": "${{ github.event.pull_request.title }}", + "repo": "${{ github.event.pull_request.head.repo.full_name }}", + "base_ref": "${{ github.event.pull_request.base.ref }}", + "user": "${{ github.event.pull_request.user.login }}", + "is_fork": ${{ github.event.pull_request.head.repo.full_name != github.repository }} + } + EOF + + echo "đŸ“Ļ Saved PR metadata:" + cat pr-context.json | jq . + + - 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 From 8d9001af5e194de42b40d9e595d33b80bb191489 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sat, 4 Oct 2025 19:00:14 -0500 Subject: [PATCH 02/15] Add Stage 2 workflow for PR preview build and deployment This workflow runs in trusted context triggered by workflow_run. It has full access to secrets and builds/deploys the preview. Key features: - Triggered by workflow_run after Stage 1 completes - Downloads PR metadata from artifact - Checks out PR code directly from git - Checks out private theme using JEKYLL_THEME_KEY - Builds site with full theme - Deploys to preview.wafer.space using PREVIEW_KEY - Creates GitHub deployment - Comments on PR with preview URL - Supports both internal and fork PRs Part of implementing two-stage workflow pattern for Issue #59 --- .github/workflows/pr-preview-deploy.yml | 339 ++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 .github/workflows/pr-preview-deploy.yml diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml new file mode 100644 index 00000000..c5b41bf9 --- /dev/null +++ b/.github/workflows/pr-preview-deploy.yml @@ -0,0 +1,339 @@ +# 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) +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 +concurrency: + group: pr-preview-deployment-${{ github.event.workflow_run.pull_requests[0].number }} + cancel-in-progress: false + +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 PR metadata + id: pr + run: | + echo "đŸ“Ļ Reading PR metadata..." + cat pr-context.json | jq . + + # Extract values and set outputs + echo "number=$(jq -r .number pr-context.json)" >> $GITHUB_OUTPUT + echo "sha=$(jq -r .sha pr-context.json)" >> $GITHUB_OUTPUT + echo "title=$(jq -r .title pr-context.json | head -c 100)" >> $GITHUB_OUTPUT + echo "is_fork=$(jq -r .is_fork pr-context.json)" >> $GITHUB_OUTPUT + + echo "✅ PR #$(jq -r .number pr-context.json) from $(jq -r .repo pr-context.json)" + + - name: Checkout PR code from git + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + submodules: false # We'll handle submodules separately + + - name: Initialize private theme submodule with SSH + 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 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + bundler-cache: true + cache-version: 0 + + - name: Generate PR directory name + id: pr-directory + uses: actions/github-script@v7 + with: + script: | + const generatePrDirectoryName = require('./.github/scripts/generate-pr-directory-name.js'); + const prNumber = ${{ steps.pr.outputs.number }}; + const prTitle = `${{ steps.pr.outputs.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 + run: | + export PATH=$PATH:~/.local/share/gem/ruby/3.2.0/bin + echo "🔨 Building Jekyll site..." + bundle exec jekyll build --baseurl "${{ steps.pr-directory.outputs.baseurl_path }}" + echo "✅ Site built successfully" + env: + JEKYLL_ENV: production + + - name: Save built site for deployment + run: | + # Save to temp location + 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: Clone preview repository and deploy + run: | + 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 ${{ steps.pr-directory.outputs.directory_name }} + 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 + if [ "${{ steps.pr-directory.outputs.slugified_name }}" != "${{ steps.pr-directory.outputs.directory_name }}" ]; then + mkdir -p ${{ steps.pr-directory.outputs.slugified_name }} + 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 redirect from ${{ steps.pr-directory.outputs.slugified_name }} to ${{ steps.pr-directory.outputs.directory_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-${{ steps.pr-directory.outputs.directory_name }}/scripts/generate-preview-index.sh" ]; then + 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 + 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 #${{ steps.pr.outputs.number }} - ${{ steps.pr.outputs.sha }}" + + # Re-setup SSH for push + eval $(ssh-agent -s) + echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + git push origin main + + echo "✅ Deployment complete" + + - name: Wait for Pages deployment + 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.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.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.pr.outputs.is_fork }}; + + const forkBadge = isFork ? '🌍 **External Contributor** (fork PR)' : '🏠 Internal PR'; + + 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} + +--- +⚡ Deployed via workflow_run (two-stage deployment) â€ĸ Preview will be removed when PR is closed`; + + // 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.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.` + }); From 3619aaef27349a1f241b03fb9c9e1c808119883e Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sat, 4 Oct 2025 19:02:11 -0500 Subject: [PATCH 03/15] Archive old single-stage pr-preview workflow Renamed to pr-preview.yml.old to preserve for reference. This workflow is replaced by the two-stage workflow pattern: - pr-preview-build.yml (Stage 1 - untrusted) - pr-preview-deploy.yml (Stage 2 - trusted) The old workflow failed for fork PRs due to secret access restrictions. The new two-stage pattern solves this by using workflow_run. Related to Issue #59 --- .github/workflows/{pr-preview.yml => pr-preview.yml.old} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{pr-preview.yml => pr-preview.yml.old} (100%) 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 From 52854d297fea63c46c730c68bb656ae7214fd181 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sat, 4 Oct 2025 19:03:07 -0500 Subject: [PATCH 04/15] Update preview-verification to trigger after deployment Changed from pull_request trigger to workflow_run trigger. Now runs after PR Preview Deploy completes successfully. Benefits: - More efficient (only verifies after successful deployment) - Works with two-stage workflow pattern - Avoids racing with deployment - Still supports manual workflow_dispatch for testing Related to Issue #59 --- .github/workflows/preview-verification.yml | 62 ++++++++++------------ 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/.github/workflows/preview-verification.yml b/.github/workflows/preview-verification.yml index f89da5d4..197e5ba3 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,12 +29,16 @@ 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 @@ -43,53 +48,40 @@ jobs: if (context.eventName === 'workflow_dispatch') { const prNumber = '${{ github.event.inputs.pr_number }}'; const previewUrl = '${{ github.event.inputs.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); From 65d0c43b563b28cc8b411b04de3071c75b120cf4 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Sat, 4 Oct 2025 19:12:11 -0500 Subject: [PATCH 05/15] Fix automated review comments from Copilot and CodeQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressed all legitimate code review suggestions: 1. Fixed Ruby version path mismatch (3.2.0 → 3.1) - Aligned with ruby-version in setup step 2. Enabled cancel-in-progress for concurrency - Cancels outdated deployments when new commits pushed - Prevents resource waste and conflicts 3. Fixed is_fork JSON boolean generation - Now properly evaluates in shell before JSON creation - Ensures valid JSON output 4. Removed duplicate SSH agent setup - SSH agent persists throughout the step - No need to re-setup before git push 5. Added input validation for PR metadata - Validates PR number format (1-99999) - Validates SHA format (40-char hex) - Sanitizes title (truncate, remove newlines) - Validates is_fork boolean - Mitigates code injection risks from untrusted artifacts These changes address security concerns while maintaining the workflow_run pattern's ability to safely handle data from fork PRs. Related to PR #60, Issue #59 --- .github/workflows/pr-preview-build.yml | 11 ++++-- .github/workflows/pr-preview-deploy.yml | 48 ++++++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index ba8618bf..6499577e 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -18,7 +18,14 @@ jobs: steps: - name: Save PR metadata run: | - cat > pr-context.json << 'EOF' + # Determine if this is a fork PR + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + IS_FORK="true" + else + IS_FORK="false" + fi + + cat > pr-context.json << EOF { "number": ${{ github.event.pull_request.number }}, "sha": "${{ github.event.pull_request.head.sha }}", @@ -27,7 +34,7 @@ jobs: "repo": "${{ github.event.pull_request.head.repo.full_name }}", "base_ref": "${{ github.event.pull_request.base.ref }}", "user": "${{ github.event.pull_request.user.login }}", - "is_fork": ${{ github.event.pull_request.head.repo.full_name != github.repository }} + "is_fork": $IS_FORK } EOF diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index c5b41bf9..bb31c5bf 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -14,10 +14,10 @@ permissions: deployments: write actions: read # Needed to download artifacts -# Prevent concurrent deployments +# 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: false + cancel-in-progress: true jobs: deploy-preview: @@ -36,19 +36,43 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} - - name: Read PR metadata + - name: Read and validate PR metadata id: pr run: | echo "đŸ“Ļ Reading PR metadata..." cat pr-context.json | jq . - # Extract values and set outputs - echo "number=$(jq -r .number pr-context.json)" >> $GITHUB_OUTPUT - echo "sha=$(jq -r .sha pr-context.json)" >> $GITHUB_OUTPUT - echo "title=$(jq -r .title pr-context.json | head -c 100)" >> $GITHUB_OUTPUT - echo "is_fork=$(jq -r .is_fork pr-context.json)" >> $GITHUB_OUTPUT + # 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 - echo "✅ PR #$(jq -r .number pr-context.json) from $(jq -r .repo pr-context.json)" + # Extract title (truncate to 100 chars, sanitize) + PR_TITLE=$(jq -r .title pr-context.json | head -c 100 | tr -d '\n\r') + + # Extract is_fork boolean + IS_FORK=$(jq -r .is_fork pr-context.json) + if [ "$IS_FORK" != "true" ] && [ "$IS_FORK" != "false" ]; then + echo "❌ Invalid is_fork value: $IS_FORK" + exit 1 + fi + + # Set validated outputs + echo "number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "sha=$PR_SHA" >> $GITHUB_OUTPUT + echo "title=$PR_TITLE" >> $GITHUB_OUTPUT + echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT + + echo "✅ Validated PR #$PR_NUMBER from $(jq -r .repo pr-context.json)" - name: Checkout PR code from git uses: actions/checkout@v4 @@ -106,7 +130,7 @@ jobs: - name: Build Jekyll site with private theme run: | - export PATH=$PATH:~/.local/share/gem/ruby/3.2.0/bin + export PATH=$PATH:~/.local/share/gem/ruby/3.1.0/bin echo "🔨 Building Jekyll site..." bundle exec jekyll build --baseurl "${{ steps.pr-directory.outputs.baseurl_path }}" echo "✅ Site built successfully" @@ -174,9 +198,7 @@ jobs: git commit -m "Deploy preview for PR #${{ steps.pr.outputs.number }} - ${{ steps.pr.outputs.sha }}" - # Re-setup SSH for push - eval $(ssh-agent -s) - echo "${{ secrets.PREVIEW_KEY }}" | ssh-add - + # SSH agent is still active from earlier setup git push origin main echo "✅ Deployment complete" From 201bba336d25a45f3e0a1d15d992c5864f9b9e0d Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Wed, 8 Oct 2025 10:10:18 +0800 Subject: [PATCH 06/15] Fix Gemfile poisoning vulnerability (CodeQL alert #1) Addresses artifact poisoning security issue where malicious fork PRs could modify Gemfile to execute arbitrary code during dependency installation. Solution implemented: - Fork PRs: Use trusted Gemfile/Gemfile.lock from main branch - Internal PRs: Use PR's Gemfile (allows testing dependency updates) Security benefits: - Prevents code execution via malicious Gemfile from forks - Blocks installation of backdoored gems - Protects secrets during bundle install User experience: - Fork contributors notified if Gemfile changes are ignored - Clear security notice in PR comment - Guidance provided for legitimate dependency updates Technical implementation: - Conditional step: only runs for fork PRs (is_fork == 'true') - Fetches trusted Gemfile from origin/main - Detects if PR modified Gemfile (for notification) - Replaces PR's Gemfile before Ruby setup runs This fix allows trusted developers to test Gemfile changes while protecting against the artifact poisoning attack vector. Resolves: https://github.com/wafer-space/wafer-space.github.io/security/code-scanning/1 Related to PR #60, Issue #59 --- .github/workflows/pr-preview-deploy.yml | 34 ++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index bb31c5bf..9b5780da 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -80,6 +80,31 @@ jobs: ref: ${{ steps.pr.outputs.sha }} submodules: false # We'll handle submodules separately + - name: Use trusted Gemfile for fork PRs (security) + if: steps.pr.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 run: | echo "🔐 Checking out private theme submodule..." @@ -297,9 +322,16 @@ jobs: const previewUrl = '${{ steps.verify-deployment.outputs.preview_url }}'; const commitSha = '${{ steps.pr.outputs.sha }}'.substring(0, 7); const isFork = ${{ steps.pr.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} @@ -310,7 +342,7 @@ ${forkBadge} **🎉 Your preview has been deployed successfully!** -The preview site is available at: ${previewUrl} +The preview site is available at: ${previewUrl}${securityNotice} --- ⚡ Deployed via workflow_run (two-stage deployment) â€ĸ Preview will be removed when PR is closed`; From 9e6d221dda28314e0f4cf5e13d139b15e28b7965 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 11:02:10 +1030 Subject: [PATCH 07/15] Add executable code detection to Stage 1 (informational) Stage 1 now checks PR changed files against dangerous patterns (_plugins/, .github/) and includes the results in the metadata artifact for logging and workflow summaries. SECURITY NOTE: This check is informational only. Since Stage 1 runs the fork's version of this file, a malicious fork could remove or tamper with this check. Stage 2 independently verifies security via the GitHub API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-build.yml | 51 +++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index 6499577e..cd5ecbf8 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -1,6 +1,12 @@ # 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: @@ -10,12 +16,48 @@ on: permissions: contents: read + pull-requests: read # Needed to list PR changed files actions: write # Needed to upload artifacts 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 run: | # Determine if this is a fork PR @@ -34,7 +76,9 @@ jobs: "repo": "${{ github.event.pull_request.head.repo.full_name }}", "base_ref": "${{ github.event.pull_request.base.ref }}", "user": "${{ github.event.pull_request.user.login }}", - "is_fork": $IS_FORK + "is_fork": $IS_FORK, + "has_dangerous_changes": ${{ steps.security-check.outputs.has_dangerous_changes }}, + "dangerous_files": "${{ steps.security-check.outputs.dangerous_files }}" } EOF @@ -59,3 +103,8 @@ jobs: 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 From 176f4e56229196894de64b07b49a5b678716ae72 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 11:02:19 +1030 Subject: [PATCH 08/15] Block fork PRs that modify executable code from preview deployment Stage 2 now independently verifies PR security via the GitHub API rather than trusting the Stage 1 artifact (which runs in the fork's untrusted context). The security gate: - Independently determines is_fork via GitHub API - Validates artifact SHA matches API SHA (detects tampering) - Lists PR changed files and checks for dangerous patterns - Blocks deployment for fork PRs modifying _plugins/ or .github/ - Posts an explanatory comment when deployment is blocked - Always allows deployment for internal (non-fork) PRs All build/deploy steps are gated on the security check passing. The trusted Gemfile replacement now uses the API-verified is_fork value instead of the untrusted artifact value. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-deploy.yml | 158 +++++++++++++++++++++--- 1 file changed, 143 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index 9b5780da..ce606c65 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -1,6 +1,12 @@ # 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: @@ -59,29 +65,91 @@ jobs: # Extract title (truncate to 100 chars, sanitize) PR_TITLE=$(jq -r .title pr-context.json | head -c 100 | tr -d '\n\r') - # Extract is_fork boolean - IS_FORK=$(jq -r .is_fork pr-context.json) - if [ "$IS_FORK" != "true" ] && [ "$IS_FORK" != "false" ]; then - echo "❌ Invalid is_fork value: $IS_FORK" - exit 1 - fi - - # Set validated outputs + # 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 "is_fork=$IS_FORK" >> $GITHUB_OUTPUT - echo "✅ Validated PR #$PR_NUMBER from $(jq -r .repo pr-context.json)" + 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: ref: ${{ steps.pr.outputs.sha }} submodules: false # We'll handle submodules separately + # Use API-verified is_fork (not artifact) for security decisions - name: Use trusted Gemfile for fork PRs (security) - if: steps.pr.outputs.is_fork == 'true' + 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" @@ -106,6 +174,7 @@ jobs: 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 @@ -123,6 +192,7 @@ jobs: 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' @@ -130,6 +200,7 @@ jobs: cache-version: 0 - name: Generate PR directory name + if: steps.security-check.outputs.deployment_allowed == 'true' id: pr-directory uses: actions/github-script@v7 with: @@ -154,6 +225,7 @@ jobs: return directoryName; - name: Build Jekyll site with private theme + if: steps.security-check.outputs.deployment_allowed == 'true' run: | export PATH=$PATH:~/.local/share/gem/ruby/3.1.0/bin echo "🔨 Building Jekyll site..." @@ -163,12 +235,14 @@ jobs: JEKYLL_ENV: production - name: Save built site for deployment + if: steps.security-check.outputs.deployment_allowed == 'true' run: | # Save to temp location 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: Clone preview repository and deploy + if: steps.security-check.outputs.deployment_allowed == 'true' run: | echo "🚀 Deploying to preview.wafer.space..." @@ -229,6 +303,7 @@ jobs: 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 }}/" @@ -277,7 +352,7 @@ jobs: echo "error_message=Content verification timeout" >> $GITHUB_OUTPUT - name: Create GitHub deployment - if: steps.verify-deployment.outputs.deployment_status == 'success' + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.verify-deployment.outputs.deployment_status == 'success' uses: actions/github-script@v7 with: script: | @@ -314,14 +389,14 @@ jobs: } - name: Comment on PR with preview URL - if: steps.verify-deployment.outputs.deployment_status == 'success' + 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.pr.outputs.is_fork }}; + 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'; @@ -378,7 +453,7 @@ The preview site is available at: ${previewUrl}${securityNotice} } - name: Comment on PR if deployment failed - if: steps.verify-deployment.outputs.deployment_status == 'failed' + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.verify-deployment.outputs.deployment_status == 'failed' uses: actions/github-script@v7 with: script: | @@ -391,3 +466,56 @@ The preview site is available at: ${previewUrl}${securityNotice} 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 + with: + script: | + const prNumber = ${{ steps.pr.outputs.number }}; + const dangerousFiles = `${{ steps.security-check.outputs.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)`; + + // 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 + }); + } From 3f3d9f4b46f8d38c41e573e0edcff97f0610ce68 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 11:08:20 +1030 Subject: [PATCH 09/15] Fix critical and high security issues from audit Addresses findings from security audit: CRITICAL-001: Add _config_security.yml override for fork PRs that forces plugins_dir to _plugins, preventing a malicious _config.yml from redirecting plugin loading to attacker-controlled directories. HIGH-001: Fix expression injection by passing PR title (artifact- sourced, attacker-controlled) via process.env instead of direct ${{ }} interpolation in actions/github-script template literals. HIGH-003: Overlay .github/scripts/ with trusted versions from main for fork PRs, preventing pre-existing fork modifications to executable scripts that don't appear in the PR diff. MED-001: Use API-verified SHA (steps.security-check.outputs.pr_sha) for checkout instead of artifact SHA, closing the trust chain. Also fixes expression injection in blocked deployment comment by passing dangerous_files via env. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-deploy.yml | 54 +++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index ce606c65..41899f21 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -144,9 +144,43 @@ jobs: if: steps.security-check.outputs.deployment_allowed == 'true' uses: actions/checkout@v4 with: - ref: ${{ steps.pr.outputs.sha }} + # 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 executable files with trusted + # versions from main. This prevents two 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 + - name: Apply security overrides for fork PRs + if: steps.security-check.outputs.deployment_allowed == 'true' && steps.security-check.outputs.is_fork == 'true' + run: | + 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 + + # Replace executable scripts with trusted versions from main + # to prevent pre-existing fork modifications from executing + git fetch origin main + for script in \ + .github/scripts/generate-pr-directory-name.js \ + .github/scripts/generate-preview-index.sh \ + .github/scripts/comment-verification-results.js; 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' @@ -203,11 +237,15 @@ jobs: 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 = `${{ steps.pr.outputs.title }}`; + const prTitle = process.env.PR_TITLE; // Main directory uses just PR number for simplicity const directoryName = `pr-${prNumber}`; @@ -229,7 +267,13 @@ jobs: run: | export PATH=$PATH:~/.local/share/gem/ruby/3.1.0/bin echo "🔨 Building Jekyll site..." - bundle exec jekyll build --baseurl "${{ steps.pr-directory.outputs.baseurl_path }}" + # 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 "${{ steps.pr-directory.outputs.baseurl_path }}" $CONFIG_FLAG echo "✅ Site built successfully" env: JEKYLL_ENV: production @@ -470,10 +514,12 @@ The preview site is available at: ${previewUrl}${securityNotice} - 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 = `${{ steps.security-check.outputs.dangerous_files }}`; + const dangerousFiles = process.env.DANGEROUS_FILES; const commentBody = `## đŸšĢ Preview Deployment Blocked From 6342d446541713c226ea58f0ba03553e8e096908 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 12:17:03 +1030 Subject: [PATCH 10/15] Replace fork's _plugins/ with trusted version, add set -e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes remaining HIGH finding from security re-audit: NEW-001: Fork's _plugins/ directory was checked out from the fork and executed during Jekyll build without overlay. A fork could modify _plugins/theme_plugin.rb before opening the PR (so the change wouldn't appear in the PR diff) and have malicious Ruby execute with access to SSH secrets. Fix: completely replace _plugins/ with the trusted version from main using git checkout, which also removes any new malicious plugin files added by the fork. NEW-002: Added set -e to the overlay step so git fetch failures cause the step to fail instead of silently skipping the overlays. NEW-003: Removed unnecessary comment-verification-results.js from the overlay list — that script runs in preview-verification.yml which checks out main directly, not the fork's code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-deploy.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index 41899f21..11b330e2 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -148,14 +148,17 @@ jobs: ref: ${{ steps.security-check.outputs.pr_sha }} submodules: false # We'll handle submodules separately - # SECURITY: For fork PRs, replace executable files with trusted - # versions from main. This prevents two attack vectors: + # 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 @@ -166,13 +169,20 @@ jobs: 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 - git fetch origin main for script in \ .github/scripts/generate-pr-directory-name.js \ - .github/scripts/generate-preview-index.sh \ - .github/scripts/comment-verification-results.js; do + .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" From 89af4ad8568b1d4ee10cf8ab6a94210bb89e2439 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 12:29:27 +1030 Subject: [PATCH 11/15] Harden Stage 1: remove excess permissions, fix JSON injection MED-002: Remove actions:write permission from Stage 1. The actions/upload-artifact action does not require it, and it unnecessarily grants fork workflows the ability to manage workflow runs and artifacts across the repository. LOW-001: Replace heredoc JSON construction with jq -n using --arg parameters. This prevents JSON injection via PR titles containing double quotes or other JSON-special characters. Attacker-controlled values (title, ref, repo, user) are passed via env: to also prevent shell injection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-build.yml | 54 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index cd5ecbf8..b5cf2e62 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -17,7 +17,6 @@ on: permissions: contents: read pull-requests: read # Needed to list PR changed files - actions: write # Needed to upload artifacts jobs: collect-metadata: @@ -59,31 +58,48 @@ jobs: } - 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 [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then - IS_FORK="true" + if [ "$PR_REPO" != "${{ github.repository }}" ]; then + IS_FORK=true else - IS_FORK="false" + IS_FORK=false fi - cat > pr-context.json << EOF - { - "number": ${{ github.event.pull_request.number }}, - "sha": "${{ github.event.pull_request.head.sha }}", - "ref": "${{ github.event.pull_request.head.ref }}", - "title": "${{ github.event.pull_request.title }}", - "repo": "${{ github.event.pull_request.head.repo.full_name }}", - "base_ref": "${{ github.event.pull_request.base.ref }}", - "user": "${{ github.event.pull_request.user.login }}", - "is_fork": $IS_FORK, - "has_dangerous_changes": ${{ steps.security-check.outputs.has_dangerous_changes }}, - "dangerous_files": "${{ steps.security-check.outputs.dangerous_files }}" - } - EOF + # 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 | jq . + cat pr-context.json - name: Upload PR context artifact uses: actions/upload-artifact@v4 From 212511cb461328326e853084d9695203fda870fe Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 12:35:01 +1030 Subject: [PATCH 12/15] Fix shell quoting in Stage 2 deploy steps MED-003: Assign all ${{ }} step outputs to shell variables at the top of run blocks, then use those variables with proper quoting throughout. This prevents potential shell injection from output values containing special characters. Affected steps: "Save built site for deployment", "Build Jekyll site", and "Clone preview repository and deploy". Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-deploy.yml | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index 11b330e2..b6a8b45d 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -275,6 +275,7 @@ jobs: - 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 @@ -283,7 +284,7 @@ jobs: CONFIG_FLAG="--config _config.yml,_config_security.yml" echo "🔒 Using security config override" fi - bundle exec jekyll build --baseurl "${{ steps.pr-directory.outputs.baseurl_path }}" $CONFIG_FLAG + bundle exec jekyll build --baseurl "$BASEURL" $CONFIG_FLAG echo "✅ Site built successfully" env: JEKYLL_ENV: production @@ -291,13 +292,20 @@ jobs: - 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-${{ steps.pr-directory.outputs.directory_name }} - cp -r .github /tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }} + 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 @@ -317,15 +325,15 @@ jobs: git config user.email 'github-actions[bot]@users.noreply.github.com' # Create PR directory and copy built site - mkdir -p ${{ steps.pr-directory.outputs.directory_name }} - cp -r /tmp/pr-site-${{ steps.pr-directory.outputs.directory_name }}/* ${{ steps.pr-directory.outputs.directory_name }}/ + mkdir -p "$DIR_NAME" + cp -r "/tmp/pr-site-${DIR_NAME}/"* "$DIR_NAME/" # Create redirect page for slugified URL if different - if [ "${{ steps.pr-directory.outputs.slugified_name }}" != "${{ steps.pr-directory.outputs.directory_name }}" ]; then - mkdir -p ${{ steps.pr-directory.outputs.slugified_name }} - 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 redirect from ${{ steps.pr-directory.outputs.slugified_name }} to ${{ steps.pr-directory.outputs.directory_name }}" + 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 @@ -334,10 +342,10 @@ jobs: fi # Generate preview index page - if [ -f "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/scripts/generate-preview-index.sh" ]; then - cp "/tmp/pr-github-${{ steps.pr-directory.outputs.directory_name }}/scripts/generate-preview-index.sh" ./ + 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-${{ steps.pr-directory.outputs.directory_name }}/templates/preview-index.html" .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 @@ -349,7 +357,7 @@ jobs: exit 0 fi - git commit -m "Deploy preview for PR #${{ steps.pr.outputs.number }} - ${{ steps.pr.outputs.sha }}" + git commit -m "Deploy preview for PR #${PR_NUM} - ${PR_SHA}" # SSH agent is still active from earlier setup git push origin main From ed9c319618242b913a21f959a8a6a46b8b134faa Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 12:35:47 +1030 Subject: [PATCH 13/15] Fix expression injection and workflow_run context in verification MED-004: Pass workflow_dispatch inputs (pr_number, preview_url) via env: instead of direct ${{ }} interpolation in actions/github-script. An internal contributor triggering workflow_dispatch with a crafted pr_number could otherwise inject JavaScript. LOW-002: Fix comment-verification-results.js to read PR number from process.env.PR_NUMBER instead of context.payload.pull_request.number. The latter is always null for workflow_run-triggered workflows, causing the script to throw and silently fail to post verification comments. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/scripts/comment-verification-results.js | 11 ++++------- .github/workflows/preview-verification.yml | 13 +++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) 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/preview-verification.yml b/.github/workflows/preview-verification.yml index 197e5ba3..9b1eed0b 100644 --- a/.github/workflows/preview-verification.yml +++ b/.github/workflows/preview-verification.yml @@ -42,12 +42,16 @@ jobs: - 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}`); @@ -418,14 +422,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' From e1b9698c64250dee15c038c33ffd3970669d4b34 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 12:37:49 +1030 Subject: [PATCH 14/15] Fix expression injection, workflow_run context, and pin muffet MED-004: Pass workflow_dispatch inputs (pr_number, preview_url) via env: instead of direct ${{ }} interpolation in actions/github-script JavaScript. Prevents code injection by internal contributors triggering manual verification runs. LOW-002: Fix comment-verification-results.js to read PR number from process.env.PR_NUMBER instead of context.payload.pull_request which is null for workflow_run events. The workflow now passes PR_NUMBER via env from the pr-info step output. LOW-003: Pin muffet to v2.11.2 with SHA256 checksum verification. Prevents supply chain attacks from compromised releases or MITM. Previous code used /latest/ which is a moving target with no integrity check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/preview-verification.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/preview-verification.yml b/.github/workflows/preview-verification.yml index 9b1eed0b..9cdcf887 100644 --- a/.github/workflows/preview-verification.yml +++ b/.github/workflows/preview-verification.yml @@ -153,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 From 5c5d904507cf41a67ef758454d8192f1d5deb456 Mon Sep 17 00:00:00 2001 From: Tim 'mithro' Ansell Date: Fri, 20 Mar 2026 15:57:30 +1030 Subject: [PATCH 15/15] Fix YAML block scalar indentation in template literals Multi-line JavaScript template literals in actions/github-script had lines at column 1 (e.g. ${forkBadge}), which exits the YAML | block scalar. GitHub rejects the workflow file on push validation. Fix: replace multi-line template literals with array.join('\n') so all code stays within the YAML indentation level. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-preview-deploy.yml | 64 +++++++++++++------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index b6a8b45d..107f0113 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -469,20 +469,22 @@ jobs: 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`; + 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({ @@ -539,22 +541,24 @@ The preview site is available at: ${previewUrl}${securityNotice} 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)`; + 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({