Skip to content

Deploy Preview

Deploy Preview #7417

# Because C.I. jobs could expose secrets to malicious pull requsets,
# GitHub prevents (by default) exposing action secrets to pull requests
# from forks.
#
# This is great, however, the jobs that use the secrets are still useful on
# pull requests.
#
# To run a _trusted_ workflow, we can trigger it from an event from an _untrusted_
# workflow. This keeps the secrets out of reach from the fork, but still allows
# us to keep the utility of pull request preview deploys, browserstack running, etc.
# Normally, this _trusted_ behavior is offloaded by Cloudflare, Netlify, Vercel, etc
# -- their own workers are trusted and can push comments / updates to pull requests.
#
# We don't want to use their slower (and sometimes paid) hardware.
# When we use our own workflows, we can re-use the cache built from the PR
# (or elsewhere).
#
# To be *most* secure, you'd need to build all the artifacts in the PR,
# then upload them to then be downloaded in the trusted workflows.
# Trusted workflows should not run any scripts from a PR, as malicious
# submitters may tweak the build scripts.
# Since all build artifacts are for the web browser, and not executed in
# node-space, we can be reasonably confident that downloading and testing/deploying
# those artifacts does not compromise our secrets.
#
#
# More information here:
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
#
# Things that would make this easier:
# Readablity: https://github.com/actions/download-artifact/issues/172
# Security:
# - if there was a way to avoid pnpm install *entirely*
name: Deploy Preview
# read-write repo token
# access to secrets
on:
workflow_dispatch:
inputs:
prNumber:
required: true
description: Which PR to create a preview for
workflow_run:
workflows: ["CI"]
types:
# as early as possible
- requested
concurrency:
group: deploy-preview-${{ github.event.workflow_run.pull_requests[0].number }}
cancel-in-progress: true
env:
TURBO_API: http://127.0.0.1:9080
TURBO_TOKEN: this-is-not-a-secret
TURBO_TEAM: myself
jobs:
# This is the only job that needs access to the source code
Build:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [determinePR]
steps:
- uses: actions/checkout@v4
with:
# Defaults to ${{ github.repository }}, but that doesn't work for forks
# This should also make it obvious why this is in a separate workflow
# and in a job that doesn't have access to our secrets.
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }}
- name: TurboRepo local server
uses: felixmosh/turborepo-gh-artifacts@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: wyvox/action-setup-pnpm@v3
- run: pnpm turbo build
- uses: actions/upload-artifact@v4
with:
name: deploy-prep-dist
if-no-files-found: error
path: |
./apps/**/dist/**/*
!node_modules/
!./**/node_modules/
#################################################################
# For the rest:
# Does not checkout code, has access to secrets
#################################################################
determinePR:
# this job gates the others -- if the workflow_run request did not come from a PR,
# exit as early as possible
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request' || github.event.inputs.prNumber
outputs:
number: ${{ steps.pr.outputs.result || steps.number.outputs.pr-number || github.event.inputs.prNumber }}
steps:
- run: echo '${{ toJSON(github.event) }}'
name: "debug workflow_run event"
- run: echo "${{ github.event.number }}"
name: 'debug github.event.number'
# When PR comes from a fork,
# github.event.workflow_run.pull_requests is empty
- run: echo "${{ github.event.workflow_run.pull_requests[0].number }}"
id: number
# https://github.com/orgs/community/discussions/25220#discussioncomment-8697399
- name: Find associated pull request
# if: "!${{ steps.number.output.pr-number }}"
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const response = await github.rest.search.issuesAndPullRequests({
q: 'repo:${{ github.repository }} is:pr sha:${{ github.event.workflow_run.head_sha }}',
per_page: 1,
})
const items = response.data.items
if (items.length < 1) {
console.error('No PRs found')
return
}
const pullRequestNumber = items[0].number
console.info("Pull request number is", pullRequestNumber)
return pullRequestNumber
# We can know the URL ahead of time:
# https://<SHA>.limber-glimmer-tutorial.pages.dev
# https://<SHA>.limber-glimdown.pages.dev
DeployPreview:
name: "Deploy: Preview ${{ matrix.app.name }}"
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [Build]
permissions:
contents: read
strategy:
matrix:
app:
- { path: "./tutorial/dist", cloudflareName: "limber-glimmer-tutorial", name: "tutorial" }
- { path: "./repl/dist", cloudflareName: "limber-glimdown", name: "limber" }
steps:
- uses: actions/download-artifact@v4
name: deploy-prep-dist
- name: Preview ${{ matrix.app.name }}
working-directory: ./deploy-prep-dist/${{ matrix.app.path }}
run: |
npx wrangler pages deploy ./ \
--project-name=${{ matrix.app.cloudflareName }} \
--branch=${{ github.event.workflow_run.head_branch }} \
--commit-hash=${{ github.event.workflow_run.head_sha }}
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ReformatSubDomain:
name: Reformat Sub-domain Alias
runs-on: ubuntu-latest
needs: [determinePR]
outputs:
subdomain: ${{ steps.reformat.outputs.subdomain }}
steps:
- id: reformat
run: |
branch="${{ github.event.workflow_run.head_branch }}"
# Remove any / in the branch name
# renovate/uuid becomes renovate-uuid
subdomain="${branch//\//-}"
subdomain=$(echo $subdomain | cut -c -28)
echo "subdomain=$subdomain" >> $GITHUB_OUTPUT
PostComment:
name: Post Preview URL as comment to PR
runs-on: ubuntu-latest
needs: [DeployPreview, ReformatSubDomain, determinePR]
permissions:
pull-requests: write
steps:
- uses: marocchino/sticky-pull-request-comment@v2
with:
header: preview-urls
number: ${{ needs.determinePR.outputs.number }}
message: |+
| Project | Preview URL[^note] | Manage |
| ------- | ------------------ | ------ |
| Limber | https://${{ needs.ReformatSubDomain.outputs.subdomain }}.limber-glimdown.pages.dev | [on Cloudflare](https://dash.cloudflare.com/c67910a047e1510fec6d0d0cf442934c/pages/view/limber-glimdown) |
| Tutorial | https://${{ needs.ReformatSubDomain.outputs.subdomain }}.limber-glimmer-tutorial.pages.dev | [on Cloudflare](https://dash.cloudflare.com/c67910a047e1510fec6d0d0cf442934c/pages/view/limber-glimmer-tutorial)
[Logs](https://github.com/NullVoxPopuli/limber/actions/runs/${{ github.run_id }})
[^note]: if these branch preview links are not working, please check the logs for the commit-based preview link. There is a character limit of 28 for the branch subdomain, as well as some other heuristics, [described here](https://community.cloudflare.com/t/algorithm-to-generate-a-preview-dns-subdomain-from-a-branch-name/477633?u=walshymvp) for the sake of implementation ease in deploy-preview.yml, that algo has been omitted. The URLs are logged in the wrangler output, but it's hard to get outputs from a matrix job.