From 91f9eda3f5c507a9d960f7fae5cc18b5a62ecc3b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:41 +0800 Subject: [PATCH] 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 --- .github/workflows/docs-deploy.yml | 228 +++++++++++++++++++++++++ apps/docs/vercel.json | 4 +- scripts/vercel-ignore-docs.selftest.sh | 63 ------- scripts/vercel-ignore-docs.sh | 103 ----------- 4 files changed, 231 insertions(+), 167 deletions(-) create mode 100644 .github/workflows/docs-deploy.yml delete mode 100755 scripts/vercel-ignore-docs.selftest.sh delete mode 100755 scripts/vercel-ignore-docs.sh diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000000..3fe7d08ba17 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,228 @@ +name: Docs Deploy + +# The ONE path by which the docs site (Vercel project `objectstack`, Root +# Directory `apps/docs`) reaches production. +# +# ## Why the build runs here and not on Vercel +# +# Vercel bills Build CPU Minutes, and it bills them for deployments that do not +# end up building anything. Measured on 2026-10-12 against this project: ~600 +# deployments a day, ~533 of them Git-integration PREVIEW deployments for +# `claude/*`, `gh-readonly-queue/*`, `board-archive*` and `changeset-release/*` +# branches. Each one cloned the monorepo, ran the old Ignored Build Step script +# for ~25 s and was then canceled — and was still billed: billed CPU-minutes +# divided by production-build wall-minutes came out at 12–15, more than the +# 8-vCPU build machine can produce on its own, so the canceled ones are in the +# bill. They were ~40% of this project's cost; the ~34 production builds a day +# that pushes to `main` triggered were most of the rest. Build CPU Minutes were +# 97% of the team's invoice. +# +# The maintainer's ruling, verbatim and untranslated: +# 「文档站应该只有主分支需要部署,而且应该用免费的」 — only `main` deploys the docs, +# and on free compute. This repository is public, so a standard +# `ubuntu-latest` runner costs nothing. So this job builds the site on that +# runner with `vercel build` and uploads the result with +# `vercel deploy --prebuilt`: Vercel stores and serves it and builds nothing. +# `apps/docs/vercel.json` turns the Git integration's deployments off +# (`git.deploymentEnabled: false`), so no branch push creates a Vercel +# deployment at all — billed, canceled or otherwise. +# +# ## Failure direction: a silent skip is the worst outcome +# +# A run that cannot deploy goes RED, never green-and-skipped. The site going +# quietly stale with no error anywhere is exactly what the old Ignored Build +# Step script was written around ("a wrong skip silently stops publishing +# documentation"), and moving the build here does not change which direction +# is the dangerous one. In particular a missing `VERCEL_TOKEN` secret (an +# objectstack-ai organization secret; a repository secret of the same name +# resolves identically) is an `::error::` and a failed run, never a notice: +# unlike an optional mirror, this job IS the publishing path, and without it +# nothing else publishes the docs. + +on: + schedule: + # Every six hours, at :43 past 00, 06, 12 and 18 UTC. The maintainer's + # decision: the docs refresh at most six hours behind `main`, and + # "Run workflow" on this workflow's Actions page (workflow_dispatch below) + # publishes `main` immediately when that is too slow. Builds cost nothing + # here at any frequency; four a day (against ~87 commits a day on `main`) + # keeps this job from competing with CI for runners and needs no + # paths-filter list to keep in step with what the site is built from. + # + # The HOURS keep out of this repository's crowded scheduled windows, read + # from `grep -rn 'cron:' .github/workflows/` on the day this landed: 01–05 + # UTC carry most of the nightlies (stale, CodeQL, validate-deps, + # rerun-safety, coverage, the release-coverage and required-set patrols, + # the smokes and nightly tiers), 07 carries showcase-smoke, and the board + # snapshot owns :07 of 02, 08, 14 and 20. Of the six six-hour sets, only + # 0/6/12/18 and 1/7/13/19 have hours with nothing scheduled after :00; the + # first is taken because its :00 neighbours are short (release.yml's + # version-pr refresh, prerelease-pin-watch at 06:00, the hourly ci/lint + # runs), while 07:00 starts the showcase smoke. The MINUTE is off the top of + # the hour for the reason every patrol here gives: scheduled runs queue + # behind everyone else's :00 cron. + # + # This job spends almost nothing of the per-repository GITHUB_TOKEN budget + # (the checkout, and no API reads), so unlike the board readers it is not + # placed against that pool. + # + # ⚠️ GitHub delays scheduled runs under load and occasionally drops one. For + # docs that costs one more interval of staleness, which a dispatch buys back. + - cron: '43 0,6,12,18 * * *' + workflow_dispatch: {} + +# Read the tree, nothing else. Vercel is reached with VERCEL_TOKEN, not with +# any GitHub scope. +permissions: + contents: read + +# One production deploy at a time, and the newest one wins. Cancelling an +# in-flight run is safe HERE, unlike a backup: every run builds the tip of +# `main`, which contains everything an earlier run would have published, so a +# cancelled run loses nothing a later run does not deliver. +concurrency: + group: docs-deploy-production + cancel-in-progress: true + +jobs: + deploy: + name: Build the docs site and deploy it to Vercel production + runs-on: ubuntu-latest + # A forced docs build (spec generators + `next build`) measures ~4 minutes + # on this runner class in ci.yml's Build Docs job; the rest is install and + # upload. + timeout-minutes: 30 + env: + # Which Vercel project receives the deployment. These are identifiers, + # not credentials; with both set, the CLI needs no `.vercel/project.json` + # in the repository and no interactive `vercel link`. + VERCEL_ORG_ID: team_06nvlGKZCkwhzdXmRdxeCNnB + VERCEL_PROJECT_ID: prj_KbPkuEVCj8viErlaC0wBUgMIYZWe + # Pinned, so a CLI release cannot change how production is built between + # two runs of an unchanged tree. Moving it is a reviewed edit here. + VERCEL_CLI_VERSION: '60.1.3' + steps: + # The maintainer's ruling is "only main deploys". `schedule` always runs + # on the default branch, but `workflow_dispatch` offers a branch picker, + # and a dispatch from any other ref would publish that branch to + # production. Refused loudly rather than skipped: a skip reads as a + # successful run of the button somebody just pressed. + - name: Refuse any ref but main + if: github.ref != 'refs/heads/main' + run: | + 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." + exit 1 + + # Checked before anything is built, so a missing credential costs seconds + # rather than a full build, and it fails RED: see the header. + - name: Require the Vercel token + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ -z "${VERCEL_TOKEN:-}" ]; then + 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." + exit 1 + fi + echo "VERCEL_TOKEN is set." + + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV" + + # The same key ci.yml's jobs use, so a scheduled run restores the store + # CI already saved for this lockfile instead of downloading it again. + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + # `vercel build` runs vercel.json's `installCommand` again; with the tree + # already installed from the frozen lockfile that pass has nothing to do, + # and a lockfile problem fails HERE, under its own name. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # ## Every Vercel command runs from the REPOSITORY ROOT, not apps/docs + # + # The project's Root Directory setting (`apps/docs`) comes down with + # `vercel pull`, and the CLI applies it relative to the directory it is + # run from: `vercel build` builds `/` and writes + # `/.vercel/output`, and `vercel deploy` checks that + # `/` exists before uploading `/.vercel/output` + # (read from vercel/vercel's packages/cli build and deploy commands). Run + # from `apps/docs`, the same setting would point at `apps/docs/apps/docs`. + # From the root, the build reads `apps/docs/vercel.json` and runs its + # `installCommand` / `buildCommand` with `apps/docs` as the working + # directory — the same command, in the same place, that ci.yml's Build + # Docs gate reads out of that file and runs on every docs PR. + - name: Pull the production project settings + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: npx --yes "vercel@${VERCEL_CLI_VERSION}" pull --yes --environment=production --token="$VERCEL_TOKEN" + + - name: Build the site (vercel build --prod) + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + # Build THIS tree, never replay one. A fresh runner has no local turbo + # cache and this job passes no remote-cache credentials, so nothing + # could be replayed today; forcing keeps that true if either ever + # changes, for the reason ci.yml's Build Docs step gives: a replayed + # `.next` would publish a stale site and report success. + TURBO_FORCE: 'true' + run: npx --yes "vercel@${VERCEL_CLI_VERSION}" build --prod --token="$VERCEL_TOKEN" + + # An exit code of 0 is not proof that the docs app was built — see + # ci.yml's "Assert the docs app actually built" for the measured case of a + # build command that matched no package and exited 0. Assert both the + # Next.js build and the Build Output API directory the upload reads. + - name: Assert the build produced a deployable output + run: | + set -euo pipefail + if [ ! -f apps/docs/.next/BUILD_ID ]; then + 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." + exit 1 + fi + if [ ! -f .vercel/output/config.json ]; then + echo "::error::vercel build exited 0 but wrote no .vercel/output/config.json at the repository root - there is no prebuilt output to deploy." + exit 1 + fi + echo "Built docs app: BUILD_ID=$(cat apps/docs/.next/BUILD_ID)" + + - name: Deploy the prebuilt output to production + id: deploy + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + set -euo pipefail + # stdout is the deployment URL and nothing else; progress goes to + # stderr. `set -e` fails the step on a non-zero exit before the URL + # is used. + url=$(npx --yes "vercel@${VERCEL_CLI_VERSION}" deploy --prebuilt --prod --token="$VERCEL_TOKEN") + echo "url=${url}" >> "$GITHUB_OUTPUT" + echo "Deployed: ${url}" + + - name: Publish the run to the summary + if: always() + run: | + { + echo "### Docs Deploy — ${{ job.status }}" + echo + echo "Commit: \`${{ github.sha }}\` (${{ github.event_name }})" + echo + echo "Deployment: ${{ steps.deploy.outputs.url || 'none (the run stopped before the deploy step finished)' }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json index 0450efe86bb..b219f8628ff 100644 --- a/apps/docs/vercel.json +++ b/apps/docs/vercel.json @@ -3,7 +3,9 @@ "framework": "nextjs", "installCommand": "cd ../.. && pnpm install", "buildCommand": "cd ../.. && pnpm turbo run build --filter=@objectstack/docs", - "ignoreCommand": "bash ../../scripts/vercel-ignore-docs.sh", + "git": { + "deploymentEnabled": false + }, "github": { "silent": true } diff --git a/scripts/vercel-ignore-docs.selftest.sh b/scripts/vercel-ignore-docs.selftest.sh deleted file mode 100755 index eda9d76c8c6..00000000000 --- a/scripts/vercel-ignore-docs.selftest.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# -# Self-test for scripts/vercel-ignore-docs.sh. -# -# The script decides whether a docs production build runs. Its two failure -# directions are not symmetric: a wrong "build" wastes build-minutes, a wrong -# "skip" silently stops publishing documentation. These cases pin both, and in -# particular pin the `content/**` case (#12743) — the repo's MDX lives outside -# the `apps/docs` package boundary, so a dependency-graph check alone reports -# SKIP for a pure documentation edit. -# -# Run: bash scripts/vercel-ignore-docs.selftest.sh -set -uo pipefail -cd "$(git rev-parse --show-toplevel)" || exit 1 - -SCRIPT=scripts/vercel-ignore-docs.sh -fail=0 - -# Historical commits on `main`, each a single well-understood kind of change. -# If history is ever rewritten these stop resolving; the test says so rather -# than silently passing. -SHA_CONTENT=1265f12b # touches only content/docs/api/client-sdk.mdx -SHA_SPEC=366f8957 # touches packages/spec (the docs app's only workspace dep) -SHA_CLAUDE=72f91652 # touches only .claude/** - -check() { - local label="$1" want="$2"; shift 2 - local out rc got - out=$(env "$@" bash "$SCRIPT" 2>&1); rc=$? - got=$([ "$rc" -eq 0 ] && echo skip || echo build) - if [ "$got" = "$want" ]; then - printf ' ok %-32s -> %s\n' "$label" "$got" - else - printf ' FAIL %-32s -> %s (wanted %s)\n %s\n' "$label" "$got" "$want" "$(echo "$out" | tail -1)" - fail=1 - fi -} - -at() { # at -> echoes env assignments pinning the range to that commit - local sha="$1" parent - parent=$(git rev-parse "${sha}^" 2>/dev/null) || return 1 - echo "VERCEL_GIT_PREVIOUS_SHA=${parent} VERCEL_GIT_COMMIT_SHA=${sha}" -} - -echo "vercel-ignore-docs selftest" - -check "preview deployments skip" skip VERCEL_ENV=preview -check "no baseline builds" build VERCEL_ENV=production -check "unreachable baseline builds" build VERCEL_ENV=production \ - VERCEL_GIT_PREVIOUS_SHA=0000000000000000000000000000000000000000 - -for pair in "content-only:$SHA_CONTENT:build" "spec-dependency:$SHA_SPEC:build" "claude-only:$SHA_CLAUDE:skip"; do - label=${pair%%:*}; rest=${pair#*:}; sha=${rest%%:*}; want=${rest#*:} - if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then - printf ' SKIP %-32s (commit %s no longer in history)\n' "$label" "$sha" - continue - fi - # shellcheck disable=SC2046 - check "$label" "$want" VERCEL_ENV=production $(at "$sha") -done - -if [ "$fail" -ne 0 ]; then echo "SELFTEST FAILED"; exit 1; fi -echo "all cases passed" diff --git a/scripts/vercel-ignore-docs.sh b/scripts/vercel-ignore-docs.sh deleted file mode 100755 index 2a227a48d35..00000000000 --- a/scripts/vercel-ignore-docs.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# -# Vercel "Ignored Build Step" for @objectstack/docs. -# -# Vercel's contract is inverted from the usual one: -# exit 0 -> IGNORE the build (skip) -# exit 1 -> RUN the build -# -# Why this exists: every push to `main` used to rebuild the docs site. Measured -# over one week, that was 228 production builds consuming 2835 build-minutes — -# 98.6% of the whole team's build time — while `objectui` (18s/build) and -# `hotcrm` (6s/build) queued behind them on a `concurrentBuilds: 1` team. The -# queue reached 92 deployments, the oldest 34 hours old (#12743). -# -# ⚠️ The single most important property of this script is its FAILURE DIRECTION. -# A wrong "build" costs a few build-minutes. A wrong "skip" silently stops -# publishing documentation, with no error anywhere — the site just quietly goes -# stale. Every indeterminate case below therefore exits 1. -set -uo pipefail - -# All paths below are repo-relative, but Vercel runs this from the project's -# Root Directory (apps/docs), so anchor to the repo root first. -cd "$(git rev-parse --show-toplevel)" || exit 1 - -# `VERCEL_GIT_COMMIT_SHA` is what Vercel is deploying; falling back to HEAD lets -# this script be exercised locally and in tests. -HEAD_SHA="${VERCEL_GIT_COMMIT_SHA:-HEAD}" -PREV_SHA="${VERCEL_GIT_PREVIOUS_SHA:-}" - -# 1. Preview deployments never build the docs site. This preserves exactly the -# behaviour of the dashboard rule this replaces, whose preview half was -# already correct; only its production half ("always build") was wasteful. -if [ "${VERCEL_ENV:-}" != "production" ]; then - echo "skip: VERCEL_ENV=${VERCEL_ENV:-unset} is not production" - exit 0 -fi - -# 2. No baseline to compare against -> build. Happens on the first deployment -# after this lands, and any time Vercel cannot name a previous success. -if [ -z "$PREV_SHA" ]; then - echo "build: no VERCEL_GIT_PREVIOUS_SHA to compare against" - exit 1 -fi - -# 3. Vercel builds from a shallow clone, so the previous commit is frequently -# absent. Try to fetch it; if it stays unreachable, build. -if ! git cat-file -e "${PREV_SHA}^{commit}" 2>/dev/null; then - git fetch --depth=100 origin "$PREV_SHA" >/dev/null 2>&1 || true -fi -if ! git cat-file -e "${PREV_SHA}^{commit}" 2>/dev/null; then - echo "build: previous SHA ${PREV_SHA} is not reachable in this clone" - exit 1 -fi - -# 4. The site's own sources. -# -# This check is NOT redundant with turbo-ignore below, and removing it breaks -# documentation publishing. `turbo --filter=...[range]` computes affected -# packages BY PACKAGE DIRECTORY. This repo's MDX lives at the repo root in -# `content/`, outside the `apps/docs` package boundary, so turbo does not see -# it. `turbo.json` does list `"$TURBO_ROOT$/content/**"` under -# `@objectstack/docs#build`'s `inputs`, but `inputs` only feeds the cache -# hash — it does not widen the affected-package calculation. -# -# Measured on `main`: commit 1265f12b touches only -# `content/docs/api/client-sdk.mdx`, and `turbo-ignore` alone reports SKIP. -if ! git diff --quiet "$PREV_SHA" "$HEAD_SHA" -- content apps/docs; then - echo "build: content/ or apps/docs/ changed since ${PREV_SHA}" - exit 1 -fi - -# 5. Nothing in the site's own sources changed. Ask turbo whether the docs app -# is affected through its dependency graph — `@objectstack/spec` is the only -# workspace dependency, but it changes often. -# -# Deliberately NOT `turbo-ignore`: that wrapper is deprecated upstream ("Use -# `turbo query affected` instead") and it derives its own comparison range, -# falling back to `[HEAD^]` when it cannot read Vercel's git environment — -# a range that silently answers a different question than the one asked here. -# Naming the range explicitly keeps this decision reviewable and testable. -echo "no direct docs changes; asking turbo about the dependency graph" -DRY=$(npx --yes "turbo@${TURBO_VERSION:-^2}" run build \ - --filter="@objectstack/docs...[${PREV_SHA}...${HEAD_SHA}]" \ - --dry=json 2>/dev/null) -if [ -z "$DRY" ]; then - echo "build: could not get a verdict from turbo" - exit 1 -fi - -AFFECTED=$(printf '%s' "$DRY" | node -e ' - let s=""; - process.stdin.on("data", d => s += d); - process.stdin.on("end", () => { - try { process.stdout.write(String((JSON.parse(s).tasks || []).length)); } - catch { process.stdout.write("error"); } - }); -' 2>/dev/null) - -case "$AFFECTED" in - 0) echo "skip: nothing in @objectstack/docs dependency graph changed since ${PREV_SHA}"; exit 0 ;; - ''|*[!0-9]*) echo "build: could not parse turbo's verdict (${AFFECTED:-empty})"; exit 1 ;; - *) echo "build: ${AFFECTED} task(s) in the docs dependency graph affected"; exit 1 ;; -esac