Skip to content

Enable PR preview deployments for external contributors (fork PRs) #59

Description

@mithro

Problem Statement

Preview deployments are currently failing for pull requests from external contributors who are not part of the wafer-space GitHub organization.

Example failures:

Error observed:

RequestError [HttpError]: Resource not accessible by integration
status: 403

Root Cause Analysis

GitHub Actions implements a security model where pull requests from repository forks run with:

  1. Read-only token - Cannot create deployments or modify repository state
  2. No access to repository secrets - Cannot access JEKYLL_THEME_KEY or PREVIEW_KEY

This is by design to prevent malicious actors from:

  • Stealing repository secrets
  • Making unauthorized deployments
  • Accessing private resources

Current workflow dependencies that fail for forks:

  1. JEKYLL_THEME_KEY - Required to checkout private theme submodule
  2. PREVIEW_KEY - Required to push to preview.wafer.space repository
  3. Deployment API - Requires write permissions to create deployment objects

Solution Options

✅ Option 1: Two-Stage Workflow with workflow_run (RECOMMENDED)

Description:
Split the preview deployment into two separate workflows with a clear security boundary:

Stage 1: Metadata Collection (Untrusted Context)

  • Triggered by: pull_request event (runs for all PRs including forks)
  • Access: No secrets, read-only token
  • Actions:
    • Save PR metadata (number, SHA, title) as small JSON artifact
    • That's it! No building, no source upload

Stage 2: Build & Deploy (Trusted Context)

  • Triggered by: workflow_run event when Stage 1 completes
  • Access: Full secrets, write token, runs from base branch
  • Actions:
    • Download PR metadata from artifact
    • Checkout PR code directly from git using SHA (no artifact needed!)
    • Checkout private theme using JEKYLL_THEME_KEY
    • Build Jekyll site with full theme
    • Deploy to preview.wafer.space using PREVIEW_KEY
    • Create GitHub deployment
    • Comment on PR with preview URL

Pros:

  • Secure - Untrusted code never has secret access, deployment runs from trusted base branch
  • Automatic - No manual approval needed, great UX for contributors
  • Recommended by GitHub - Official pattern for fork PR workflows
  • Clean separation - Clear trust boundary between stages
  • Works for all PRs - Both internal and external contributors
  • Simple - Only passes tiny metadata artifact, checks out code directly from git
  • Fast - Single build with full theme (not two builds)

Cons:

  • ⚠️ Requires two workflow files instead of one
  • ⚠️ Slight delay for artifact upload/download (metadata only, <1 second)

Security considerations:

  • Stage 1 cannot access secrets (safe for forks)
  • Stage 2 validates and checks out code, then deploys
  • Stage 2 runs trusted workflow code from base branch
  • Only a tiny JSON metadata file passes between stages

Option 2: pull_request_target with GitHub Environment Protection

Description:
Replace pull_request trigger with pull_request_target, but protect with environment rules requiring manual approval.

Implementation:

  1. Create GitHub Environment (e.g., "pr-preview")
  2. Configure environment protection rules:
    • Require approval from maintainers team
    • Limit to pull_request_target events
  3. Modify workflow to use environment with secrets

Pros:

  • ✅ Simpler workflow structure (single workflow file)
  • ✅ Full control - maintainer reviews before deployment
  • ✅ Can inspect code before granting access to secrets

Cons:

  • Manual approval required - Every external PR needs maintainer action
  • Poor contributor UX - Delays, friction, requires maintainer availability
  • Maintainer burden - Someone must review and approve each PR preview
  • Risk of mistakes - Accidental approval of malicious code possible
  • Scalability issues - Doesn't scale well with many contributors

Security considerations:

  • Requires vigilant review of each PR before approval
  • Environment protection provides guardrails
  • Still some risk of human error in approval process

Note: This option is unchanged - it doesn't benefit from the artifact simplification since it's a single-stage workflow.

Option 3: pull_request_target without Protection (NOT RECOMMENDED)

Description:
Simply change trigger from pull_request to pull_request_target to grant secret access.

Pros:

  • ✅ Simplest to implement (minimal code change)
  • ✅ Fully automatic

Cons:

  • DANGEROUS - Exposes secrets to any fork PR
  • Security risk - Malicious PR could steal JEKYLL_THEME_KEY and PREVIEW_KEY
  • Compromise risk - Could push malicious code to preview.wafer.space
  • Not acceptable - Violates security best practices for open source

Security considerations:

  • DO NOT USE - This option is fundamentally insecure
  • Would allow any external contributor to exfiltrate secrets
  • Could enable supply chain attacks

Note: This option is unchanged and remains dangerous.

Option 4: Public Theme Build Artifact

Description:
Create a public, pre-built version of the theme (without source) that can be used for PR previews.

Implementation:

  1. Create automated build of theme to static assets
  2. Publish theme build artifacts to public location (npm, CDN, etc.)
  3. Modify preview workflow to use public theme build
  4. Keep theme source repository private

Pros:

  • ✅ Eliminates need for JEKYLL_THEME_KEY in PR workflow
  • ✅ Simplifies authentication
  • ✅ One less secret to worry about
  • ✅ Could improve build times (pre-built theme)

Cons:

  • ⚠️ Additional infrastructure (publishing pipeline)
  • ⚠️ Still need PREVIEW_KEY for deployment (still needs Option 1 or 2)
  • ⚠️ May leak some theme implementation details
  • ⚠️ Doesn't solve the full problem alone

Security considerations:

  • Theme implementation somewhat exposed (compiled form)
  • Must be combined with Option 1 or 2 for PREVIEW_KEY access
  • Could simplify Option 1 by eliminating one secret dependency

Update: This option can be combined with Option 1. If you publish a public theme build, Stage 2 could use it instead of checking out the private theme, eliminating the JEKYLL_THEME_KEY dependency entirely.

Option 5: Conditional Deploy (No Auto-Deploy for Forks)

Description:
Don't automatically deploy previews for fork PRs. Only deploy for internal PRs from branches in the main repo.

Implementation:
Add condition to workflow:

if: github.event.pull_request.head.repo.full_name == github.repository

Pros:

  • ✅ Most secure (no changes needed)
  • ✅ Zero implementation effort
  • ✅ No secret exposure risk
  • ✅ Works with existing workflow

Cons:

  • Poor UX - External contributors can't see previews
  • Defeats purpose - Preview system doesn't work for forks
  • Discourages contributions - Higher barrier for external contributors
  • Testing burden - Maintainers must manually test fork PRs locally

Note: This option is unchanged - it's the status quo workaround.

Recommendation: Option 1 (Two-Stage Workflow with workflow_run)

Why this is the best choice:

  1. Aligns with stated priorities - "ease of use for people sending pull requests should be prioritized"

    • No manual approval needed
    • Automatic previews for all contributors
    • Transparent process
  2. Secure by design - Follows GitHub's recommended pattern

    • Untrusted code never has secret access
    • Deployment logic runs from base branch
    • Clear trust boundary
  3. Best practices - Industry standard for this use case

    • Used by major open source projects
    • Officially recommended by GitHub
    • Well-documented pattern
  4. Scalable - Works well as project grows

    • No maintainer intervention required
    • Handles high PR volume
    • Consistent experience for all contributors
  5. Risk appropriate - Given that "accidentally exposing the proprietary theme module is not a huge concern"

    • Can optionally use public theme build for fork PRs (Option 1 + 4)
    • Preview.wafer.space exposure is acceptable
    • Deployment is still controlled
  6. Simple and fast - Simpler than initially thought

    • Only tiny metadata artifact passes between stages
    • PR code checked out directly from git in Stage 2
    • Single build with full theme
    • Minimal overhead

Implementation Outline for Option 1

Stage 1: Metadata Collection (Untrusted)

File: .github/workflows/pr-preview-build.yml

name: PR Preview Build
on:
  pull_request:
    branches: ["main"]

jobs:
  save-pr-context:
    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 }}"
          }
          EOF
      
      - name: Upload PR context
        uses: actions/upload-artifact@v4
        with:
          name: pr-context-${{ github.event.pull_request.number }}
          path: pr-context.json

Stage 2: Build & Deploy (Trusted)

File: .github/workflows/pr-preview-deploy.yml

name: PR Preview Deploy
on:
  workflow_run:
    workflows: ["PR Preview Build"]
    types: [completed]

jobs:
  deploy-preview:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - name: Download PR context
        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 "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)" >> $GITHUB_OUTPUT
      
      - name: Checkout PR code directly from git
        uses: actions/checkout@v4
        with:
          ref: ${{ steps.pr.outputs.sha }}
          submodules: false
      
      - name: Checkout private theme using SSH
        run: |
          eval $(ssh-agent -s)
          echo "${{ secrets.JEKYLL_THEME_KEY }}" | ssh-add -
          mkdir -p ~/.ssh
          ssh-keyscan github.com >> ~/.ssh/known_hosts
          git submodule update --init --recursive
      
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1'
          bundler-cache: true
      
      - name: Build Jekyll site with full private theme
        run: |
          bundle exec jekyll build --baseurl "/pr-${{ steps.pr.outputs.number }}"
        env:
          JEKYLL_ENV: production
      
      - name: Deploy to preview.wafer.space
        run: |
          eval $(ssh-agent -s)
          echo "${{ secrets.PREVIEW_KEY }}" | ssh-add -
          
          git clone git@github.com:wafer-space/preview.wafer.space.git preview-repo
          cd preview-repo
          
          mkdir -p pr-${{ steps.pr.outputs.number }}
          cp -r ../_site/* pr-${{ steps.pr.outputs.number }}/
          
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add .
          git commit -m "Deploy preview for PR #${{ steps.pr.outputs.number }}"
          git push origin main
      
      - name: Create deployment and comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = ${{ steps.pr.outputs.number }};
            
            // Create deployment
            const { data: deployment } = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: '${{ steps.pr.outputs.sha }}',
              environment: `pr-preview-${prNumber}`,
              transient_environment: true,
              production_environment: false,
              required_contexts: []
            });
            
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deployment.id,
              state: 'success',
              environment_url: `https://preview.wafer.space/pr-${prNumber}/`
            });
            
            // Comment on PR
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `## ✅ Preview Deployed\n\n🔗 **Preview URL:** https://preview.wafer.space/pr-${prNumber}/\n\n---\n<sub>⚡ Deployed via workflow_run • Preview will be removed when PR is closed</sub>`
            });

Security Model

┌─────────────────────────────────────────────────────────────┐
│  Stage 1: Untrusted (Fork PR context)                       │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  • No secrets                                        │    │
│  │  • Read-only git access                             │    │
│  │  • Can only write to workflow artifacts             │    │
│  │  • Saves tiny JSON metadata only                    │    │
│  └─────────────────────────────────────────────────────┘    │
│                            │                                  │
│                            │ (passes PR metadata only)       │
│                            ▼                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Stage 2: Trusted (Base branch context)             │    │
│  │  • Full secrets access                              │    │
│  │  • Can checkout any git ref directly                │    │
│  │  • Can deploy to external repos                     │    │
│  │  • Builds site with private theme                   │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

Key Points

  1. No source artifact upload/download - Stage 2 checks out PR code directly from git using the SHA
  2. Single build - Only builds once in Stage 2 with full private theme
  3. Minimal artifacts - Only a tiny JSON file with PR metadata passes between stages
  4. Simple & fast - Much simpler than typical workflow_run examples that upload build outputs

Alternative: Hybrid Approach (Option 1 + Option 4)

For maximum simplicity, could combine approaches:

  • Option 1: Two-stage workflow for deployment security
  • Option 4: Public theme build to eliminate JEKYLL_THEME_KEY dependency

This would mean:

  • Fork PRs use public theme build (acceptable per requirements)
  • All PRs get automatic previews (great UX)
  • Deployment still secure (workflow_run pattern)
  • One less secret to manage

Questions for Discussion

  1. Theme handling preference:

    • Option A: Use private theme in Stage 2 (current plan, requires JEKYLL_THEME_KEY)
    • Option B: Create public theme build and use in Stage 2 (eliminates JEKYLL_THEME_KEY)
  2. Verification workflow:

    • Should preview-verification.yml also use workflow_run?
    • Or can it run safely on forks (uses no secrets, just verifies URLs)?
  3. Deployment approach:

    • Keep current two-repository model (main repo + preview.wafer.space)?
    • Continue with current architecture?
  4. Rollout strategy:

    • Test with specific PR first?
    • Gradual rollout or immediate switch?

References

Priority

High - Currently blocking external contributor previews, impacting open source collaboration.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions