Add a /preview PR command that deploys the build to R2
Commenting /preview on a pull request builds its head commit and syncs
dist/ to the tempory bucket under previews/<repo>/<branch>/, then replies
with the link. The workflow is self-contained so it can be dropped into
other repos: only the build block between the marked comments is
specific to this one.
1 changed file+148−0
.github/workflows/preview.ymladded+148−0View file
@@ -0,0 +1,148 @@
1+## Comment `/preview` on a pull request to build it and publish the result to
2+## https://tempory.net/previews/<repo>/<branch>/index.html
3+##
4+## This file is meant to be copied into other repos as-is. Only the "build"
5+## block below is repo-specific; everything else is driven by `env` at the top.
6+##
7+## Two things to know before copying it:
8+##
9+## 1. `issue_comment` workflows only ever run the copy of this file on the
10+## DEFAULT branch, so it has to be merged to main before `/preview` works
11+## on any PR — including the PR that adds it.
12+## 2. It checks out and builds the PR's head commit while the R2 credentials
13+## are in scope. The `author_association` guard means only the repo owner,
14+## an org member, or a collaborator can trigger it, but one of those people
15+## asking for a preview of an untrusted fork PR would run that fork's code.
16+## Read the diff before commenting `/preview` on a fork.
17+
18+name: preview
19+
20+on:
21+ issue_comment:
22+ types: [created]
23+
24+env:
25+ R2_BUCKET: tempory
26+ R2_PREFIX: previews
27+ PREVIEW_BASE_URL: https://tempory.net/previews
28+ DIST_DIR: dist
29+ NODE_VERSION: "24"
30+
31+permissions:
32+ contents: read
33+ issues: write
34+ pull-requests: write
35+
36+concurrency:
37+ group: preview-${{ github.event.issue.number }}
38+ cancel-in-progress: true
39+
40+jobs:
41+ preview:
42+ if: >-
43+ github.event.issue.pull_request &&
44+ startsWith(github.event.comment.body, '/preview') &&
45+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
46+ runs-on: ubuntu-latest
47+ env:
48+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+ PR_NUMBER: ${{ github.event.issue.number }}
50+ APP_NAME: ${{ github.event.repository.name }}
51+ steps:
52+ - name: Acknowledge the command
53+ env:
54+ COMMENT_ID: ${{ github.event.comment.id }}
55+ run: |
56+ gh api --silent -X POST \
57+ "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" \
58+ -f content=eyes
59+
60+ # The comment event carries no commit info, so ask the API what the PR
61+ # currently points at. `head.repo` differs from this repo for fork PRs.
62+ - name: Resolve the PR head
63+ id: head
64+ run: |
65+ gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
66+ --jq '"repo=\(.head.repo.full_name)", "sha=\(.head.sha)", "branch=\(.head.ref)"' \
67+ >> "$GITHUB_OUTPUT"
68+
69+ # Branch names may contain characters that are awkward in a URL path
70+ # (most commonly `/`, as in `feat/thing`), so flatten to one path segment.
71+ # BRANCH goes through the environment rather than `${{ }}` because on a
72+ # fork PR its value is chosen by someone outside the org.
73+ - name: Compute the deploy path
74+ id: path
75+ env:
76+ BRANCH: ${{ steps.head.outputs.branch }}
77+ run: |
78+ slug=$(printf '%s' "$BRANCH" | tr -c 'A-Za-z0-9._-' '-')
79+ echo "key=$R2_PREFIX/$APP_NAME/$slug" >> "$GITHUB_OUTPUT"
80+ echo "url=$PREVIEW_BASE_URL/$APP_NAME/$slug/index.html" >> "$GITHUB_OUTPUT"
81+
82+ - uses: actions/checkout@v4
83+ with:
84+ repository: ${{ steps.head.outputs.repo }}
85+ ref: ${{ steps.head.outputs.sha }}
86+
87+ - uses: actions/setup-node@v4
88+ with:
89+ node-version: ${{ env.NODE_VERSION }}
90+ cache: npm
91+
92+ ## ---- repo-specific build (replace this block when copying) ----------
93+ # numbl is a `file:../../numbl` dependency: we use its compiler internals
94+ # (parser, lowerer, IR, inline pass), which its published package
95+ # `exports` do not expose. Clone it where that relative path expects it,
96+ # pinned to the same ref as ci.yml and deploy.yml.
97+ - name: Check out numbl (sibling dependency)
98+ env:
99+ NUMBL_REF: 38ce14046d64d03ecf05cb57def53057a6bc64ab
100+ run: |
101+ git clone --filter=blob:none --no-checkout \
102+ https://github.com/flatironinstitute/numbl.git "$GITHUB_WORKSPACE/../../numbl"
103+ git -C "$GITHUB_WORKSPACE/../../numbl" checkout --quiet "$NUMBL_REF"
104+ # --ignore-scripts: npm runs a linked package's `prepare` script, and
105+ # numbl's is husky, which is not installed here.
106+ - run: npm ci --ignore-scripts
107+ - run: npm run build
108+ ## ---- end repo-specific build ----------------------------------------
109+
110+ - name: Publish to R2
111+ env:
112+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
113+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
114+ AWS_DEFAULT_REGION: auto
115+ R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
116+ # R2 rejects the extra integrity checksums AWS CLI v2 adds by default.
117+ AWS_REQUEST_CHECKSUM_CALCULATION: when_required
118+ AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
119+ KEY: ${{ steps.path.outputs.key }}
120+ run: |
121+ # Two passes so the entry HTML is always revalidated while the
122+ # content-hashed assets around it can be cached hard. The filters
123+ # apply to both sides of the sync, so `--delete` in each pass only
124+ # ever removes files of that same kind.
125+ aws s3 sync "$DIST_DIR" "s3://$R2_BUCKET/$KEY" \
126+ --endpoint-url "$R2_ENDPOINT" --delete --no-progress \
127+ --exclude '*.html' \
128+ --cache-control 'public, max-age=31536000, immutable'
129+ aws s3 sync "$DIST_DIR" "s3://$R2_BUCKET/$KEY" \
130+ --endpoint-url "$R2_ENDPOINT" --delete --no-progress \
131+ --exclude '*' --include '*.html' \
132+ --cache-control 'no-cache'
133+
134+ - name: Comment with the preview link
135+ env:
136+ URL: ${{ steps.path.outputs.url }}
137+ SHA: ${{ steps.head.outputs.sha }}
138+ run: |
139+ gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
140+ --body "Preview of \`${SHA:0:7}\` is live: $URL"
141+
142+ - name: Report failure
143+ if: failure()
144+ env:
145+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
146+ run: |
147+ gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
148+ --body "Preview build failed — [run log]($RUN_URL)."