|
| 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" |
0 commit comments