v1 · the whole journey, box by box
This is the actual machine that stands between a change and your users — every automatic check, what each one costs in seconds, what it catches, and where the failure lands when it fails. Nothing is glossed over. Click any box to open it. Every number on this page was measured from the real repository on 2026-07-21.
Three acts. Left to right, top to bottom. A change enters at box 1 and is in front of users after box 8; box 9 never stops running.
Everything here happens before the change is allowed into the main line of the codebase. This is the cheap place to be wrong.
The only step on the whole page that no machine is watching. Everything downstream exists because this step is done by a human at 1am.
Either Swift code in the iOS app, or — for anything touching the database — a new migration file dropped into supabase/migrations/ with a timestamp in its name.
as long as it takes
Not measured, and not measurable. Nothing is automated here.
Nothing. This is the step where mistakes are made, not caught.
Nowhere yet. It shows up two boxes later, or — if it slips through — in front of a user.
migration — a numbered text file of instructions for the database, applied once, in order. NoonFox never edits a database by hand: it writes a file, and every copy of the database (the throwaway test one, staging, production) is brought to the same shape by applying the same files in the same order. The files are the source of truth; the databases are just the result.
Why the database is the scary half
App code can be rolled back by shipping a new build. A database migration that drops a column has already destroyed the data by the time anyone notices. That asymmetry is the reason nearly every check on this page is about the database, and why the last two boxes require a human to type a confirmation.
A pull request is a proposal: "here is a bundle of changes, please let them into the main line." Nothing is merged yet. Opening it is what fires the starting gun for every automatic check.
The branch is pushed to GitHub and a pull request is opened against main. GitHub then looks at every workflow file in the repository and starts the ones that listen for pull_request.
seconds
The click itself. The waiting starts at box 3.
Nothing on its own — but it is the trigger without which none of the checks would ever run.
N/A.
Path filters — why not every check runs every time
Each workflow declares which files it cares about. The database workflows only wake if something under supabase/** changed; the Xcode workflow only wakes if the project file changed. A pure copy-text change to the app can therefore go green in seconds, while a one-line SQL change triggers the full two-minute battery. This is deliberate: it keeps the common case fast.
presubmit — a check that runs before a change is allowed in, not after. The opposite is finding out in production. Everything in Act 1 is presubmit.
Five separate workflows fire at the same moment. Because they are separate workflows on the same event, they run concurrently — so the wait is the length of the longest one, not the sum. Today that is about 114 seconds. Any single red one blocks the merge button.
≈ 114 s
Median of the slowest workflow. Its worst measured run: 143 s.
Broken schemas, un-replayable migrations, security rules that leak, drifting config, unregistered source files, unpinned dependencies.
Anything in the iOS app itself. See the honest gaps.
A red ✗ next to the pull request, plus an email from GitHub. Never an email when it goes green again — see where errors show up.
The five checks — open each one
.github/workflows/edge-schema-refs.yml · median 114 s · slowest measured 143 s
This is the conceptual centrepiece of the whole pipeline, and it is worth understanding properly, because it is the one check that can prove a database change is correct without touching any real database.
hermetic — sealed off from the outside world. The job spins up a brand-new, completely empty Postgres database inside the CI machine, replays every migration file in order starting from 000_baseline.sql, runs its tests against that, and then destroys the whole thing. It needs no staging, no production, no credentials, no network. Nothing it does can affect anything real, and nothing real can affect its result. Run it a thousand times and you get the same answer a thousand times.
Where the 114 seconds actually go
pgTAP — a testing framework that runs inside the database itself. The tests are written in SQL and report pass/fail like any other test suite. RLS — row-level security: the database's own rules about which rows each signed-in user is allowed to see or change. Several of those 33 tests exist purely to prove one user cannot read another user's photos.
A real catch, from today
A new database function called capture_session_owner was added. Written that way, it would run with elevated privileges and was reachable by the app — so a rule fired: any function that powerful has to be on an explicit allowlist. The job caught it from the files alone, in under two minutes, before the change had reached staging, production, or a single user. That is the entire argument for the hermetic approach in one line of output.
.github/workflows/supabase-tests.yml · median 70 s · two jobs
Job A — edge function unit tests · ≈ 40 s
Fifteen guard and test steps against the server-side functions: a guard that every external dependency is pinned to an exact version, guards on how storage paths are built, a check that four separate copies of the same text-normalising function still agree byte for byte, an AI spend cap, health-check rules. All of it hermetic and cheap.
Job B — SQL contract tests · 57 s
Twenty-five shell scripts that send SQL over the internet to the production database, via Supabase's management interface. Each one is wrapped in BEGIN … ROLLBACK, which means the database is asked to do the work and then throw it away, so nothing is left behind.
Two problems, both structural — neither is a bug you can fix by rewriting a line.
1. It inverts the order of the whole pipeline. If a pull request adds a new column, the contract test for that column asks production about it — and production doesn't have it yet, because it isn't merged. The test can only go green after production has already been changed. So production gets the change first and the merge follows, which is precisely backwards from the order every other part of this page assumes.
2. It makes a shared, live, moving thing part of a presubmit check. The rollback means nothing is written, but the answers still depend on whatever production happens to look like at that second.
.github/workflows/xcode-project-sync.yml · ≈ 22 s · 7 jobs in parallel
The first job answers a boringly specific question that has cost real days: is every new Swift file actually listed in the Xcode project file? A file that exists on disk but isn't registered simply never gets compiled — the build is green and the code does nothing.
The other six — "shrink-only ratchets"
Each one counts how many times a known bad pattern appears in the codebase and compares it to the number recorded last time. The count is allowed to go down. It is never allowed to go up. That is the whole rule.
It is a quietly excellent idea: it doesn't demand that anyone stop and clean up 40 old offences before shipping a feature, but it makes offence number 41 impossible. Debt can only ever be repaid, never taken on. Six such counters run here, each in its own job, which is why the whole thing finishes in 22 seconds.
Authentication settings — password rules, token lifetimes, which sign-in providers are switched on — live in a dashboard where they can be changed with a mouse and no record. "Drift" is the gap that opens between what the repository claims is true and what is actually configured. This check measures that gap and fails if it exists.
It runs both here, on pull requests, and on a schedule — because drift isn't caused by pull requests. It is caused by somebody clicking something on a Tuesday. It is the one check on this page that appears in two acts, and that is deliberate.
The release process is itself a set of scripts — assembling the changelog, stamping a version tag, pushing it. Those scripts get their own tests, so a broken release tool is discovered on an ordinary pull request rather than at the moment somebody is trying to ship.
No representative duration was captured for this workflow on the day this page was measured. It is listed here for completeness rather than left out.
This page draws the flow as it is supposed to run: merge, then staging, then production. In practice the arrow between box 3 and box 4 sometimes points the other way, and it is worth being precise about why.
The intended order
1 Prove it hermetically on a throwaway database → 2 merge the files into main → 3 apply to staging and smoke-test it → 4 a human types PROD and applies it to production. The files are the source of truth and the environments follow them, in that order.
What actually happens with a new column
Check 2's SQL contract job asks the live production database the questions. A pull request that adds a new column therefore asks production about a column production does not have — and the check goes red. It cannot go green until production already has the change. So the change is applied to production first, and the merge happens afterwards, to a branch whose own gate only passed because the deed was already done.
The fix is a shape, not a patch: the contract suite has to run against a database it created itself, the way check 1 already does. Until then, the red dashed line stays on the diagram.
Nothing in this act is automatic. There is no auto-deploy to production anywhere in this system, by design — a bad migration applied automatically to production is an outage, and that has already happened once.
The pull request is merged. From this moment the change is part of the official history, and main is what every environment is supposed to be brought into line with.
GitHub merges the branch. Note what does not happen: nothing is deployed. No database is touched. Merging changes the files, not the world.
a click
Only possible once every check from box 3 is green.
It is the enforcement point, not a check: the green checks are what make the button pressable.
GitHub refuses the merge and says which check is red.
Staging is a second, complete copy of the backend that no user ever touches. It exists so the first time a migration meets a real, running system, the stakes are zero.
A person opens the deploy: migration workflow and dispatches it by hand, filling in three inputs: the environment (staging), the path to the migration file, and — only for production — a typed confirmation.
.github/workflows/deploy-migration.yml
≈ 21 s
The machine part. The waiting-for-a-human part is unbounded.
A migration that applies cleanly to an empty test database but not to a real one that already holds data.
A red job on the workflow run.
workflow_dispatch — "only starts when a human presses the button." Most workflows on this page start themselves when something happens. This one cannot. There is no code path anywhere in the repository that deploys a migration on its own.
The three steps it runs, identically for both environments
Staging and production get byte-identical treatment, which is the point: whatever staging survived, production will experience the same way.
The hermetic check in box 3 already proved the schema is valid. This step exists for the three things a throwaway database physically cannot prove, because it has no world around it.
1. That the sync engine replicates the new shape correctly to phones. 2. That the server-side functions deploy. 3. A real client sync round-trip — a device sending and receiving actual data.
by hand
Not a workflow, not measured. A person looking at a running system.
Integration failures: the schema is fine, and yet the pieces don't fit together once they're live.
In front of the person doing it — which is exactly why it must not be skipped.
Correctness here, integration there
The division of labour is deliberate and worth internalising, because it explains the shape of the entire pipeline. Correctness — does the schema apply, do the security rules hold — is proven before merge, on a hermetic throwaway database, where the answer is deterministic. Integration — do the live parts still talk to each other — is proven after merge, on staging. Trying to prove correctness on staging is how a test suite becomes flaky: staging is shared, stateful and moving, so the same test can pass on Monday and fail on Tuesday for reasons that have nothing to do with the change.
The same workflow as box 5, aimed at the real database that real people's photos live in. The only differences are one dropdown value and one word typed by hand.
The same dispatch form, with environment = prod and a third input where the operator must type PROD in capitals. Then the identical apply → ledger → verify sequence.
≈ 21 s
Same workflow, same measured cost as staging.
The typed confirmation catches the only failure mode left at this point: a human who is moving too fast.
A red job on the run — and, if the migration half-succeeded, in the app itself.
Why there is deliberately no automatic promotion
Everywhere else in modern engineering the fashion is: green tests, automatic deploy. Not here, and not by accident. A database migration cannot be rolled back the way a build can — once a column is dropped, the data is gone. So the human dispatch is the promotion gate. There is no "auto-promote to production" switch to find, because none was ever built.
Two things confirm the change landed: the deploy workflow's own final step, and then a canary that behaves like a real user.
The workflow's verify step reads the database back and confirms the expected shape. The canary then fires automatically after every successful deploy.
automatic
Part of the same 21-second run, plus the canary immediately after.
A migration that reported success but left the database in the wrong shape — and, via the canary, one that quietly broke the app's ability to write data.
A red job on the deploy run; a red canary run.
canary — named after the bird taken down coal mines. A tiny, harmless, repeated action whose only job is to stop working before anything important does. This one signs in as a genuine test user and sends the app's exact write request to every synced table. It is non-destructive by construction: every field is sent as NULL, so the database can check whether the write would be permitted without any row ever being stored.
Why this specific canary is clever
Most health checks ask a database "are you awake?" — which it always is, right up until it starts rejecting the one request that matters. This one asks the only question a photo app actually cares about: would the app be allowed to save something right now? It uses a real account and a real signed-in session, so it exercises the permission rules, not just the connection.
Everything above is triggered by a change. These run on a clock whether anything shipped or not — nobody is waiting on them, and their whole value is being boring for months and then loud once. There are two independent clocks: GitHub's, with 4 jobs, and the database's own, with 12.
These are the only jobs in the system with no human waiting on the result, which is what lets them be slow. The nightly backup takes five minutes; nobody minds, because it runs at 02:30 in the morning. There are two entirely separate clocks here, and that distinction matters more than any single job on either of them.
Clock 1 · GitHub Actions — 4 jobs, all times UTC
backup-nightly · ≈ 294 s · 02:30
Copies the database out to Cloudflare R2, a storage service on completely different infrastructure from Supabase. That separation is the entire point: a backup that lives inside the thing it is backing up is not a backup. By a wide margin the longest-running job in the system — and the one whose failure would matter most, in the way that is hardest to notice.
powersync-health · ≈ 54 s · 05:20
Checks that the sync service — the piece that keeps every phone's local copy of the data in step with the server — is genuinely alive. This one exists because of a specific scar: a dead sync instance can keep serving stale cached data and look perfectly healthy from the outside for days.
auth-config-drift · ≈ 23 s · 06:00
The same check that runs on pull requests, on a timer as well — because sign-in settings drift when somebody changes them in a dashboard, which does not produce a pull request to check.
canary: authenticated write · 06:15 + manual + after every deploy
Three triggers: the timer, a button, and automatically after every successful deploy: migration. Signs in as a real test user, sends the app's exact write request to every synced table, and confirms none is rejected. Non-destructive — every field is sent as NULL, so no row can be stored.
The 06:15 slot is chosen, not arbitrary: it sits after the 02:30 backup so the two never contend for the same resources, and early enough in the day to catch a regression introduced the day before.
Clock 2 · pg_cron — 12 jobs running inside the database
These do not appear anywhere in GitHub. They are scheduled by the database, run by the database, and are invisible to anyone watching the repository. Most of them are housekeeping — the daily 03:00–03:40 block is a garbage-collection train, each job clearing out one kind of expired row.
Measured, in seconds, from the real repository. Bars are to the same scale, so the shapes are comparable at a glance. The five pull-request checks run at the same time, so what you wait for is the longest bar among them — not their sum.
The single most expensive line in the entire pipeline is not a test. It is the 88 seconds the hermetic job spends booting an empty Postgres from nothing — three quarters of the longest pull-request wait, spent before a single assertion runs. Everything the pipeline actually checks costs about 26 seconds put together.
Four different places, and only two of them are CI. Knowing which is which is the difference between "the build is broken" and "the app is broken".
Plus an email from GitHub. This is the normal channel for all five pull-request checks.
The gotcha worth knowing: GitHub emails you when a check fails, but not when it goes green again. So after a fix is pushed, the last email in the inbox still points at the old failed run — and it will keep looking broken forever.
The reliable answer is gh pr checks <PR>, or reading the run whose commit matches the current tip of the branch. Never trust the newest email.
If deploy: migration fails — on staging or on production — it shows as a red job on that workflow run. Since a human dispatched it and is watching, this is the one failure that always has an audience.
The iOS app reports its own distress directly to Sentry: dead-letter events (a change that could not be sent to the server and has been set aside) and queue-wedge events (the outbound queue has stopped moving).
No CI job is involved. This is the app on a real user's phone raising its hand.
A job called pipeline_health_check runs inside the database itself four times an hour, writes what it finds into a pipeline_alerts table, and raises alerts in Sentry.
Its most sensitive rule, client_dead_letter_growth, has a threshold of 0 — a single client-side dead letter is enough to trip it. But read the rest of the rule carefully: it looks back over a 6-hour window, and it fires at severity WARN, not CRITICAL.
So the check runs every 15 minutes; this particular alarm does not shout every 15 minutes. Gap 3 is what that cost.
There are two completely independent schedulers in this system. GitHub Actions runs the repository's workflows — 4 jobs on timers. pg_cron runs jobs inside the Supabase database — 12 of them. They share no state, no dashboard, and no alerting path, and a person looking at one will see nothing of the other. Both lists are in box 9.
Three things this machine does not do. They are stated plainly because a diagram that only shows the working parts is worse than no diagram — it manufactures confidence.
There is no xcodebuild workflow at all in the repository. The 2,396 Swift unit tests and every UI test run only on a developer's own Mac, when that developer chooses to run them.
Nothing runs them automatically on a pull request. The entire two-minute battery in box 3 checks the database, the server functions, the config and the project file — and not one line of the app that people actually hold in their hands.
The SQL contract suite sends its questions to the live production database. A new migration's test therefore cannot pass until production already has that migration — so production gets the change before the merge that was supposed to authorise it.
The merge gate is meant to be the last moment where saying no is free. When production is already changed, it isn't. Full detail in the broken link.
Until 2026-07-21, the mirror that copies client-side dead letters up to the server was broken. The health check with a threshold of 0 ran the whole time, faithfully finding nothing, because nothing could reach it. During a four-day total outage of photo capture, it never fired once.
Two things had to be true for that silence, and both were. One: the table it watches could not be written to, so there was no signal. Two: even with a signal, the client_dead_letter_growth rule is set to severity WARN, not CRITICAL, and it evaluates a 6-hour window — so the loudest it can ever be is a warning that describes the last six hours, in a stream full of warnings.
An alarm that cannot receive a signal is indistinguishable from an alarm with nothing to report, and it is the second one everybody assumes. Issue #775 is open to raise the severity and to test-fire it, on the principle that no detector counts as coverage until a test-fire proves it reaches a human.
Every term used above, defined once, in the order you would meet them.
Measured 2026-07-21. Durations are medians of recent runs unless marked otherwise; where a number was not captured, this page says so rather than estimating.