Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e311851
chore(vercel): gate preview builds on pull request readiness
imshashank Aug 20, 2026
8620b54
test(vercel): cover the build gate, and watch the root tsconfig
imshashank Aug 20, 2026
2da9f89
fix(vercel): require a complete pull request payload before skipping
imshashank Aug 20, 2026
2a78c65
docs(vercel): design the preview deployment gate
imshashank Aug 21, 2026
918908f
Merge remote-tracking branch 'origin/main' into chore/gate-preview-bu…
imshashank Aug 21, 2026
e255616
docs(vercel): tighten the preview gate plan
imshashank Aug 21, 2026
ae5fefc
feat(ci): define preview deployment policy
imshashank Aug 21, 2026
2e359cd
fix(ci): harden preview event schemas
imshashank Aug 21, 2026
756ea88
fix(ci): validate preview deployment identity
imshashank Aug 21, 2026
a861e38
docs(vercel): finalize the preview controller contract
imshashank Aug 21, 2026
89201b1
feat(ci): deploy previews after successful checks
imshashank Aug 21, 2026
cb219bf
fix(ci): harden preview reconciliation
imshashank Aug 21, 2026
73a42cc
docs(vercel): harden preview workflow plan
imshashank Aug 21, 2026
d4fe89e
chore(vercel): gate previews after CI
imshashank Aug 21, 2026
b6ea842
test(vercel): lock preview workflow contract
imshashank Aug 21, 2026
6b362df
test(vercel): keep legacy setting scan clean
imshashank Aug 21, 2026
5998d8c
fix(ci): harden preview deployment reconciliation
imshashank Aug 21, 2026
5adec4b
chore(ci): apply preview review cleanups
imshashank Aug 21, 2026
618ecb6
fix(ci): cancel superseded preview deployments
imshashank Aug 23, 2026
dee4f81
Merge remote-tracking branch 'source/main' into chore/gate-preview-bu…
imshashank Aug 25, 2026
6556e05
fix(ci): declare read-only workflow permissions
imshashank Aug 25, 2026
99d8c63
Merge main into chore/gate-preview-builds
imshashank Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ jobs:
run: bun run lint
- name: No-comment policy
run: bun run check-comments
- name: Source byte policy
run: bun run check-bytes
- name: No Bun built-ins in shipped server code
run: bun run check-bun-imports
- name: One copy of every overridden dependency
run: bun run check-deps
- name: Typecheck
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/vercel-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Vercel Preview

on:
pull_request_target:
branches: [main]
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, labeled, unlabeled, closed]
workflow_run:
workflows: [CI]
types: [completed]
repository_dispatch:
types: [vercel-preview-reconcile]

permissions:
actions: read
contents: read
pull-requests: read

concurrency:
group: vercel-preview-${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pull_request || github.event.workflow_run.head_sha || github.run_id }}
cancel-in-progress: false

jobs:
reconcile:
if: >-
github.event_name == 'pull_request_target' ||
github.event_name == 'repository_dispatch' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Check out trusted controller
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
repository: ${{ github.repository }}
ref: ${{ github.sha }}
persist-credentials: false
submodules: false
lfs: false
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: "1.3.14"
- name: Install trusted controller dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Reconcile Vercel Preview
run: bun scripts/vercel-preview-deploy.ts
env:
GITHUB_TOKEN: ${{ github.token }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
VERCEL_PROJECT_NAME: ${{ vars.VERCEL_PROJECT_NAME }}
6 changes: 6 additions & 0 deletions apps/web/vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"installCommand": "rm -rf ../../node_modules node_modules && bun install --frozen-lockfile",
"git": {
"deploymentEnabled": {
"**": false,
"main": true
}
},
"regions": ["hnd1"],
"crons": [
{ "path": "/api/cron/analytics-snapshots", "schedule": "0 */6 * * *" },
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ gated behind a plan.
| Write or run the tests | [Testing](testing.md) |
| Fix something that broke | [Troubleshooting](troubleshooting.md) |
| See what is coming | [Roadmap](roadmap.md) |
| Operate gated Vercel Preview deployments | [Vercel Preview deployment gate](VERCEL_BUILD_GATE.md) |
| Contribute | [CONTRIBUTING.md](../CONTRIBUTING.md) |

## The five minute version
Expand Down
196 changes: 196 additions & 0 deletions docs/VERCEL_BUILD_GATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Vercel Preview deployment gate

Orbit creates Vercel Preview deployments only after the exact pull request head
has passed CI and still satisfies the repository policy. Production deployments
from `main` remain enabled through the Vercel Git integration.

## Eligibility

The controller evaluates the current pull request from GitHub on every event.
`no-preview` takes precedence over every other state.

| Pull request state | Labels | Result after exact-head CI succeeds |
| --- | --- | --- |
| Ready for review | neither managed label | eligible |
| Ready for review | `preview` | eligible |
| Ready for review | `no-preview`, with or without `preview` | ineligible |
| Draft | `preview` without `no-preview` | eligible |
| Draft | neither managed label | ineligible |
| Draft | `no-preview`, with or without `preview` | ineligible |
| Closed | any labels | ineligible |
| Fork | any state or labels | never eligible for an automatic Preview |

An eligible pull request must also target `main`, come from the same repository,
and change at least one web-impacting path:

- `apps/web/**`
- `packages/**`
- `package.json`
- `bun.lock`
- `tsconfig.base.json`

For a renamed file, either the current filename or GitHub's validated
`previous_filename` can make the change web-impacting. Moving code out of
`apps/web/**` or `packages/**` therefore still requires a Preview.

Ready status or the `preview` label does not establish trust. The controller
also proves that the newest `CI` run belongs to the current head SHA, is
associated with the same pull request and current `main`, and completed
successfully. A state event can create a Preview immediately when that proof
already exists. Otherwise the successful `workflow_run` event reconciles the
pull request after CI finishes. A later non-green run blocks an older success.
Before Create, the controller repeats the CI proof and then refetches the pull
request once more. Any head, identity, state, draft, or label change during that
proof prevents the POST.

## Trust boundary

`Vercel Preview` is a privileged default-branch workflow. It checks out
`${{ github.sha }}`, which is the trusted base or default-branch commit for its
three event types. It never selects, fetches, installs, caches, downloads an
artifact from, builds, or executes pull request code. Dependency lifecycle
scripts are disabled. The only operational command is
`bun scripts/vercel-preview-deploy.ts`, and `VERCEL_TOKEN` exists only on that
step.

Only the trusted GitHub controller is isolated from pull request code. The
API-created Vercel Preview still builds same-repository pull request code with
the project Preview environment scope. Git Fork Protection must remain enabled,
and forks are rejected by the controller, but maintainers must still treat the
Preview environment as available to same-repository pull request code.

The `git.deploymentEnabled` map in `apps/web/vercel.json` disables automatic Git
deployments for `**` and enables them for `main`. This is a repository-controlled
cost policy, not a security boundary. A repository change can alter that policy,
so security depends on the trusted workflow and controller validation.

Each API create remains a Vercel deployment. Canceled attempts and reused
deployments can remain visible in Vercel deployment history and counts. The gate
reduces unnecessary creation, but it does not promise that an ignored or
canceled attempt is free.

## Reconciliation and Vercel API behavior

The controller uses one deployment path:

1. Vercel v7 lists Preview deployments by team, project, and branch before CI
to find active work from prior heads. It lists by exact head SHA again when
creating or reusing current-head work.
2. Vercel v9 reads the configured project immediately before every mutation.
The validated project ID, name, and account ID must match the configured
project and team.
3. Vercel v13 creates or reads a deployment with the same-repository GitHub
repository ID, head ref, exact head SHA, and Orbit metadata. It omits a
target so Vercel uses the project's Preview environment.
4. Vercel v12 cancels matching active deployments.

Deployment IDs are accepted only when they contain ASCII letters, digits,
underscores, and hyphens within the controller's fixed bound. The controller
checks IDs and URLs against both tokens before they can enter a result, and URL
encodes every deployment ID used as an API path segment.

`QUEUED`, `INITIALIZING`, and `BUILDING` deployments are active. Making a pull
request ineligible by closing it, converting it to draft without `preview`,
removing `preview` from an otherwise ineligible draft, or adding `no-preview`
cancels matching active Preview work. A deployment that is already `READY` is
not canceled, so its ready URL remains available.

Per-pull-request workflow runs remain serialized with in-progress cancellation
disabled. While the owner polls a queued, initializing, or building deployment,
it refetches the current pull request after every active detail response. If the
same exact head becomes closed or ineligible, that owner cancels only its exact
deployment and returns a canceled result. If only the head SHA changed while the
pull request, repositories, base, and head ref remain identical, the old owner
cancels its superseded active deployment. Any other identity change returns a
stale event without mutation.

`pull_request_target` includes `synchronize`, so a pushed head immediately runs
the trusted controller. Every trusted current-candidate reconciliation also
sweeps active prior-head work before checking current-head CI. A candidate for
cancellation must have a different valid 40-character hexadecimal SHA and exact
configured project, repository ID, pull request number, and head ref metadata.
This repeated sweep lets later CI and repository-dispatch events recover after a
transient synchronize failure or an interrupted polling owner. READY prior-head
deployments are retained, so their URLs remain available.

Events for stale heads cannot create or cancel work for the current head. An
existing exact ready or active deployment is reused. Terminal deployment
history can cause one forced create for the exact head, using the same v13
endpoint rather than an alternate build path.

## Repository and Vercel setup

The GitHub repository must provide:

- Secret `VERCEL_TOKEN`
- Variable `VERCEL_TEAM_ID`
- Variable `VERCEL_PROJECT_ID`
- Variable `VERCEL_PROJECT_NAME`

Keep Vercel Git Fork Protection enabled. Synchronize the managed `preview` and
`no-preview` labels with the rest of the repository labels:

```bash
bun run labels:sync
bun run labels:sync --apply
```

The first command is a dry run. Review its plan before applying it.

After `.github/workflows/vercel-preview.yml` is present on `main`, remove these
legacy Vercel environment values:

- `BUILD_GATE_GITHUB_TOKEN`
- `BUILD_GATE_WATCH_PATHS`
- `BUILD_GATE_READY_LABEL`
- `BUILD_GATE_BLOCK_LABEL`

They belonged to the removed Ignored Build Step and are not read by the trusted
controller.

## Manual recovery

A maintainer can reconcile a positive numeric pull request number with an
authenticated repository dispatch:

```bash
gh api repos/Noveum/orbit/dispatches \
--method POST \
-f event_type=vercel-preview-reconcile \
-F 'client_payload[pull_request]=341'
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The event type must be `vercel-preview-reconcile`, and
`client_payload.pull_request` must be a positive number. The event shares the
same per-pull-request concurrency group as state and CI events. Malformed input
can form an unused group but is rejected before any Vercel call.

Do not add or use `workflow_dispatch` for recovery. A caller can select a
non-default ref for that trigger. GitHub runs `repository_dispatch` from the
last commit on the default branch, preserving the controller trust boundary.

## Post-merge canary

Run this procedure only after the workflow exists on `main`:

1. Confirm Vercel Git Fork Protection is enabled. Configure `VERCEL_TOKEN` and
the `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and `VERCEL_PROJECT_NAME`
repository variables.
2. Remove the four legacy values only after the workflow is on `main`.
3. Open a same-repository, web-impacting draft at head A. Confirm it gets no
Preview, apply `preview`, let CI succeed for exact head A, and wait for its
deployment to reach `READY`. Record and retain head A's ready URL.
4. Keep `preview` applied and push a web-impacting head B. Let exact-head CI
succeed and wait until B's deployment is `QUEUED`, `INITIALIZING`, or
`BUILDING`. Apply `no-preview`. Confirm the polling owner observes the new
live state and cancels the deployment whose metadata names head B before it
reaches `READY`, while head A's recorded ready URL remains available.
5. Keep `no-preview` applied, make the pull request ready for review, and push a
web-impacting head C. Let exact-head CI succeed and confirm no deployment is
created for C while the label remains. Remove `no-preview`, then confirm a
deployment is created for exact head C. Confirm no new deployment is created
for head B and no SHA other than C is selected by this reconciliation.
6. Open a ready same-repository pull request with only a docs change, let its
exact-head CI succeed, and confirm it receives no automatic Preview.
7. Open a ready fork pull request with a web-impacting change, let its exact-head
CI succeed, and confirm it receives no automatic Preview.
Loading
Loading