Deployment
Five Vercel projects, four owned by GitHub Actions prebuilt deploys and one on Vercel's Git Integration. CI gates every Actions-owned deploy before it ships.
The repo deploys to five Vercel projects. Four are owned by GitHub Actions
(prebuilt deploys, Vercel auto-deploy disabled in vercel.json); the fifth
predates the convention and runs on Vercel's Git Integration.
| Vercel project | Source path | Workflow | Repo variable | Owner |
|---|---|---|---|---|
today-web | apps/web | deploy-web.yml | VERCEL_PROJECT_ID_WEB | Actions |
today-admin | apps/admin | deploy-admin.yml | VERCEL_PROJECT_ID_ADMIN | Actions |
today-design-system | design-system/docs | deploy-docs.yml | VERCEL_PROJECT_ID_DOCS | Actions |
today-dev-docs (Vercel project: web-dev-docs) | apps/dev-docs | deploy-dev-docs.yml | VERCEL_PROJECT_ID_DEV_DOCS | Actions (intended) — see Current state |
webview-bridge-docs | apps/webview-bridge-docs | (none) | (n/a) | Vercel Git |
For the underlying conceptual model — what build time / deploy time / runtime
mean on Vercel, what vercel.json actually controls, and why we keep projects
linked to Git even with auto-deploy off — see
Architecture → Vercel deploy model.
Current state of each project's deploy path
Verified against the Vercel REST API on 2026-06-03 (last 100 deployments per project, source breakdown):
| Project | vercel.json git.deploymentEnabled | Project-level previewDeploymentsDisabled | Actions workflow status | Last 100 deployments source |
|---|---|---|---|---|
today-web | false | true | passing | 100 cli |
today-admin | false | true | passing | 100 cli |
today-design-system | false | true | passing | 100 cli |
web-dev-docs | absent (defaults true) | null | failing every run (missing repo variable VERCEL_PROJECT_ID_DEV_DOCS) | 3 git, 1 import |
webview-bridge-docs | absent | null | n/a | 75 git, 1 import, 3 redeploy |
The web-dev-docs row is a known misconfig: deploy-dev-docs.yml references
vars.VERCEL_PROJECT_ID_DEV_DOCS but that variable is not set on the repo, so
vercel pull exits with You specified VERCEL_ORG_ID but you forgot to specify VERCEL_PROJECT_ID. In practice, Vercel's default Git Integration is shipping
dev-docs today, not the Actions workflow. Resolving requires either:
- Set
VERCEL_PROJECT_ID_DEV_DOCS = prj_2tjNCCu9K751RCGGnwUsMVRxwXGras a repo variable, and addgit.deploymentEnabled: falsetoapps/dev-docs/vercel.json(Actions-owned shape, matches the other three). - Delete
.github/workflows/deploy-dev-docs.ymlentirely (Vercel-Git-owned shape, matcheswebview-bridge-docs). - Keep the workflow as
workflow_dispatch-only for emergency manual deploys; removepush/pull_requesttriggers.
Don't do "set the variable but leave vercel.json alone" — that flips the
state from "one deploy path (Vercel Git)" to "two deploy paths racing" (Actions
prebuilt + Vercel Git both fire on every push).
Environment separation
Each workflow maps the Git ref to a GitHub environment + Vercel target:
| Git ref | GitHub environment | Vercel target |
|---|---|---|
main | <app>-production | production |
dev / pr-* | <app>-preview | preview |
PRs land on the preview target; only after sync-main.yml advances main
does the production deploy fire.
Workflow shape (same across the four)
Every deploy workflow follows the same skeleton:
on:
push:
branches: [main, dev]
paths: ['apps/<name>/**', 'packages/<deps>/**']
pull_request:
types: [opened, synchronize, reopened]
paths: [same]
workflow_dispatch:
env:
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID_<NAME> }}
NODE_VERSION: '24.14.1'
jobs:
deploy:
environment:
name: ${{ github.ref == 'refs/heads/main' && '<app>-production' || '<app>-preview' }}
steps:
- checkout (with LFS)
- setup pnpm + node + cache
- install Vercel CLI
- vercel pull (preview or production env)
- inject @todayai-labs/* GH Packages auth into .npmrc
- vercel build (prebuilt deploy)
- wait for required CI checks
- vercel deploy --prebuilt
- PR comment with preview URL
- mark Vercel git status successfulThe wait-for-CI step is what makes deploys safe — it blocks until
Lint, Format, Typecheck, Unit Tests, and Build are all green on
the same SHA. See
deploy-docs.yml's "Wait for CI checks" step
for the exact polling logic (it dedupes runs to handle merge-queue
speculative-branch entries).
Path-based triggering
Each deploy workflow declares paths: for both push and pull_request
events so changes outside the app don't trigger a deploy. For
deploy-dev-docs.yml:
paths:
- '.github/workflows/deploy-dev-docs.yml'
- 'apps/dev-docs/**'
- 'packages/tsconfig/**'A change in apps/web doesn't run deploy-dev-docs. Likewise a pnpm-lock.yaml
change triggers every deploy (since any package could be affected) — that's
intentional.
Caching
Each workflow caches the .vercel/ directory (project metadata, pulled
env vars) using a weekly-rotating key:
key: vercel-<app>-${{ runner.os }}-week${{ steps.cache-week.outputs.week }}-...The week number expires the cache every ~7 days so stale Vercel project
config never lingers. Day-to-day, the cache hits, and vercel pull is
near-instant.
PR preview comments
Each workflow posts a PR comment with the preview URL after deploy:
📚 Docs Preview deployed!
https://today-dev-docs-git-pr-ca-dev-docs.vercel.appThe comment is updated in place on subsequent pushes (looking up the previous bot comment by body content), so each PR has one preview-URL comment that always shows the latest deploy.
Required Vercel setup
For a new app (and for apps/dev-docs initially), the human-on-keyboard
checklist:
- Vercel side:
- Create a new project in the
todayaiteam - Set root directory to
apps/<name> - Framework preset: Next.js (Turbopack or Webpack as the app dictates)
- Enable Deployment Protection if the site is internal
- Create a new project in the
- GitHub side:
- Add
VERCEL_PROJECT_ID_<NAME>to repo variables - Confirm
VERCEL_ORG_IDis already set (it's shared) - Confirm
VERCEL_TOKENis already in repo secrets
- Add
- GitHub environments:
- Create
<app>-previewand<app>-productionGitHub environments - Optionally add reviewers on
<app>-productionfor a manual prod gate
- Create
After that, the deploy workflow runs unattended.
Build duplication and the wait-for-CI ordering
A push that touches apps/web currently triggers two next build
invocations of apps/web in parallel — one from ci.yml's Build job
(verification, output discarded; required check for branch protection) and
one from deploy-web.yml's Build Project step (vercel build, produces
.vercel/output for prebuilt upload). Measured for SHA 64c2a6a:
ci.yml `Build` job 06:52:13Z → 06:55:44Z (~3.5 min, every affected app)
deploy-web.yml `Build` 06:52:38Z → 06:54:14Z (~1.5 min, apps/web only)The duplication is intentional — see Architecture → Vercel deploy model for why both are valuable rather than redundant.
However, the current step order inside each deploy workflow has a real inefficiency:
current: vercel pull → vercel build → Wait for CI checks → vercel deploy
better: vercel pull → Wait for CI checks → vercel build → vercel deployIn the current order, if CI fails after vercel build succeeded, the deploy
artifact was built and is then thrown away. Reordering short-circuits that
wasted compute when CI is already red on the same SHA. Low-risk follow-up
worth doing the next time someone is in these workflows.
Pre-existing oddities
Two notes worth knowing:
deploy-docs.ymlline 242 stamps the Vercel commit-status context asVercel – today-tdx-docs, but the Vercel project is namedtoday-design-system. The status check therefore never overlaps with the one Vercel for GitHub would otherwise post — it's effectively an orphan status. Cosmetic; should be renamed toVercel – today-design-system.vercel.jsonper app includes anignoreCommand:that lists the package's own path plus its workspace dep paths. Updating workspace deps requires updating this list, or Vercel won't rebuild when those deps change.
Rollback
Vercel keeps every deploy; rolling back is "promote a previous deploy" from the Vercel UI. The deploy workflow doesn't automate rollback — by design, since the recovery flow involves a human deciding which prior deploy is the right rollback target.
For code-level rollback, see dev → main sync § "Rolling back".