Skip to content

Commit 91f9eda

Browse files
hotlongclaude
andcommitted
ci(docs): build the docs site on a free GitHub runner and deploy it prebuilt; turn Vercel's Git deployments off
Vercel bills Build CPU Minutes for every Git-triggered deployment of the docs project, including the ~533 preview deployments a day that clone the monorepo, run the Ignored Build Step script and are canceled. Measured on 2026-10-12, Build CPU Minutes are 97% of the team's invoice and this project is ~$70 of it. - apps/docs/vercel.json: `git.deploymentEnabled: false`, so no branch push creates a Vercel deployment. The `ignoreCommand` only ever ran inside a Git deployment, so it and scripts/vercel-ignore-docs.sh (with its self-test, which no workflow ran) are removed. `buildCommand` and `installCommand` stay: `vercel build` runs them, and ci.yml's Build Docs gate reads `buildCommand` from this file. - .github/workflows/docs-deploy.yml: every six hours (:43 past 00/06/12/18 UTC) and on workflow_dispatch, on ubuntu-latest (free for this public repository): `vercel pull`, `vercel build --prod`, then `vercel deploy --prebuilt --prod`, with the CLI pinned and every command run from the repository root because the project's Root Directory is apps/docs. A missing VERCEL_TOKEN, a non-main ref, or a build that left no output fails the run red; nothing skips green. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 018b077 commit 91f9eda

4 files changed

Lines changed: 231 additions & 167 deletions

File tree

‎.github/workflows/docs-deploy.yml‎

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
name: Docs Deploy
2+
3+
# The ONE path by which the docs site (Vercel project `objectstack`, Root
4+
# Directory `apps/docs`) reaches production.
5+
#
6+
# ## Why the build runs here and not on Vercel
7+
#
8+
# Vercel bills Build CPU Minutes, and it bills them for deployments that do not
9+
# end up building anything. Measured on 2026-10-12 against this project: ~600
10+
# deployments a day, ~533 of them Git-integration PREVIEW deployments for
11+
# `claude/*`, `gh-readonly-queue/*`, `board-archive*` and `changeset-release/*`
12+
# branches. Each one cloned the monorepo, ran the old Ignored Build Step script
13+
# for ~25 s and was then canceled — and was still billed: billed CPU-minutes
14+
# divided by production-build wall-minutes came out at 12–15, more than the
15+
# 8-vCPU build machine can produce on its own, so the canceled ones are in the
16+
# bill. They were ~40% of this project's cost; the ~34 production builds a day
17+
# that pushes to `main` triggered were most of the rest. Build CPU Minutes were
18+
# 97% of the team's invoice.
19+
#
20+
# The maintainer's ruling, verbatim and untranslated:
21+
# 「文档站应该只有主分支需要部署,而且应该用免费的」 — only `main` deploys the docs,
22+
# and on free compute. This repository is public, so a standard
23+
# `ubuntu-latest` runner costs nothing. So this job builds the site on that
24+
# runner with `vercel build` and uploads the result with
25+
# `vercel deploy --prebuilt`: Vercel stores and serves it and builds nothing.
26+
# `apps/docs/vercel.json` turns the Git integration's deployments off
27+
# (`git.deploymentEnabled: false`), so no branch push creates a Vercel
28+
# deployment at all — billed, canceled or otherwise.
29+
#
30+
# ## Failure direction: a silent skip is the worst outcome
31+
#
32+
# A run that cannot deploy goes RED, never green-and-skipped. The site going
33+
# quietly stale with no error anywhere is exactly what the old Ignored Build
34+
# Step script was written around ("a wrong skip silently stops publishing
35+
# documentation"), and moving the build here does not change which direction
36+
# is the dangerous one. In particular a missing `VERCEL_TOKEN` secret (an
37+
# objectstack-ai organization secret; a repository secret of the same name
38+
# resolves identically) is an `::error::` and a failed run, never a notice:
39+
# unlike an optional mirror, this job IS the publishing path, and without it
40+
# nothing else publishes the docs.
41+
42+
on:
43+
schedule:
44+
# Every six hours, at :43 past 00, 06, 12 and 18 UTC. The maintainer's
45+
# decision: the docs refresh at most six hours behind `main`, and
46+
# "Run workflow" on this workflow's Actions page (workflow_dispatch below)
47+
# publishes `main` immediately when that is too slow. Builds cost nothing
48+
# here at any frequency; four a day (against ~87 commits a day on `main`)
49+
# keeps this job from competing with CI for runners and needs no
50+
# paths-filter list to keep in step with what the site is built from.
51+
#
52+
# The HOURS keep out of this repository's crowded scheduled windows, read
53+
# from `grep -rn 'cron:' .github/workflows/` on the day this landed: 01–05
54+
# UTC carry most of the nightlies (stale, CodeQL, validate-deps,
55+
# rerun-safety, coverage, the release-coverage and required-set patrols,
56+
# the smokes and nightly tiers), 07 carries showcase-smoke, and the board
57+
# snapshot owns :07 of 02, 08, 14 and 20. Of the six six-hour sets, only
58+
# 0/6/12/18 and 1/7/13/19 have hours with nothing scheduled after :00; the
59+
# first is taken because its :00 neighbours are short (release.yml's
60+
# version-pr refresh, prerelease-pin-watch at 06:00, the hourly ci/lint
61+
# runs), while 07:00 starts the showcase smoke. The MINUTE is off the top of
62+
# the hour for the reason every patrol here gives: scheduled runs queue
63+
# behind everyone else's :00 cron.
64+
#
65+
# This job spends almost nothing of the per-repository GITHUB_TOKEN budget
66+
# (the checkout, and no API reads), so unlike the board readers it is not
67+
# placed against that pool.
68+
#
69+
# ⚠️ GitHub delays scheduled runs under load and occasionally drops one. For
70+
# docs that costs one more interval of staleness, which a dispatch buys back.
71+
- cron: '43 0,6,12,18 * * *'
72+
workflow_dispatch: {}
73+
74+
# Read the tree, nothing else. Vercel is reached with VERCEL_TOKEN, not with
75+
# any GitHub scope.
76+
permissions:
77+
contents: read
78+
79+
# One production deploy at a time, and the newest one wins. Cancelling an
80+
# in-flight run is safe HERE, unlike a backup: every run builds the tip of
81+
# `main`, which contains everything an earlier run would have published, so a
82+
# cancelled run loses nothing a later run does not deliver.
83+
concurrency:
84+
group: docs-deploy-production
85+
cancel-in-progress: true
86+
87+
jobs:
88+
deploy:
89+
name: Build the docs site and deploy it to Vercel production
90+
runs-on: ubuntu-latest
91+
# A forced docs build (spec generators + `next build`) measures ~4 minutes
92+
# on this runner class in ci.yml's Build Docs job; the rest is install and
93+
# upload.
94+
timeout-minutes: 30
95+
env:
96+
# Which Vercel project receives the deployment. These are identifiers,
97+
# not credentials; with both set, the CLI needs no `.vercel/project.json`
98+
# in the repository and no interactive `vercel link`.
99+
VERCEL_ORG_ID: team_06nvlGKZCkwhzdXmRdxeCNnB
100+
VERCEL_PROJECT_ID: prj_KbPkuEVCj8viErlaC0wBUgMIYZWe
101+
# Pinned, so a CLI release cannot change how production is built between
102+
# two runs of an unchanged tree. Moving it is a reviewed edit here.
103+
VERCEL_CLI_VERSION: '60.1.3'
104+
steps:
105+
# The maintainer's ruling is "only main deploys". `schedule` always runs
106+
# on the default branch, but `workflow_dispatch` offers a branch picker,
107+
# and a dispatch from any other ref would publish that branch to
108+
# production. Refused loudly rather than skipped: a skip reads as a
109+
# successful run of the button somebody just pressed.
110+
- name: Refuse any ref but main
111+
if: github.ref != 'refs/heads/main'
112+
run: |
113+
echo "::error::Docs Deploy publishes to PRODUCTION and runs only from main; this run is on ${GITHUB_REF}. Re-run it with 'main' selected in the branch picker."
114+
exit 1
115+
116+
# Checked before anything is built, so a missing credential costs seconds
117+
# rather than a full build, and it fails RED: see the header.
118+
- name: Require the Vercel token
119+
env:
120+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
121+
run: |
122+
if [ -z "${VERCEL_TOKEN:-}" ]; then
123+
echo "::error::The secret VERCEL_TOKEN is not visible to this run, so the docs site cannot be deployed and production is NOT being updated. It is provided as an objectstack-ai ORGANIZATION secret: check that it still exists and that this repository is in its repository access list (org Settings -> Secrets and variables -> Actions). To replace it, create a token at vercel.com -> Account Settings -> Tokens scoped to the Object Stack team, and store it as VERCEL_TOKEN there (or as a repository secret of the same name). Then re-run this workflow."
124+
exit 1
125+
fi
126+
echo "VERCEL_TOKEN is set."
127+
128+
- name: Checkout repository
129+
uses: actions/checkout@v7
130+
131+
- name: Setup Node.js
132+
uses: actions/setup-node@v7
133+
with:
134+
node-version: '22'
135+
136+
- name: Setup pnpm
137+
uses: ./.github/actions/setup-pnpm
138+
139+
- name: Get pnpm store directory
140+
shell: bash
141+
run: |
142+
echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
143+
144+
# The same key ci.yml's jobs use, so a scheduled run restores the store
145+
# CI already saved for this lockfile instead of downloading it again.
146+
- name: Setup pnpm cache
147+
uses: actions/cache@v6
148+
with:
149+
path: ${{ env.STORE_PATH }}
150+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
151+
restore-keys: |
152+
${{ runner.os }}-pnpm-store-v3-
153+
154+
# `vercel build` runs vercel.json's `installCommand` again; with the tree
155+
# already installed from the frozen lockfile that pass has nothing to do,
156+
# and a lockfile problem fails HERE, under its own name.
157+
- name: Install dependencies
158+
run: pnpm install --frozen-lockfile
159+
160+
# ## Every Vercel command runs from the REPOSITORY ROOT, not apps/docs
161+
#
162+
# The project's Root Directory setting (`apps/docs`) comes down with
163+
# `vercel pull`, and the CLI applies it relative to the directory it is
164+
# run from: `vercel build` builds `<cwd>/<rootDirectory>` and writes
165+
# `<cwd>/.vercel/output`, and `vercel deploy` checks that
166+
# `<cwd>/<rootDirectory>` exists before uploading `<cwd>/.vercel/output`
167+
# (read from vercel/vercel's packages/cli build and deploy commands). Run
168+
# from `apps/docs`, the same setting would point at `apps/docs/apps/docs`.
169+
# From the root, the build reads `apps/docs/vercel.json` and runs its
170+
# `installCommand` / `buildCommand` with `apps/docs` as the working
171+
# directory — the same command, in the same place, that ci.yml's Build
172+
# Docs gate reads out of that file and runs on every docs PR.
173+
- name: Pull the production project settings
174+
env:
175+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
176+
run: npx --yes "vercel@${VERCEL_CLI_VERSION}" pull --yes --environment=production --token="$VERCEL_TOKEN"
177+
178+
- name: Build the site (vercel build --prod)
179+
env:
180+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
181+
# Build THIS tree, never replay one. A fresh runner has no local turbo
182+
# cache and this job passes no remote-cache credentials, so nothing
183+
# could be replayed today; forcing keeps that true if either ever
184+
# changes, for the reason ci.yml's Build Docs step gives: a replayed
185+
# `.next` would publish a stale site and report success.
186+
TURBO_FORCE: 'true'
187+
run: npx --yes "vercel@${VERCEL_CLI_VERSION}" build --prod --token="$VERCEL_TOKEN"
188+
189+
# An exit code of 0 is not proof that the docs app was built — see
190+
# ci.yml's "Assert the docs app actually built" for the measured case of a
191+
# build command that matched no package and exited 0. Assert both the
192+
# Next.js build and the Build Output API directory the upload reads.
193+
- name: Assert the build produced a deployable output
194+
run: |
195+
set -euo pipefail
196+
if [ ! -f apps/docs/.next/BUILD_ID ]; then
197+
echo "::error file=apps/docs/vercel.json::vercel build exited 0 but produced no apps/docs/.next/BUILD_ID - the docs app was not built, so nothing is deployed."
198+
exit 1
199+
fi
200+
if [ ! -f .vercel/output/config.json ]; then
201+
echo "::error::vercel build exited 0 but wrote no .vercel/output/config.json at the repository root - there is no prebuilt output to deploy."
202+
exit 1
203+
fi
204+
echo "Built docs app: BUILD_ID=$(cat apps/docs/.next/BUILD_ID)"
205+
206+
- name: Deploy the prebuilt output to production
207+
id: deploy
208+
env:
209+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
210+
run: |
211+
set -euo pipefail
212+
# stdout is the deployment URL and nothing else; progress goes to
213+
# stderr. `set -e` fails the step on a non-zero exit before the URL
214+
# is used.
215+
url=$(npx --yes "vercel@${VERCEL_CLI_VERSION}" deploy --prebuilt --prod --token="$VERCEL_TOKEN")
216+
echo "url=${url}" >> "$GITHUB_OUTPUT"
217+
echo "Deployed: ${url}"
218+
219+
- name: Publish the run to the summary
220+
if: always()
221+
run: |
222+
{
223+
echo "### Docs Deploy — ${{ job.status }}"
224+
echo
225+
echo "Commit: \`${{ github.sha }}\` (${{ github.event_name }})"
226+
echo
227+
echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}"
228+
} >> "$GITHUB_STEP_SUMMARY"

‎apps/docs/vercel.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
"framework": "nextjs",
44
"installCommand": "cd ../.. && pnpm install",
55
"buildCommand": "cd ../.. && pnpm turbo run build --filter=@objectstack/docs",
6-
"ignoreCommand": "bash ../../scripts/vercel-ignore-docs.sh",
6+
"git": {
7+
"deploymentEnabled": false
8+
},
79
"github": {
810
"silent": true
911
}

‎scripts/vercel-ignore-docs.selftest.sh‎

Lines changed: 0 additions & 63 deletions
This file was deleted.

0 commit comments

Comments
 (0)