Skip to content

Commit ea278e7

Browse files
Brooooooklynclaude
andauthored
Deploy production on push to prod. (#320)
Production had no ref to release from: `platform-deploy.yml` was manual dispatch only, so a release was whoever clicked "Run workflow". The old repo released on push to its `prod` branch; this restores that model on the monorepo. ## What changed ### `platform-deploy.yml` — deploy on push to `prod` ```yaml push: branches: [prod] concurrency: group: platform-deploy-production cancel-in-progress: false ``` `prod` is the production ref — it points at the commit that is live, so advancing it is the release: ```bash git push origin main:prod # release whatever main is at ``` No path filter, deliberately: the old repo had none, and a push to `prod` is already deliberate. The concurrency group mirrors staging's (`cancel-in-progress: false`), so a deploy is never interrupted between the API migration and the workers. ### `schema-compat` — the production guard, restored The old repository's job, ported as-is: Phase 1 applies the candidate's migrations to a real Postgres 18; Phase 2 checks out `prod` and runs its `platform-api-integration` suite on that schema. It runs on PRs and, through `require-schema-compat`, on every production deploy (a push to `prod` checks itself, as before; a manual dispatch from another ref is checked against `prod`). Two integration tests only ever passed on PGlite; they are fixed so the suite holds on Postgres too. Accepted limits, same as the old repository and stated in the job comment: the baseline is the `prod` branch head rather than the deployed revision, and released migrations are assumed append-only (drizzle never re-applies an applied entry and skips entries not newer than the last applied). A stricter guard (deploy-recorded baselines, an append-only checker) was tried on this branch and removed: more moving parts than the risk it covers. ### Docs `platform/meta/operations.md` (environments table, secrets note, workflow list) and `platform/README.md` — which `platform/AGENTS.md` symlinks to — now describe the branch model, and the agent guardrail becomes "do not push `prod`" instead of "deploys are manual". ## First release notes - `prod` in this repo currently points at `ab92e6d3` (2026-06-02, the SDK repo's old release branch), 1214 commits behind `main`. It is an ancestor of `main`, so advancing it is a fast-forward. - The old repo's `prod` (`ea101e78`) is also an ancestor of `main`, so no prod-only work is lost. - **No database migrations ride along**: all 26 files in `platform/packages/api/drizzle` are blob-identical between the old repo's `prod` and `main`, so the first release from here applies none. - Nothing deploys until someone pushes `prod`. That push is the trigger; there is no separate button. ## Follow-ups this unblocks - Retire the old repo's `Deploy Production` (push to its `prod`) at the same moment, so only one repo can write production. - `platform-ci.yml`'s `schema-compat` job is still inert with the comment "the merged repository's production ref has intentionally not been chosen yet" — `prod` is now that ref, so the job can be ported next. - The old repo's `prod` branch carried a ruleset (`deletion`, `non_fast_forward`, `update`, bypass for org admins / write role / the maintainers team). Nothing protects `prod` here yet, which matters more now that a push to it deploys production. ## Verified - `actionlint` on the workflow: clean - `vp fmt --check`: clean <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Production deploys become automatic on `prod` pushes and gate releases on migration compatibility against live `prod` code; mis-timed or breaking migrations can block or endanger production rollout. > > **Overview** > **Production releases now advance the `prod` branch** instead of relying on manual workflow dispatch. Pushes to `prod` trigger `platform-deploy.yml`, with a non-cancelling concurrency group so deploys are not interrupted between API migration and worker rollout. The reusable CI workflow accepts `require-schema-compat`, and production deploy always sets it so the guard runs on every production trigger (including manual dispatch from another ref). > > **The `schema-compat` CI job is enabled** (it was previously inert). It applies the candidate’s Drizzle migrations to Postgres 18, then checks out `prod` and runs `platform-api-integration` against that schema so live code must tolerate the new schema before ship. PRs get the same check; staging deploys skip it. If `prod` predates the monorepo platform tree, migrations still run but integration tests are skipped with a warning. > > **Docs** (`platform/README.md`, `platform/meta/operations.md`) now describe push-to-`prod` as the production trigger and warn agents not to push `prod`. > > **Two integration tests were adjusted for real Postgres** used by `schema-compat`: the rollback test models **sequential** rollbacks with a transaction hold instead of concurrent lock contention (Postgres lease/409 behavior differs from PGlite); the sandbox teardown-race test hooks **`cutover-ready:`** KV writes (post-commit, no open tx) so teardown ordering is deterministic on pooled Postgres. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 22e988b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a49f009 commit ea278e7

6 files changed

Lines changed: 185 additions & 51 deletions

File tree

‎.github/workflows/platform-ci.yml‎

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ on:
1212
- 'vite.config.ts'
1313
- '.github/workflows/platform-*.yml'
1414
workflow_call:
15+
inputs:
16+
require-schema-compat:
17+
description: Run the production schema-compatibility guard. The production deploy sets this for every trigger.
18+
type: boolean
19+
default: false
1520

1621
permissions: {}
1722

@@ -75,11 +80,89 @@ jobs:
7580
- run: vp run --filter './platform/packages/**' build
7681
- run: vp test run --project ${{ matrix.project }}
7782

78-
# The former repository compared migrations against its `prod` branch. The merged repository's
79-
# production ref has intentionally not been chosen yet, so this job remains visible but inert
80-
# until the deployment-cutover change supplies the authoritative ref.
83+
# The production guard. `platform-deploy.yml` migrates before it deploys the api worker, so a
84+
# push to `prod` applies a new schema while the previous release is still serving traffic. This
85+
# migrates a real Postgres with the migrations under test, then runs the `prod` branch's
86+
# integration tests against that schema: if the code that is live cannot run on the new schema,
87+
# the push must not ship. Accepted limits: the baseline is the `prod` branch head rather than the
88+
# deployed revision, and released migrations are assumed append-only (drizzle never re-applies an
89+
# applied entry and skips entries not newer than the last applied).
90+
#
91+
# It has teeth on PRs, where the code under test is a different commit from the one that is
92+
# live. The production deploy sets `require-schema-compat` for every trigger — including a
93+
# manual dispatch, which can be started from any ref and would otherwise skip the job. Staging
94+
# deploys skip it: they cannot break production, and they run on nearly every push to `main`.
8195
schema-compat:
82-
if: ${{ false }}
96+
if: github.event_name == 'pull_request' || inputs.require-schema-compat
8397
runs-on: blacksmith-4vcpu-ubuntu-2404
98+
timeout-minutes: 20
99+
permissions:
100+
contents: read
101+
env:
102+
POSTGRES_USER: void_ci
103+
POSTGRES_PASSWORD: void_ci_pass
104+
POSTGRES_DB: void_schema_compat
105+
COMPAT_DATABASE_URL: postgresql://void_ci:void_ci_pass@localhost:5432/void_schema_compat
106+
# Pinned: the suite compares timestamps written by Postgres with local time, so a
107+
# runner whose locale is not UTC shifts them by the offset.
108+
TZ: UTC
109+
services:
110+
postgres:
111+
image: postgres:18
112+
env:
113+
POSTGRES_USER: ${{ env.POSTGRES_USER }}
114+
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
115+
POSTGRES_DB: ${{ env.POSTGRES_DB }}
116+
ports:
117+
- 5432:5432
118+
options: >-
119+
--health-cmd="pg_isready -U $POSTGRES_USER"
120+
--health-interval=5s
121+
--health-timeout=5s
122+
--health-retries=5
84123
steps:
85-
- run: echo 'Enable after choosing the merged repository production ref.'
124+
# Phase 1 — apply the migrations under test to a real Postgres.
125+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
126+
with:
127+
persist-credentials: false
128+
- uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
129+
with:
130+
cache: true
131+
node-version-file: .node-version
132+
- run: vp run build
133+
- run: vp run --filter @voidcloud/api migrate
134+
env:
135+
DATABASE_URL: ${{ env.COMPAT_DATABASE_URL }}
136+
137+
# Phase 2 — the release that is live must still run on that schema. `prod` is that release.
138+
# PRs are checked against it; a push to `prod` checks itself, as in the old repository; a
139+
# manual dispatch from another ref is checked against `prod`.
140+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
141+
with:
142+
ref: prod
143+
persist-credentials: false
144+
- name: Does the baseline carry the platform?
145+
id: production
146+
run: |
147+
if [ -d platform/packages/api ]; then
148+
echo 'usable=true' >> "$GITHUB_OUTPUT"
149+
else
150+
# Production still runs a pre-monorepo revision, so there is no platform code to test
151+
# against: the schema is checked, the code is not. That gap closes with the first
152+
# release from this repository, after which this branch stops being taken.
153+
echo 'usable=false' >> "$GITHUB_OUTPUT"
154+
echo '::warning::The compatibility baseline predates the platform, so the production code was not exercised against the migrated schema. This stops once prod carries the platform.'
155+
fi
156+
- if: steps.production.outputs.usable == 'true'
157+
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
158+
with:
159+
cache: true
160+
node-version-file: .node-version
161+
- name: Run the production ref's integration tests on the migrated schema
162+
if: steps.production.outputs.usable == 'true'
163+
run: |
164+
vp run build
165+
vp run --filter './platform/packages/**' build
166+
vp test run --project platform-api-integration
167+
env:
168+
VOID_TEST_DATABASE_URL: ${{ env.COMPAT_DATABASE_URL }}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
name: Deploy Platform Production
22

33
on:
4+
push:
5+
# `prod` is the production ref: it holds the commit that is live, and
6+
# advancing it is the release. No path filter — a push here is deliberate.
7+
branches: [prod]
48
workflow_dispatch:
59

10+
concurrency:
11+
# One running deploy plus at most one pending. Never interrupt a deploy
12+
# between the api migration and the workers that depend on it.
13+
group: platform-deploy-production
14+
cancel-in-progress: false
15+
616
permissions: {}
717

818
jobs:
919
ci:
1020
if: ${{ vars.VOID_PLATFORM_DEPLOY_ENABLED == 'true' }}
1121
uses: ./.github/workflows/platform-ci.yml
22+
with:
23+
# Every production trigger runs the schema-compatibility guard, whichever ref the
24+
# dispatch came from.
25+
require-schema-compat: true
1226
permissions:
1327
contents: read
1428

‎platform/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Paths in this file are relative to `platform/` unless they begin with `../`. For
7373

7474
The `@voidcloud/platform` workspace package retains the dashboard-only `dev` and `build` scripts. Dashboard deploys run after the platform workers in the platform deploy pipeline.
7575

76-
**Production deploys are manual only; staging deploys on every push to `main`.** See [`meta/operations.md`](./meta/operations.md#github-actions) for the workflow triggers. Do not ask to push or deploy.
76+
**Production deploys on push to `prod`; staging deploys on every push to `main`.** See [`meta/operations.md`](./meta/operations.md#github-actions) for the workflow triggers. Do not push `prod` or ask to deploy.
7777

7878
## Local Development
7979

‎platform/meta/operations.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ Five Cloudflare Workers:
2727

2828
Two fully isolated environments. Each has its own CF resources (KV, R2, dispatch namespace, analytics engine) and a separate Hyperdrive configuration pointing to its own PlanetScale PostgreSQL database.
2929

30-
| Environment | API URL | Dashboard | User Sites | Trigger |
31-
| -------------- | ------------------------ | ------------------------- | -------------------- | ----------------------------------------------------------------------------- |
32-
| **Production** | `api.void.cloud` | `dash.void.cloud` | `*.void.app` | Manual: "Deploy Platform Production" workflow |
33-
| **Staging** | `api.staging.void.cloud` | `dash.staging.void.cloud` | `*.staging.void.app` | Automatic: "Deploy Platform Staging" on push to `main` (also manual dispatch) |
30+
| Environment | API URL | Dashboard | User Sites | Trigger |
31+
| -------------- | ------------------------ | ------------------------- | -------------------- | -------------------------------------------------------------------------------- |
32+
| **Production** | `api.void.cloud` | `dash.void.cloud` | `*.void.app` | Automatic: "Deploy Platform Production" on push to `prod` (also manual dispatch) |
33+
| **Staging** | `api.staging.void.cloud` | `dash.staging.void.cloud` | `*.staging.void.app` | Automatic: "Deploy Platform Staging" on push to `main` (also manual dispatch) |
3434

3535
## Cloudflare Resources
3636

@@ -231,7 +231,7 @@ Two GitHub environments (`Prod` and `Staging`) share the same repo-level secrets
231231
| `CF_ACCESS_CLIENT_ID_STAGING` | Staging | CF Access service-token client id for reaching staging (see Staging Access Control) |
232232
| `CF_ACCESS_CLIENT_SECRET_STAGING` | Staging | CF Access service-token client secret for reaching staging |
233233

234-
`platform-deploy.yml` uses the `Prod` environment and runs only on manual dispatch; `platform-deploy-staging.yml` uses `Staging` and also runs on every push to `main` that matches the `platform-ci.yml` path filter. Both run only when the repository variable `VOID_PLATFORM_DEPLOY_ENABLED` is `true`.
234+
`platform-deploy.yml` uses the `Prod` environment and runs on every push to `prod` or on manual dispatch; `platform-deploy-staging.yml` uses `Staging` and runs on every push to `main` that matches the `platform-ci.yml` path filter, or on manual dispatch. Both run only when the repository variable `VOID_PLATFORM_DEPLOY_ENABLED` is `true`.
235235

236236
**GitHub Actions `CF_API_TOKEN` permissions** — narrower-scope token used only for wrangler deploys. Separate from the API worker runtime token.
237237

@@ -513,13 +513,16 @@ Four platform workflows live in the repository-level `.github/workflows/` direct
513513
5. `vp run check:platform` and `vp run check:sdk` (typecheck the platform packages and the SDK packages; the playgrounds are typechecked by `ci.yml`)
514514
6. `vp test run --project platform-unit` and `--project platform-api-integration`
515515
7. Builds the API build container image (PRs only)
516+
8. The production schema-compatibility guard (`schema-compat`; PRs and production deploys): migrates a fresh Postgres 18 with the candidate's migrations and runs the `prod` branch's `platform-api-integration` suite on it
516517

517-
**`platform-deploy.yml`** — manual dispatch (production, environment: `Prod`):
518+
**`platform-deploy.yml`** — push to `prod`, or manual dispatch (production, environment: `Prod`):
518519

519520
1. Runs `platform-ci.yml` first
520521
2. `vp run build` (SDK packages the dashboard bundles)
521522
3. `vp run --filter @voidcloud/platform deploy` (api with drizzle-kit migrations, proxy, tail, dispatch, dashboard)
522523

524+
`prod` is the production ref: it points at the commit that is live, so advancing it is the release (`git push origin main:prod` deploys whatever `main` is at). There is no path filter — a push to `prod` is deliberate. The branch carries a `prod` ruleset that blocks deletion and force-pushes and allows updates only from its bypass actors (organization admins, repository admins, and the `Void` team), so a push to `prod` is a maintainer action. A `concurrency` group (`platform-deploy-production`, `cancel-in-progress: false`) keeps one deploy running and at most one pending, so a deploy is never interrupted between the API migration and the workers.
525+
523526
**`platform-deploy-staging.yml`** — push to `main` that matches the `platform-ci.yml` path filter, or manual dispatch (staging, environment: `Staging`):
524527

525528
1. Runs `platform-ci.yml` first

‎platform/packages/api/test/integration/api-migrations.test.ts‎

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4476,18 +4476,19 @@ describe('POST /projects/:id/migrations/rename', () => {
44764476
expect(rollbackBody.migrationDiff).toEqual(['m3.sql']);
44774477
});
44784478

4479-
it('rollback-vs-rollback serializes on the row lock and keeps pg and ROUTING in sync', async () => {
4480-
// Concurrent rollbacks must fully serialize. The row lock covers the
4481-
// whole cutover (pg flip + ROUTING.put + cron + queue + ISR), so when
4482-
// both finish the final state is internally consistent: whatever pg
4483-
// says is active, ROUTING points at the matching worker, and the
4484-
// diffs returned by both rollbacks are computed against the correct
4485-
// pre-flip snapshots.
4479+
it('two rollbacks in sequence keep pg and ROUTING in sync, the second after the first has finished', async () => {
4480+
// Two rollbacks run one after the other must leave a consistent state.
4481+
// The row lock covers the whole cutover (pg flip + ROUTING.put + cron +
4482+
// queue + ISR), so when both finish the final state is internally
4483+
// consistent: whatever pg says is active, ROUTING points at the
4484+
// matching worker, and the diffs returned by both rollbacks are
4485+
// computed against the correct pre-flip snapshots.
44864486
//
4487-
// This test pins that invariant by holding rollback A mid-cutover
4487+
// This test pins that invariant by holding rollback A mid-rollback
44884488
// (inside its locked tx, AFTER the pg flip but during its
44894489
// `ROUTING.put(slug, …)` external write) and kicking rollback B while
4490-
// A is paused. B must block on the row lock until A commits.
4490+
// A is paused. B starts only after A has fully finished, post-commit
4491+
// lease cleanup included.
44914492
const user = await createTestUser(env.db);
44924493
const project = await createTestProject(env.db, user.id, 'r13f1-rb-rb');
44934494
await insertD1Resource(env.db, project.id);
@@ -4552,9 +4553,8 @@ describe('POST /projects/:id/migrations/rename', () => {
45524553
//
45534554
// Both rollbacks in this scenario carry non-empty diffs (A→B drops m3;
45544555
// B→C drops m2) so the destructive guard would reject them without an
4555-
// opt-in. The invariant here (lock serialization between concurrent
4556-
// rollbacks) is orthogonal, so echo each rollback's exact diff via
4557-
// `allowDestructive`.
4556+
// opt-in. The invariant here (a second rollback after the first) is
4557+
// orthogonal, so echo each rollback's exact diff via `allowDestructive`.
45584558
const rollbackAPromise = app.fetch(
45594559
authedReq(`/projects/${project.id}/rollback`, user.jwt, {
45604560
method: 'POST',
@@ -4564,10 +4564,48 @@ describe('POST /projects/:id/migrations/rename', () => {
45644564
);
45654565
await aReachedPut;
45664566

4567-
// Kick rollback B to C. It enters the tx callback and blocks on the
4568-
// per-project row lock — A still holds it. (On PGlite this is also
4569-
// enforced by the underlying single-connection serialization, a safe
4570-
// upper bound for this test.)
4567+
// B is held at the waypoint between its pre-lock reads and its row-lock
4568+
// acquisition, so it can only run once A's rollback — including the
4569+
// post-commit lease cleanup — has finished. The wrapper is installed after
4570+
// A's transaction started, so the first transaction it sees is B's.
4571+
//
4572+
// Without the hold the driver decides the order: PGlite queues statements,
4573+
// so B's in-lock read runs after A's cleanup, while a pooled Postgres grants
4574+
// the queued row lock at A's commit, so B resumes before that cleanup, reads
4575+
// A's rollback lease and answers 409 `deploy in progress`. That fence is
4576+
// real, but it is driver order rather than the sequence under test.
4577+
//
4578+
// This test therefore does not exercise row-lock contention between
4579+
// rollbacks: on Postgres a rollback that wins the lock at the other's
4580+
// commit still sees its rollback lease and answers 409, which is intended.
4581+
// The row lock itself is covered by the `/apply` contention tests in this
4582+
// file.
4583+
const realDb = env.db;
4584+
const realTransaction = realDb.transaction.bind(realDb);
4585+
let releaseB: () => void = () => {};
4586+
const bHold = new Promise<void>((r) => {
4587+
releaseB = r;
4588+
});
4589+
let bHoldFired = false;
4590+
const wrappedDb: Db = new Proxy(realDb, {
4591+
get(target, prop, receiver) {
4592+
if (prop === 'transaction') {
4593+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4594+
return (async (fn: any) => {
4595+
if (!bHoldFired) {
4596+
bHoldFired = true;
4597+
await bHold;
4598+
}
4599+
return realTransaction(fn);
4600+
}) as unknown as typeof realDb.transaction;
4601+
}
4602+
return Reflect.get(target, prop, receiver);
4603+
},
4604+
}) as Db;
4605+
setTestDb(wrappedDb);
4606+
4607+
// Kick rollback B to C. It does its pre-lock reads, then waits at the
4608+
// waypoint until A's rollback has fully completed.
45714609
const rollbackBPromise = app.fetch(
45724610
authedReq(`/projects/${project.id}/rollback`, user.jwt, {
45734611
method: 'POST',
@@ -4576,18 +4614,14 @@ describe('POST /projects/:id/migrations/rename', () => {
45764614
env,
45774615
);
45784616

4579-
// Give B's request time to reach its tx/lock acquisition and
4580-
// actually be blocked. We cannot poll pg to confirm this — a
4581-
// `SELECT` through env.db would itself block on the single
4582-
// PGlite connection that A's tx still holds — so we use a small
4583-
// sleep. Release A afterward.
4584-
await new Promise((r) => setTimeout(r, 30));
4585-
4586-
// Release A. It commits the tx (pg active=B, ROUTING=B, etc),
4587-
// then B acquires the lock and flips pg active=C, ROUTING=C.
4617+
// Release A. It commits the tx (pg active=B, ROUTING=B, etc) and clears its
4618+
// rollback lease, then B acquires the lock and flips pg active=C, ROUTING=C.
45884619
releaseA();
4589-
const [resA, resB] = await Promise.all([rollbackAPromise, rollbackBPromise]);
4620+
const resA = await rollbackAPromise;
45904621
expect(resA.status).toBe(200);
4622+
releaseB();
4623+
const resB = await rollbackBPromise;
4624+
setTestDb(realDb);
45914625
expect(resB.status).toBe(200);
45924626

45934627
const bodyA = (await resA.json()) as {
@@ -4609,8 +4643,8 @@ describe('POST /projects/:id/migrations/rename', () => {
46094643

46104644
// Final consistency: pg says active=C AND ROUTING.workerName matches C.
46114645
// The pg flip happens FIRST inside the lock and external writes follow,
4612-
// so the serialized order guarantees pg and ROUTING converge on the
4613-
// same terminal state.
4646+
// so running B after A guarantees pg and ROUTING converge on the same
4647+
// terminal state.
46144648
const finalPg = await env.db.select().from(projects).where(eq(projects.id, project.id));
46154649
expect(finalPg[0]!.active_deployment_id).toBe(depC.id);
46164650

0 commit comments

Comments
 (0)