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'