Skip to content

Commit 4a8ed76

Browse files
jmelahmanclaude
andauthored
ci: deploy full-stack previews to the local-preview server (#14030)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 132b355 commit 4a8ed76

1 file changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
name: Local Preview Deployment
2+
3+
# Per-commit full-stack previews on the self-hosted local-preview server: the
4+
# Next.js frontend and the FastAPI backend running together at
5+
# <sha>-onyx.<the server's preview domain>. Which server that is lives
6+
# entirely in vars.PREVIEW_URL, managed in onyx-infra
7+
# (internal-tools/terraform/github-org) — nothing here names a host, so
8+
# pointing this at a different deployment is a variable change, not a PR.
9+
#
10+
# Separate from preview.yml (Vercel) because the two want different triggers.
11+
# The server does not watch this repo: it deploys only what this workflow
12+
# hands it, so a commit that never runs this job never gets a preview. That is
13+
# why backend/** is here and is not in preview.yml — Vercel has no backend to
14+
# build and should not run for a backend-only change.
15+
on:
16+
push:
17+
branches-ignore:
18+
- main
19+
paths:
20+
- "web/**"
21+
- "backend/**"
22+
# Redeploy a branch without pushing to it — for a preview lost to a server
23+
# rebuild, or a commit whose only changes fell outside the paths above.
24+
# Path filters do not apply to a dispatch, so this always builds.
25+
#
26+
# GitHub resolves dispatchable workflows from the default branch, so this
27+
# appears in the Actions tab only once this file is on main; the ref picker
28+
# then still runs the chosen branch's copy.
29+
workflow_dispatch:
30+
31+
# One deploy per branch at a time. Without this, two commits deploying
32+
# together can finish out of order and leave the PR comment on the older
33+
# commit's URL. Cancelling the older run also frees the server sooner.
34+
concurrency:
35+
group: ${{ github.workflow }}-${{ github.ref }}
36+
cancel-in-progress: true
37+
38+
permissions:
39+
contents: read
40+
41+
jobs:
42+
Deploy-Local-Preview:
43+
if: ${{ vars.PREVIEW_URL != '' }}
44+
runs-on: ubuntu-latest
45+
timeout-minutes: 30
46+
permissions:
47+
contents: read
48+
id-token: write
49+
pull-requests: write
50+
env:
51+
PREVIEW_URL: ${{ vars.PREVIEW_URL }}
52+
# Matches the server's --github-oidc-audience (terraform sets it to the
53+
# server URL). The CLI would default to exactly this; naming it keeps
54+
# the two ends visibly pinned to each other.
55+
PREVIEW_GITHUB_OIDC_AUDIENCE: ${{ vars.PREVIEW_URL }}
56+
steps:
57+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v6
58+
with:
59+
persist-credentials: false
60+
61+
- name: Setup bun
62+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # ratchet:oven-sh/setup-bun@v2
63+
with:
64+
bun-version: "1.3.13"
65+
66+
- name: Cache bun install cache
67+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
68+
with:
69+
path: ~/.bun/install/cache
70+
key: ${{ runner.os }}-bun-${{ hashFiles('web/bun.lock') }}
71+
restore-keys: |
72+
${{ runner.os }}-bun-
73+
74+
# next build's incremental compilation cache — the bulk of this job's
75+
# runtime is that one command, and without this every run compiles the
76+
# whole app from cold. Keyed on the sources so an unchanged tree restores
77+
# exactly; the restore-keys prefix means a changed one still starts from
78+
# the last build rather than from nothing.
79+
- name: Cache the Next.js build
80+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
81+
with:
82+
path: web/.next/cache
83+
key: ${{ runner.os }}-nextjs-${{ hashFiles('web/bun.lock') }}-${{ hashFiles('web/**/*.[jt]s', 'web/**/*.[jt]sx') }}
84+
restore-keys: |
85+
${{ runner.os }}-nextjs-${{ hashFiles('web/bun.lock') }}-
86+
${{ runner.os }}-nextjs-
87+
88+
- name: Setup uv
89+
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # ratchet:astral-sh/setup-uv@v9.0.0
90+
91+
- name: Install the preview CLI
92+
run: uv tool install local-preview==0.9.0
93+
94+
# Must match the frontend build in the server's own manifest for this
95+
# repo (manifests/onyx.toml in the terraform workspace) step for step —
96+
# an upload replaces what the server would have built, so a divergence
97+
# here is a preview that doesn't match what a rebuild would produce.
98+
#
99+
# @onyx-ai/opal depends on @onyx-ai/shared, so shared builds first.
100+
# (The Vercel job below has the two in the other order; it survives
101+
# because bun install's prepare scripts have already built both.)
102+
- name: Build the frontend
103+
working-directory: ./web
104+
run: |
105+
bun install --frozen-lockfile
106+
bun run --filter './lib/shared' build
107+
bun run --filter './lib/opal' build
108+
bun run build:fast
109+
cp -r .next/static .next/standalone/.next/static
110+
if [ -d public ]; then
111+
cp -r public .next/standalone/public
112+
fi
113+
114+
# The published tree becomes the process's working directory and the
115+
# manifest runs `node .next/standalone/server.js` in it, so the upload
116+
# has to keep that path — but nothing else. Shipping web/ wholesale
117+
# would mean uploading node_modules for no reason.
118+
- name: Assemble the upload tree
119+
run: |
120+
mkdir -p upload/.next
121+
cp -r web/.next/standalone upload/.next/standalone
122+
123+
- name: Upload the frontend and deploy
124+
id: deploy
125+
run: |
126+
# Without pipefail the tee below would mask a failed upload.
127+
set -o pipefail
128+
preview upload frontend upload "$GITHUB_SHA" \
129+
--repo onyx --server "$PREVIEW_URL" --oidc --deploy | tee out.txt
130+
URL=$(sed -n 's/^ready: //p' out.txt)
131+
echo "url=$URL" >> "$GITHUB_OUTPUT"
132+
133+
- name: Update PR comment with preview URL
134+
if: always() && steps.deploy.outputs.url
135+
env:
136+
GH_TOKEN: ${{ github.token }}
137+
PREVIEW_DEPLOYMENT_URL: ${{ steps.deploy.outputs.url }}
138+
# Which GitHub org the server admits, only so the comment can say
139+
# so. Optional — the sign-in line drops the clause when it is unset.
140+
PREVIEW_SSO_ORG: ${{ vars.PREVIEW_SSO_ORG }}
141+
run: |
142+
PR_NUMBER=$(gh pr list --head "$GITHUB_REF_NAME" --json number --jq '.[0].number')
143+
if [ -z "$PR_NUMBER" ]; then
144+
echo "No open PR found for branch $GITHUB_REF_NAME, skipping comment."
145+
exit 0
146+
fi
147+
148+
COMMENT_MARKER="<!-- local-preview-deployment -->"
149+
COMMENT_BODY="$COMMENT_MARKER
150+
**Full-stack Preview** (frontend + backend)
151+
152+
| Status | Preview | Commit | Updated |
153+
| --- | --- | --- | --- |
154+
| ✅ | $PREVIEW_DEPLOYMENT_URL | \`${GITHUB_SHA::7}\` | $(date -u '+%Y-%m-%d %H:%M:%S UTC') |
155+
156+
Sign in with GitHub${PREVIEW_SSO_ORG:+ as an \`$PREVIEW_SSO_ORG\` member} to view it."
157+
158+
# --paginate: on a long PR the marker can sit past the first page.
159+
EXISTING_COMMENT_ID=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
160+
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" | head -1)
161+
162+
if [ -n "$EXISTING_COMMENT_ID" ]; then
163+
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$EXISTING_COMMENT_ID" \
164+
--method PATCH --field body="$COMMENT_BODY"
165+
else
166+
gh pr comment "$PR_NUMBER" --body "$COMMENT_BODY"
167+
fi

0 commit comments

Comments
 (0)