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:
- Read-only token - Cannot create deployments or modify repository state
- 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:
JEKYLL_THEME_KEY - Required to checkout private theme submodule
PREVIEW_KEY - Required to push to preview.wafer.space repository
- 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:
- Create GitHub Environment (e.g., "pr-preview")
- Configure environment protection rules:
- Require approval from maintainers team
- Limit to pull_request_target events
- 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:
- Create automated build of theme to static assets
- Publish theme build artifacts to public location (npm, CDN, etc.)
- Modify preview workflow to use public theme build
- 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:
-
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
-
Secure by design - Follows GitHub's recommended pattern
- Untrusted code never has secret access
- Deployment logic runs from base branch
- Clear trust boundary
-
Best practices - Industry standard for this use case
- Used by major open source projects
- Officially recommended by GitHub
- Well-documented pattern
-
Scalable - Works well as project grows
- No maintainer intervention required
- Handles high PR volume
- Consistent experience for all contributors
-
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
-
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
- No source artifact upload/download - Stage 2 checks out PR code directly from git using the SHA
- Single build - Only builds once in Stage 2 with full private theme
- Minimal artifacts - Only a tiny JSON file with PR metadata passes between stages
- 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
-
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)
-
Verification workflow:
- Should
preview-verification.yml also use workflow_run?
- Or can it run safely on forks (uses no secrets, just verifies URLs)?
-
Deployment approach:
- Keep current two-repository model (main repo + preview.wafer.space)?
- Continue with current architecture?
-
Rollout strategy:
- Test with specific PR first?
- Gradual rollout or immediate switch?
References
Priority
High - Currently blocking external contributor previews, impacting open source collaboration.
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:
Root Cause Analysis
GitHub Actions implements a security model where pull requests from repository forks run with:
JEKYLL_THEME_KEYorPREVIEW_KEYThis is by design to prevent malicious actors from:
Current workflow dependencies that fail for forks:
JEKYLL_THEME_KEY- Required to checkout private theme submodulePREVIEW_KEY- Required to push to preview.wafer.space repositorySolution 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)
pull_requestevent (runs for all PRs including forks)Stage 2: Build & Deploy (Trusted Context)
workflow_runevent when Stage 1 completesJEKYLL_THEME_KEYPREVIEW_KEYPros:
Cons:
Security considerations:
Option 2:
pull_request_targetwith GitHub Environment ProtectionDescription:
Replace
pull_requesttrigger withpull_request_target, but protect with environment rules requiring manual approval.Implementation:
Pros:
Cons:
Security considerations:
Note: This option is unchanged - it doesn't benefit from the artifact simplification since it's a single-stage workflow.
Option 3:
pull_request_targetwithout Protection (NOT RECOMMENDED)Description:
Simply change trigger from
pull_requesttopull_request_targetto grant secret access.Pros:
Cons:
JEKYLL_THEME_KEYandPREVIEW_KEYSecurity considerations:
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:
Pros:
JEKYLL_THEME_KEYin PR workflowCons:
PREVIEW_KEYfor deployment (still needs Option 1 or 2)Security considerations:
PREVIEW_KEYaccessUpdate: 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_KEYdependency 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:
Pros:
Cons:
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:
Aligns with stated priorities - "ease of use for people sending pull requests should be prioritized"
Secure by design - Follows GitHub's recommended pattern
Best practices - Industry standard for this use case
Scalable - Works well as project grows
Risk appropriate - Given that "accidentally exposing the proprietary theme module is not a huge concern"
Simple and fast - Simpler than initially thought
Implementation Outline for Option 1
Stage 1: Metadata Collection (Untrusted)
File:
.github/workflows/pr-preview-build.ymlStage 2: Build & Deploy (Trusted)
File:
.github/workflows/pr-preview-deploy.ymlSecurity Model
Key Points
Alternative: Hybrid Approach (Option 1 + Option 4)
For maximum simplicity, could combine approaches:
JEKYLL_THEME_KEYdependencyThis would mean:
Questions for Discussion
Theme handling preference:
JEKYLL_THEME_KEY)JEKYLL_THEME_KEY)Verification workflow:
preview-verification.ymlalso use workflow_run?Deployment approach:
Rollout strategy:
References
Priority
High - Currently blocking external contributor previews, impacting open source collaboration.