Internal engineering brief

JoyOS Lab -- CI and Delivery Plan

An honest map of how a change gets from your request to production today, the five places it actually breaks, and a phased plan to make the path visible and reliable end to end.

Prepared 2026-07-22  /  based on the workflows, hooks, and live GitHub Actions history in this repo

Section one

How it works today

There are three separate delivery paths, each with its own rules. They do not share gates, they do not share test coverage, and only one of them runs the test suite at all.

Every job runs on the self-hosted runner on AS3 -- the container on your own server, not GitHub's machines. That part is correct and consistent across all workflows.

Path A  --  Pull request preview (pr-preview.yml + the preview job in deploy.yml)
1Installroot + module deps
2Provision PR modulefresh DB per PR
3Seed + verifygate: empty table fails
4Buildtype check
5CSP checkgate
6Deploy previewpr-N.pages.dev
7Playwright smokegate: browser only

No unit test suite anywhere on this path. The smoke test loads the app in a browser; it does not assert that anything was written to the database.

Path B  --  Staging (staging.yml, serving joyos-lab.braceyourself.solutions)
1Bindings driftgate, before publish
2Publish schemacurrently failing
3Seed + table gategate
4Reducer-drop gategate
5Buildtype check
6Feature inventorygate: no deletions
7Deploy stagingPages branch alias

There is no test step and no end-to-end step on the staging path at all. Staging is gated entirely on schema and structural guards.

Path C  --  Production (deploy.yml, push to main)
1Bindings driftgate
2Publish schemaprod DB changes here
3Buildtype check
4Full test suitegate, ~1948 tests
5Deploy clientPages, branch main
6Health + canarygate, authenticated

Step 2 changes the live production database. Step 4 can then fail and stop the pipeline before step 5. That ordering is the single most consequential design decision in the whole system.

What gates what, in one table

SurfaceFull unit suiteBrowser E2EDatabase round-trip proof
PR previewNoYes -- smoke + WebKit loadNo
StagingNoNoNo
ProductionYes -- after schema publishNo Playwright; an authenticated canary hits one admin endpointPartial -- the canary proves one read path is alive
Local git pushYes -- still runs in the hookNoNo

What is genuinely good and should not be touched

The schema-safety machinery is strong and hard-won. The bindings-drift gate (does the committed client code match the database schema) runs before every publish on both staging and production. The reducer-drop gate catches a function disappearing out from under the client. The feature-inventory guard reads the built output and fails if a known feature vanished. The authenticated production canary is a real end-to-end check that catches the "200 OK shell over a dead app" class of outage that a plain health check misses. The staging pipeline correctly queues rather than cancels. None of that should be weakened by anything in this plan.

Takeaway

The system is over-invested in schema safety and under-invested in behavior safety. Staging deploys with zero tests; production runs all 1948 of them, but only after it has already changed the production database.

Section two

Where it actually breaks

Six findings. Five are confirmed against the actual files and the live run history. One I could only partially confirm and I say so rather than assert it.

ConfirmedLive right now

1. Staging has been broken for hours and nothing said so

Every single push to staging-next since roughly 11:46 this morning has failed, and it fails at the first step -- publishing the database schema. Six consecutive red runs. Meanwhile pull requests kept merging into that branch.

The cause is a genuinely unsafe migration: the schema in the branch removes columns and a whole table that exist on the live staging database. SpacetimeDB correctly refuses. Because the publish is step one, the build and deploy never run -- so the staging site is still serving an old build and nobody was told. This is exactly the "did my change go live, I have to guess" problem, in its most literal form.

gh run list -- staging-next, 2026-07-22: 12:58 failure | 12:51 failure | 12:21 failure | 12:21 failure 12:19 failure | 12:18 failure | 11:46 failure failing step: "Publish staging SpacetimeDB module" Removing a column featured from table marketplace_listings requires a manual migration Removing the table checkout_request_counts requires a manual migration Aborting because publishing would require manual migration ... and --delete-data was not specified
Confirmed

2. Production publishes the schema before it runs the tests

You asked me to verify this specifically. It is true, and the step is even named for it. In deploy.yml, the order is: publish the production database schema, then build, then run the 1948-test suite, then deploy the client.

So if the suite fails, production is left with a new schema and the old client. Because SpacetimeDB encodes rows positionally, an added column that the deployed client does not know about produces the byte-overflow crash that took the site down on 2026-04-22. The pipeline exists to prevent that outage and, in this specific failure window, recreates it.

Two honest qualifiers. The bindings-drift gate runs before publish and catches the common version of this, so the window only opens when the schema change is legitimate but some unrelated test fails. And publishes use --delete-data=never, so additive columns are usually tolerated by an older client. The window is narrow. It is not closed, and the blast radius when it opens is the whole site.

deploy.yml step order line 293 Publish SpacetimeDB module (before build/test for schema migrations) line 330 Type check and build line 334 Run tests <-- can fail here line 406 Deploy to Cloudflare Pages <-- never reached
Confirmed

3. A second push to main kills the first deploy mid-flight

Production is configured to cancel any in-progress deploy when a new one starts. Combined with finding 2, the half-done state is well defined and bad: the schema publish is fast and near the front, the client deploy is slow and near the end. A cancellation almost always lands after the database changed and before the site was updated.

Worth noting the staging pipeline gets this right and says so in a comment -- it queues instead of cancelling, explicitly to avoid this. Production has the opposite setting.

deploy.yml concurrency: deploy-${{ github.ref }}, cancel-in-progress: true staging.yml concurrency: staging-publish, cancel-in-progress: false "Queue, NEVER cancel ... would leave the schema newer than the deployed app"
Confirmed

4. The "no tests in hooks" cleanup is only half done on this branch

The commit hook was disabled on 2026-07-22 exactly as ruled. The push hook was not -- it still runs the entire 1948-test suite, on your desktop, on every push, roughly five minutes each time. Its own comment even advertises the cost.

This is the "huge test suites killing our systems" complaint, still live in this working tree. It is also the mechanism behind the orphaned-suite leak: interrupting a push kills the foreground git process, not the suite it already spawned.

.githooks/pre-commit if false; then # test disabled 2026-07-22 - no tests in commit hooks .githooks/pre-push (line 5 and line ~110) echo "[pre-push] Running type check + tests..." npx vitest run 2>&1 | tail -20 || { echo "[pre-push] TESTS FAILED"; exit 1; } # comment: "1948 tests ~5 min"
Confirmed

5. Steps that look like gates but are switched off

Several steps pass unconditionally. Each was a deliberate, documented decision at the time; together they mean a green check mark means less than it appears to.

  • Preview teardown is hard-disabled with if: "false" in both deploy.yml and pr-preview.yml. Per-PR databases are never deleted. They accumulate on the AS3 SpacetimeDB host indefinitely.
  • The encryption health check is set to never fail -- marked temporary on 2026-05-18 because the admin token expired. That guard exists because a key rotation silently broke Stripe for three days. It is currently decorative.
  • The preview flag-coverage check never fails, by design, because it races the seed step.
  • ensure_module_version on production is suffixed || true, so a failure to stamp the deployed version is invisible.
  • The auto-merge workflow's eligibility check is continue-on-error: true, which is load-bearing rather than sloppy -- but it means merge decisions hinge on step outcome comparisons rather than a hard gate.
Confirmed, with a caveat

6. Preview databases are fresh, so data-shaped bugs cannot appear there

Confirmed structurally. Each pull request gets its own newly provisioned, freshly seeded database. Any bug whose trigger is accumulated state -- an auto-increment counter that has run far enough to collide, a tier gate that only bites an existing paid user, a sequence that has advanced -- has no way to fire on a preview. The 2026-05-18 incident was exactly this shape.

The caveat, and the reason it stays dangerous: the preview end-to-end tests are browser smoke tests. I found no step on any path that writes a row, reads it back, and asserts its contents. So the preview both lacks the data state and lacks the assertion that would notice. Your standing rule that write-path E2E must prove a database round-trip is documented in the project instructions but is not enforced anywhere in CI.

Could not confirm

Branch configuration is drifting between working trees

The staging.yml in this working tree triggers on pushes to dev/live-tunnel. The runs actually firing today are triggered by pushes to staging-next. So the copy of the workflow in this branch is behind the copy on staging-next. I am reporting what I read plus what the live history shows, rather than guessing which is authoritative. It matters because reading a workflow file from the wrong branch gives a confidently wrong answer about how the system behaves -- which is itself a form of the guessing problem.

Takeaway

The failures are not random. Every one of them is the same shape: an irreversible or expensive step runs before the cheap check that would have stopped it, and when it goes wrong nothing announces it.

Section three

The plan

Five phases, ordered so each one closes a specific failure above. Everything here sits inside your locked rules: no tests in hooks, tests run in the swarm, full suite only at the production boundary, focused tests for staging and dev, ship-on-green to staging automatic, production promotion initiated by you.

Phase 0  --  today

Unblock staging and finish the hook removal

What changes
Two things, both small. First, resolve the marketplace migration that is blocking staging -- either restore the dropped columns or perform the deliberate manual migration -- so the staging site catches up to the six merged pull requests it has silently missed. Second, delete the npx vitest run block from the push hook, leaving the cheap guards it also runs: type check, bindings drift, and the ASCII commit-message check the Cloudflare API requires.
Why it kills a failure mode
Directly clears findings 1 and 4. Staging becomes truthful again and your pushes stop costing five minutes and stop leaking orphaned test processes.
How we know it worked
A push to staging-next completes green end to end, and the staging site reports the new build version. A timed push completes in seconds. A guard test asserts no test runner appears in any executable line of any hook file -- the same pattern already used in this repo's hook tests.
Phase 1

Make the production schema publish the last irreversible act

What changes
Reorder the production pipeline so it becomes: bindings drift, build, full test suite, then publish schema, then deploy client. Publish and deploy become adjacent, in that order, with nothing that can fail in between. And flip production's concurrency setting to queue rather than cancel, matching what staging already does.
Why it kills a failure mode
Closes findings 2 and 3 together. A failing test can no longer leave production with a migrated database and a stale client, and a second push can no longer sever the publish from the deploy. The byte-overflow outage class stops being reachable through the normal pipeline.
How we know it worked
A deliberately failing test on a scratch branch aimed at the production job stops the run before any publish occurs -- verified by describing the live schema afterward and seeing it unchanged. A workflow-structure test asserts the publish step index is greater than the test step index and less than the deploy step index, so the ordering cannot silently regress.
Phase 2

Move test execution into the swarm, split by boundary

What changes
Tests stop running anywhere on the desktop and stop being a step inside the deploy workflow's own job. They run as containerized swarm jobs, with a project agent reading the result and deciding what happens next. The split follows your rule: changed-file scope for pull requests and staging, full suite only for the production boundary. The staging pipeline gains a focused test gate it currently does not have at all.
Why it kills a failure mode
It removes the last place a large suite competes with your machine for resources, and it fixes the inverse problem nobody has named yet: staging currently deploys with no behavioral testing whatsoever, which is why regressions reach the staging site and are only discovered by looking at it.
How we know it worked
Staging pipeline time stays under a few minutes with a focused gate in place. Production still runs all 1948. No test process ever appears in the desktop process list during a push or a merge. A job that cannot be placed on a swarm node fails rather than quietly falling back to local execution -- see the open question below, which is the one word standing between the current behavior and that guarantee.
Phase 3

Make write paths prove a database round-trip

What changes
Any change that touches a write path must ship with an end-to-end test that queries the database before, performs the action through the real running site, waits, queries after, and asserts on the row's contents. Not a client-side assertion, not a screenshot. This becomes a required check on the preview pipeline for write-path changes and part of the production promotion evidence. Alongside it, the preview databases stop being pristine: seeding advances counters and creates at least one existing paying user and one existing sequence, so state-dependent bugs have somewhere to appear.
Why it kills a failure mode
Closes finding 6, which is the one that keeps producing bugs invisible on preview and fatal on production. It is also the only defense against the silent-write class -- a dropped promise, a tier gate, a queued write that is never retried -- where every unit test is green and no row ever lands.
How we know it worked
The known historical bugs of this shape are replayed against a seeded preview and the new tests catch them. A pull request that touches a write path and lacks a round-trip assertion is blocked by the check rather than by someone remembering.
Phase 4

Retire the disabled gates -- restore them or delete them

What changes
Every step identified in finding 5 gets a decision, not an indefinite reprieve. Refresh the admin token and restore the encryption health check to a hard gate. Either enable preview teardown or accept accumulation deliberately and add a scheduled reaper with a retention window. Resolve the flag-coverage race so the check can be real. Remove the || true from the version stamp so a failed stamp is visible.
Why it kills a failure mode
A green check mark should mean the thing it names actually passed. Today several of them mean only that the step executed. That erodes trust in every other check on the same run -- which is precisely how a real failure gets waved through.
How we know it worked
A repository guard test scans all workflow files and fails on any if: "false", and on any continue-on-error: true that lacks a dated expiry annotation. Disabling a gate becomes possible but never silent.
Takeaway

Phases 0 and 1 are the ones that stop outages. Phases 2 through 4 are what stop the slow drift back into guessing.

Section four

No more guessing

You should be able to answer "where is my change right now" in one glance, without opening GitHub, without asking, and without reading a workflow file from the wrong branch.

The information already exists. It is scattered across GitHub Actions run pages, Cloudflare Pages deployments, the SpacetimeDB module version stamp, and the pull request list. Nothing needs to be invented -- it needs one surface.

The surface

Extend the existing pull-request dashboard, which already runs locally and has systemd and a watchdog behind it. Add a single delivery pipeline view: one row per change, columns for each stage, refreshed on a short interval. It lives at the same local address you already use.

ColumnWhat it showsWhere the truth comes from
ChangePlain-English description plus branch, never a bare numberPull request title and branch
TestsFocused or full, pass or fail, and which swarm node ran themSwarm job result record
AdversarialConfirmed, refuted, or not yet runVerification record
Round-trip E2EYes, no, or not applicable for read-only changesThe Phase 3 check
On stagingWhether this exact commit is the build the staging site is currently servingVersion stamp fetched live from the staging site, compared to the commit
On productionSame comparison against the live production siteVersion stamp fetched live from lab.joyos.global
Blocked onThe single reason it has not moved, in plain wordsFailing step name from the run

The two deployment columns are the important ones, and the reason is finding 1. Today a change can be merged, green in the merge workflow, and still not on the staging site because the publish step failed hours earlier. Reading merge status tells you nothing about that. Asking the live site what version it is serving is the only answer that cannot lie. Both sites already stamp a build version, and production already verifies its own stamp after deploy -- the dashboard just has to poll them.

The one alert that matters

One rule, not a notification stream: if a change has been merged for more than fifteen minutes and the target site is not serving it, that is an alert. That single rule would have caught this morning's staging outage on the first failed run instead of after six. Everything else can stay quiet -- silence should mean the pipeline is flowing, and today it does not.

Takeaway

Status is not "did the workflow pass." Status is "is the site serving my commit." Measure the second one and the guessing ends.

Section five

Open questions for you

Four genuine forks. I have a recommendation on each, but these are yours to call -- guessing past any of them is how the unreliability comes back.

1. Should a dirty local tree refuse to run tests?

The job queue now fails closed when a job cannot be placed on a swarm node -- it refuses rather than quietly running on your desktop. But there is one exception: when your local working tree has uncommitted changes, it currently only warns and runs locally anyway. The reasoning was practical -- most test runs during active development happen against a dirty tree, so failing closed there would refuse a large share of every project's runs.

Making it strict is literally one word added to fail_closed_reasons in /home/ethan/bin/jobq.

  • Option A -- leave it warning. Development stays frictionless. The desktop keeps running suites, which is the exact thing the swarm rule exists to prevent.
  • Option B -- make it strict now. The rule becomes absolute. Every dirty-tree test run is refused until the work is committed or pushed to a swarm-visible ref.
  • Option C -- strict, with an explicit escape. Refuse by default, allow an opt-in flag per invocation that is logged.
Recommendation

Option C. It honors the rule as written -- no fallback to the desktop by accident -- without blocking the genuine mid-edit case, and the log makes the exception visible rather than habitual. Option B is the honest reading of your ruling and I would not argue against it, but it will bite on the first day and I would rather you choose that deliberately than discover it.

2. Which branch actually drives staging?

The workflow file in this working tree says dev/live-tunnel. The runs firing today come from staging-next. Your standing rule names staging-next as the branch serving the staging site. Two branches carrying different copies of the same pipeline is a live hazard -- it means any answer about how CI behaves depends on which tree you read it from.

  • Option A -- staging-next is authoritative; the trigger list in older branches is stale and gets fixed on merge.
  • Option B -- both branches are intentionally live for different purposes.
Recommendation

Option A, and add a check that fails if the staging workflow's trigger branch list does not match the declared staging branch. But I will not act on this without your word, because deleting the wrong trigger silently unhooks a deploy path.

3. What happens to the blocked marketplace migration?

Staging is refusing to publish because the branch removes columns and a table that exist on the live staging database. That is the schema-safety system doing its job correctly. But it needs a decision, not a workaround.

  • Option A -- restore the columns. Reintroduce the dropped fields, unblock immediately, and remove them later through the add-new-table, backfill, cut over, deprecate path.
  • Option B -- reset the staging module. The workflow already has a guarded destructive reset. Staging data is seeded and reproducible, so the cost may be acceptable. Fast, and it teaches the pipeline that reset is the answer to a hard migration -- which is the habit your safe-migration ruling exists to prevent.
Recommendation

Option A. Reset is the shortcut that cannot be taken on production, and staging should rehearse the production path, not a path that will not exist when it matters.

4. Should the production promotion gate be encoded, or stay manual?

Production promotion is yours to initiate -- that is settled. The open question is whether the pipeline should refuse a promotion whose evidence is incomplete, or trust that you only promote when it is ready.

  • Option A -- stay manual. You push, it deploys. Maximum control, no machine standing between you and production.
  • Option B -- encode the preconditions. You still initiate, but the pipeline refuses if the full suite has not passed on that exact commit, or adversarial verification is missing, or a write-path change lacks its round-trip proof.
Recommendation

Option B. It does not take the decision away from you -- it takes the remembering away from you, which is where the misses come from. But it means production can tell you no, and you should agree to that before it happens rather than during an incident.

Bottom line

Nothing in the plan requires new infrastructure. It requires reordering one pipeline, finishing one cleanup, moving tests where you already ruled they belong, and pointing one dashboard at the two live sites so the answer to "is it live" stops being a guess.