Two Supabase projects on one GitHub repository
A monorepo holds two apps, apps/a/ and apps/b/, each with its own
supabase/ directory and its own Supabase project. Both projects connect to
the same GitHub repository through the Supabase GitHub integration, each with
its own working directory, and both have Automatic branching on. This page
covers which pull requests create a preview branch on which project, and
whether a CI job can wait for one project’s preview when both report checks
on the same commit.
Every figure marked measured comes from
experiments/github-branching
in the supabase-lab harness.
Everything ran on 2026-09-23 on a Pro organization in ap-southeast-1,
against two Micro projects and a private throwaway repository, all created
and destroyed the same day. A second session from 03:27 UTC used two fresh
projects and a fresh repository for the file-kind, late-change and merge
questions; its modules (GB04-GB06), artifacts and RUNLOG Runs 5-7 are at
0c90777.
A third session on 2026-09-29 (GB07, project A only) measured where a
preview gets its secrets; its module, RUNLOG Run 8 and artifact are at
6f55c11.
A fourth session on 2026-10-10 (BN01-BN03, two runs each, no git_branch)
measured what a merge applies from a branch created through the Management
API; it ran on a Pro organization in
ap-southeast-1 and is described in the merge section.
Project A was connected with Working directory apps/a, project B with
apps/b. The branching docs the results are compared against were read the
same day.12 Anything not marked
measured is cited, or labelled as a design or as not run.
TL;DR:
- A second project can connect to a repository another project already uses; B’s first attempt did not save and the reconnect did. Each connection keeps its own working directory.
- With “Supabase changes only” off, each project created a preview for all 5
pull requests in the run, a root
README.mdchange included. With it on, a project created a preview only for pull requests that changed files under its own<workdir>/supabase/. Migrations were tested on both projects; aseed.sql,config.toml, function orNOTES.mdchange under A’s<workdir>/supabase/also triggered A. - Both projects posted check-runs named
Supabase Preview, from the same GitHub App, to every pull request head commit read. Each project posts at least oneskippedrun; a project that does not branch posts 2, bothskipped. - The docs’ wait-by-check-name workflow, path-filtered to app A, read project
B’s run both times: B’s
skippedon an A-only pull request, which skipped the migrate job, and B’ssuccesson a pull request touching both apps. - The Management API branch list, which is per project, separated the
outcomes:
MIGRATIONS_FAILEDfor A andFUNCTIONS_DEPLOYEDfor B on the same pull request. The first sample, at 15 s, readFUNCTIONS_DEPLOYEDon both, beforeCREATING_PROJECTappeared at 32 s. - With the setting on, a migration pushed to a pull request that opened without Supabase changes created no preview until the pull request was closed and reopened.
- Merging a pull request that changed only app A started a production action run on both projects, with “Supabase changes only” on. One merge was tried.
- On a branch created through the Management API with no
git_branch,POST /mergeapplied what was written throughPOST /projects/{branch_ref}/database/migrationsand nothing written throughPOST /projects/{branch_ref}/database/query, althoughGET /difflisted both (BN01-BN03, two runs each, 2026-10-10).
- One pull request’s head commit reaches both projects through the one GitHub App.
- Each project decides on its own whether to create a preview branch.
- Both projects post their check-runs to the same commit under the same name, whether they branch or not.
Which setup to pick
Section titled “Which setup to pick”| Goal | Setup | Status |
|---|---|---|
| A pull request that touches only app A creates only A’s preview | ”Supabase changes only” on, on both projects | measured |
| Every pull request gets a preview on both projects | ”Supabase changes only” off | measured; 2 previews per pull request, each billed for its own usage3 |
| A CI job waits for app A’s preview before seeding or testing it | poll project A’s branch list for the pull request’s git branch | branch-list signal measured; the wait loop below was not run in CI |
A CI job waits on the Supabase Preview check by name | do not use with two projects connected | measured: it read the other project’s run |
| Only one project reports on a commit | Automatic branching off on one project; create its branches from CI | not run: whether a connected project with Automatic branching off still posts check-runs is untested |
Preview creation: the working directory and “Supabase changes only”
Section titled “Preview creation: the working directory and “Supabase changes only””The docs define the working directory as “the path from your repository root
to the directory that contains the supabase/ folder”, and the setting as
“only create Supabase branches when Supabase files change”.1
Neither says which files count when two projects share a repository. GB01
opened the same five pull requests once with the setting off on both projects
and once with it on.
| Pull request changes | Off: preview on A | Off: preview on B | On: preview on A | On: preview on B |
|---|---|---|---|---|
README.md at the repository root | yes | yes | no | no |
apps/a/README.md | yes | yes | no | no |
a migration under apps/a/supabase/ | yes | yes | yes | no |
a migration under apps/b/supabase/ | yes | yes | no | yes |
| a migration under each | yes | yes | yes | yes |
With the setting off, the working directory did not limit previews: each
project previewed all 5 pull requests, 10 in all. With it on, each project
previewed only the pull requests that changed a migration under its own
<workdir>/supabase/ (5 shapes, one pull request each); apps/a/README.md is
inside A’s working directory and did not trigger A. These two runs changed
only migrations; a later run tested seed.sql, config.toml, a function and
a non-CLI file.
Closing the pull requests removed every preview within 2 minutes in every run; nothing was left for the harness to delete.
The connection form set Branch limit to 3, and GB00 read limit=3 back on
both projects. The run raised it to 10 on both before opening any pull
request, so the limit could not cap the count.
In that later run, GB04, with the setting on, each pull request changed one
file under apps/a/supabase/. All four kinds triggered a preview on A,
NOTES.md included, though the CLI does not read it:
| File changed | Preview on A | Preview on B |
|---|---|---|
seed.sql (a second insert) | yes | no |
config.toml (a comment line appended) | yes | no |
functions/probe/index.ts (a new function) | yes | no |
NOTES.md (a file the CLI does not read) | yes | no |
On the function pull request, A’s comment marked Edge Functions with a
warning: “Only Functions declared in config.toml will be automatically
deployed to branches”. Whether probe deployed to the preview was not
checked.
With the setting on, a pull request opened with only apps/a/README.md got
no preview on A. A migration pushed to it afterwards still produced none in
240 s, and closing and reopening the pull request produced one at the first
20 s poll. This was one pull request; late seed.sql or config.toml
changes were not tried.
A seed.sql change pushed to a pull request whose preview already exists
did not re-seed it: 240 s after the push the new row was absent from the
preview database. The docs say the same: “The database is only seeded once,
when the preview branch is created. To rerun seeding, delete the preview
branch and recreate it by closing, and reopening your pull
request.”2
The checks on a shared commit
Section titled “The checks on a shared commit”With two projects connected, both projects’ check-runs land on the same commit.
| On the head commit | Measured |
|---|---|
| Check-run name | Supabase Preview on every run, from both projects |
| GitHub App and check suite | supabase for every Supabase Preview run; one check suite on the setting-off commits (RUNLOG snapshot) |
| Runs from a project that branched | 5, once 6: 1 skipped, 1 success (or failure), 3 (once 4) in_progress when observation ended |
| Runs from a project that did not branch (“Supabase changes only” on) | 2, both skipped |
| Field that identifies the project | details_url only, of external_id, details_url, output title and output summary |
external_id | empty on every run |
| Output title | Supabase Preview or empty |
Observation ran 600 s, or 480 s for GB03; the setting-off figures are a
snapshot at 01:59:12 UTC, inside the window. Commits with the wait workflow
also carried that workflow’s own github-actions runs. details_url links
the parent project’s dashboard on the early runs and the preview branch’s own
project on later ones.
GitHub’s check-runs endpoint defaults to filter=latest, “the most recent
check runs”.4 In GB01 with the setting on, that view returned
a single Supabase Preview run, and on 2 of the 5 pull requests, the A-only
and the two-app one, the project that run belonged to changed during the
build. The A-only pull request was sampled every 3 s for the first 150 s. In
that window the name-filtered default view showed two different B skipped
runs in turn, then A’s first in_progress, then A’s success. filter=all
returns every run.
The integration also comments on each pull request, once per connected
project, from supabase[bot]. Each comment starts with a [supa]:<ref>
marker line. On a pull request that changed only apps/a/supabase/, B’s
comment carried B’s own ref and read “This pull request has been ignored for
the connected project … because there are no changes detected in
apps/b/supabase directory.” A’s comment carried its preview branch’s ref
instead of project A’s own ref, with its Deployments and Tasks tables and the
line “Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or
migration files.”
On a one-app pull request the ignored project’s comment is the one carrying
its own ref or the “has been ignored” text. A branching project’s comment
carries its preview’s ref, and only the parent’s branch list (project_ref)
maps that back to the parent. The early check-runs’ details_url carries the
parent ref directly. Comments from two projects that both branched were not
compared.
Waiting for one project’s preview in CI
Section titled “Waiting for one project’s preview in CI”The branching docs show a CI job that waits for the preview with
fountainhead/action-wait-for-check@v1.2.0 and checkName: Supabase Preview, then gates a migrate job on the result being
success.2 The action lists check-runs by name with the
endpoint’s default filter and returns the conclusion of the first completed
one.5 GB02 added that workflow to the repository with a
path filter of apps/a/supabase/**, the migrate job reduced to an echo, and
“Supabase changes only” on for both projects.
| Pull request | Wait step returned | That run belonged to | Migrate job | A’s first success seen at |
|---|---|---|---|---|
migration under apps/a only | skipped | project B | skipped | 108 s |
| migration under each app | success | project B | ran | 108 s |
Times are seconds after the run started creating the pull requests, on a 30 s poll.
On the A-only pull request the wait step read B’s skipped run, started at
02:22:13 UTC. A’s own skipped run had started at 02:22:10 and already
completed, so it was not the latest. A’s success run started at 02:22:58,
and the poll first saw it completed at 108 s. The path filter only decides
which pull requests run app A’s workflow; no B-only pull request was opened
in this run. It cannot keep B’s runs off A’s commits: with “Supabase changes
only” on, B posts skipped runs to every pull request commit it does not
branch for.
The Management API’s branch list is per project. GB03 sampled
GET /v1/projects/{ref}/branches on each parent project, matched on
git_branch, on one pull request that broke A’s migration and left B’s
valid:
| First sample showing the value (s after opening, 15 s polling) | A branch status | B branch status |
|---|---|---|
| 15 | FUNCTIONS_DEPLOYED | FUNCTIONS_DEPLOYED |
| 32 | CREATING_PROJECT | CREATING_PROJECT |
| 65 | RUNNING_MIGRATIONS | RUNNING_MIGRATIONS |
| 81 | MIGRATIONS_FAILED | FUNCTIONS_DEPLOYED |
A loop that exits on FUNCTIONS_DEPLOYED can return at 15 s, before
CREATING_PROJECT at 32 s and so before the preview project exists. The
published OpenAPI document marks status as deprecated, with “List action
runs to get branch status instead”.6 On A at 81 s, the preview
project’s latest action run (GET /v1/projects/{preview_ref}/actions) showed
migrate DEAD with deploy and seed still CREATED; on B every step
was EXITED.
In GB02, 3 action runs carried a check_run_id pointing at that run’s
commits, one per preview project: 1 on the A-only pull request and 2 on the
two-app one. Each pointed at a check-run still in_progress at the end of
the 600 s window, although every step of the action run was EXITED, so it
cannot serve as a wait target. The parent projects’ action runs pointed at no
check-run on those commits.
A wait loop on the branch list. The API calls are the ones the harness made; the loop itself was not run in CI.
# Needs SUPABASE_ACCESS_TOKEN and this app's parent project ref.seen_busy=0for i in $(seq 1 60); do s=$(curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ "https://api.supabase.com/v1/projects/$PROJECT_REF/branches" | jq -r --arg b "$GITHUB_HEAD_REF" '.[] | select(.git_branch == $b) | .status') case "$s" in CREATING_PROJECT|RUNNING_MIGRATIONS|MIGRATIONS_PASSED) seen_busy=1 ;; MIGRATIONS_FAILED|FUNCTIONS_FAILED) [ "$seen_busy" = 1 ] && exit 1 ;; FUNCTIONS_DEPLOYED) [ "$seen_busy" = 1 ] && exit 0 ;; esac sleep 10doneexit 1MIGRATIONS_PASSED and FUNCTIONS_FAILED come from the OpenAPI enum and
were not observed in the run.6 The seen_busy guard skips the
FUNCTIONS_DEPLOYED reading at 15 s. It also means a loop that starts after
the preview has finished never sees a busy state and times out. A job that
can start late could compare the branch’s updated_at with the head commit’s
time instead; the run did not read updated_at, so this is untested.
Merging to the production branch
Section titled “Merging to the production branch”Both projects had Deploy to production on, with main as the production
branch. The artifact does not record that value directly, but the merge
module runs only if both default branch rows read git_branch main, and it
ran. The dashboard form writes that field only when the option is enabled.
The docs say that on a push or merge to it, new migrations are applied and
the Edge Functions and Storage buckets declared in config.toml are
deployed; they do not mention “Supabase changes only” or the working
directory in that section.1 GB06 merged a pull request
with one migration under apps/a/supabase/ and watched both projects for
240 s:
| After the merge | Project A | Project B |
|---|---|---|
| New production action runs | 1 | 1 |
| Steps | clone, pull, health, migrate, seed, deploy EXITED; configure PAUSED | the same |
| The merged migration in the project’s migrations list | yes | no |
supabase app check-runs on the merge commit, at the end of the watch | 3: success + 2 in_progress | 3: success + 2 in_progress |
In the one merge tried, B’s production run reached EXITED on the same six
steps as A’s (clone, pull, health, migrate, seed, deploy) for a
merge that changed nothing under apps/b/, with “Supabase changes only” on.
Whether it changed anything on B was not measured, including whether
deploy redeployed anything (B had no functions). seed reached EXITED on
both projects, but whether it seeded anything was not checked; the docs say
seed files are ignored on production deploys.1
A merge applies the migration history, not the schema difference
Section titled “A merge applies the migration history, not the schema difference”A separate run on a branch created through the Management API with no git
integration at all (medium-serverless MS15, 2026-09-30) found the first
instance.
POST /projects/{ref}/branches {branch_name, persistent: true} answered 201
and the branch read ACTIVE_HEALTHY 2 s later, supabase-postgres-17.6.1.171
at ci_micro. prisma db push (6.19.3) exited 0 in 14859 ms and left a table
with a row on the branch, and GET /branches/{id}/diff answered 200 with
2016 characters of SQL - the pg_net extension, the sequence, the table, its
primary key and index. POST /branches/{id}/merge then answered 201
{"workflow_run_id": "...", "message": "ok"}, and the parent had no such
table 20 s later and still had no pg_net extension about 10 minutes later.
MS15 read this as: the merge is accepted and applies nothing when there are no migration files. BN01-BN03 (2026-10-10, two runs each) measured the mechanism behind that reading and narrow it, below.
The ACTIVE_HEALTHY reading is not a readiness signal for the parent’s
schema. In BN01-BN03 each module created a throwaway parent in a Pro
organization in ap-southeast-1 and one non-persistent branch off it, with
with_data false and no git_branch. POST /projects/{ref}/branches
answered 201 in 0.9 to 1.7 s, and the branch read ACTIVE_HEALTHY 1-2 s
after the create call in all six runs. The parent’s baseline table was absent
from the branch at that moment every time. In run 4 it was first seen at 53 s
(BN01), 44 s (BN02) and 43 s (BN03). The branch workflow’s step list is not a
completion signal either: BN03’s last read in run 4 had the baseline table
present with migrate still CREATED. A GET /diff read inside that window,
once by hand with the default and pgdelta=true variants, contained
drop table "public"."bn_base" for the parent’s own table; the drop was gone
once the table had arrived. Wait for the baseline table itself before diffing or
writing.
Each module then wrote one change set on the branch: a table with an identity
key, enable row level security, a select policy to authenticated,
revoke all from anon and authenticated followed by grant select to
authenticated, an immutable SQL function, and a column plus an index on a
baseline table the parent holds. All writes were accepted in run 4 in all
three modules (7 of 7), and the branch held the complete set. In run 3 BN01
wrote 5 of 7, because the probe started writing before the baseline table
arrived and the column and index failed with 42P01; BN02 and BN03 wrote 7 of
7. The parent held none of the set before the merge in all six runs. The
modules differ in the write route and the step before the merge:
| Module | Written on the branch through | Before the merge |
|---|---|---|
| BN01 | POST /projects/{branch_ref}/database/query | GET /diff in three variants |
| BN02 | the /database/query set and a second set through POST /projects/{branch_ref}/database/migrations, on one branch, one merge | GET /diff in three variants |
| BN03 | POST /projects/{branch_ref}/database/query | PATCH /branches/{ref} {request_review: true}, then GET /diff |
What reached the parent after POST /branches/{ref}/merge, measured 2026-10-10:
| Set | In GET /diff | Branch migration history after the write | On the parent after the merge |
|---|---|---|---|
written through /database/query (BN01, BN03) | every object, in the default, pgdelta=true and pgdelta=false variants | remote_schema only, unchanged | none; polled 246 s (BN01 run 4), 245 s (BN01 run 3), 246 s (BN03 run 4), 258 s (BN03 run 3) |
written through /database/migrations (BN02) | every object | remote_schema, bn_m_change | complete (table, RLS on, policy, SELECT only for authenticated, none for anon, function, column, index), first seen at the 42 s poll (run 4) and 56 s (run 3), with a bn_m_change row added to the parent’s history |
written through /database/query, same branch and same merge as the row above (BN02) | every object | remote_schema, bn_m_change | absent when the poll ended, at 73 s (run 4) and 87 s (run 3) |
MCP execute_sql, then apply_migration, then merge_branch, once by hand | not read | not recorded | execute_sql set absent; apply_migration set present (table, policy, function), history row bn_y_change added |
The merge answered 201 with {workflow_run_id, message: "ok"} in all six
runs, and its workflow steps read clone, deploy, health, migrate,
pull and seed EXITED and configure PAUSED at the end of the window.
PATCH /branches/{ref} {request_review: true} answered 200 and set
review_requested_at on the parent’s branch list, and the merge outcome
was the one in the first row. The default GET /diff text was identical to
?pgdelta=false in all six runs, and ?pgdelta=true differed (1137 characters
against 1430 for the default, BN01 run 4). The published OpenAPI document marks the
endpoint Beta and describes pgdelta as “Use pg-delta instead of Migra for
diffing when true”; it was read 2026-10-10.6 The pg_net
extension appears in every diff variant of every run and was never installed
on the parent after any merge. Reading of the six runs: the merge applied the
rows of the branch’s migration history that the parent lacked, and ignored the
rest of the difference that GET /diff listed. The platform’s merge
implementation was not read.
The same by-hand trial found GET /projects/{branch_ref} answering 404
Project not found while POST /projects/{branch_ref}/database/query answered
201 on the branch ref (one trial).
So a merge is accepted and applies nothing when there is no migration history
row to apply. A change written through the SQL route is in the diff and survives
request_review, and the parent still lacks it. MS15’s 201 and nothing is the same
outcome: after prisma db push the branch history held only the baseline row
(inferred from the matching outcome; the Prisma path was not re-run in BN). For
a db push workflow the promotion is prisma db push against the parent or
prisma migrate diff generating supabase/migrations for the normal path.
The Management API’s migrations route is a third path, and the one BN02
measured; whether supabase/migrations files reach a git-less branch’s
history was not measured. The branch itself works as an isolated staging
database: push, insert and diff all behaved, and the connection ran through
the branch’s Supavisor tenant because db.<ref> is IPv6-only.
Not measured: the dashboard’s own SQL Editor and Table Editor routes, which
reject a PAT, so whether Studio records a migration row for a change made
there is unknown; the dashboard merge-request screen and its review diff;
migration_version on the merge body; persistent branches in BN (MS15’s was
persistent), with_data: true, edge functions, merging twice, a branch whose
history conflicts with the parent’s, and a parent that already has migration
files. Two earlier harness attempts of BN01 were stopped and left no artifact;
one over-ran its background-task limit and one was killed by hand while the
call wrapper retried a 200 body it had flagged as throttled.
Two harness notes from the MS15 run: a create call can time out at a client
default of 30 s while the platform creates the branch anyway, so adopt the
branch on the retry rather than creating a second; and a persistent branch
refuses deletion with 422 Cannot delete persistent branch. until
PATCH {persistent: false} answers 200.
Secrets on a preview branch
Section titled “Secrets on a preview branch”The branching configuration docs, read 2026-09-29, say in their
persistent-branch section that secrets set for one branch “are not
automatically available in other branches”. They also say config.toml
reads secrets through env(NAME), and that for a committed
supabase/.env.preview encrypted with dotenvx the branching executor “will
automatically retrieve and use these values”, once the private key from
.env.keys is set with supabase secrets set.7 They do
not say what a preview gets when an env() reference has no value anywhere,
or when .env.preview was encrypted to a key the project does not hold.
GB07 opened four pull requests under apps/a/supabase/, each adding a probe
function (secretprobe) that reports, per secret name, whether the value is
present and its SHA-256, so no value was recorded:
| Pull request | Branch status | Action run steps | Probe function | Secret on the preview |
|---|---|---|---|---|
PVLAB_GB07_PARENT set on parent A only, not referenced in config.toml | FUNCTIONS_DEPLOYED | all EXITED | HTTP 200 | absent from the preview’s secrets list and from the function’s environment |
dotenvx .env.preview holding PVLAB_GB07_DOTENV, private key set on parent A as DOTENV_PRIVATE_KEY_PREVIEW, mapped through [edge_runtime.secrets] and env() | FUNCTIONS_DEPLOYED | all EXITED | HTTP 200 | present; the function saw the value GB07 set, and the secrets list carried its SHA-256 |
A .env.preview built the same way, holding PVLAB_GB07_WRONGKEY, encrypted to a second keypair whose private key was discarded (parent A never held it) | MIGRATIONS_FAILED | clone DEAD, every other step CREATED | HTTP 404, nothing deployed | none: the run stopped before any deploy, and the preview’s secrets list came back empty |
env(PVLAB_GB07_UNSET) in config.toml, with no value anywhere | FUNCTIONS_DEPLOYED | all EXITED | HTTP 200 | absent from the preview’s secrets list and from the function’s environment |
GET /v1/projects/{ref}/secrets listed value as the SHA-256 of the value
set for both secrets GB07 could check: PVLAB_GB07_PARENT on the parent,
read before any preview, and PVLAB_GB07_DOTENV on the dotenvx preview.
DOTENV_PRIVATE_KEY_PREVIEW itself was absent from the three previews that
returned readings (inherit, dotenvx, unset), so the dotenvx value was
decrypted with the parent’s copy of the key. That is inferred from the key’s
absence on the dotenvx preview and the wrongkey failure; no pull request ran
with the key missing from the parent as well.
The wrong key stopped the run. The integration’s pull request comment, read
by hand after the run and not recorded in the artifact, carried the cause:
failed to parse config: ... 'edge_runtime.secrets[pvlab_gb07_wrongkey]' failed to decrypt secret: cannot decrypt ciphertext: cipher: message authentication failed. The branch status read MIGRATIONS_FAILED,
although no migration ran, so a CI job waiting on status sees a failure but
not its cause. The unset env() reference produced a function deployed
without the secret: neither the branch list (FUNCTIONS_DEPLOYED) nor the
action run (every step EXITED) reported it. That pull request’s check-runs
and bot comment were not read.
What to do about it
Section titled “What to do about it”| Practice | Evidence | Module |
|---|---|---|
| Turn “Supabase changes only” on for every project sharing the repository. | Off, each project previewed all 5 pull requests; on, only those changing its own <workdir>/supabase/. | GB01 |
Keep files that should not trigger a preview outside <workdir>/supabase/. | apps/a/README.md inside A’s working directory did not trigger A with the setting on. | GB01 |
| Raise Branch limit before relying on per-pull-request previews. | GB00 read limit=3 back on both projects as connected. | GB00 |
Do not wait on the Supabase Preview check by name with two projects connected. | The docs’ workflow for app A read B’s run on 2 of 2 pull requests. | GB02 |
Read check-runs with filter=all when inspecting a shared commit. | The default view returned one run per name, where a commit on which both projects branched carried 10 or 11 Supabase Preview runs: 10 on the five setting-off commits (RUNLOG snapshot) and 11 on the setting-on two-app commit. | GB01 |
Wait on your project’s branch list, matched on git_branch, once a non-terminal status is seen. | status separated MIGRATIONS_FAILED from FUNCTIONS_DEPLOYED per project, after a first reading of FUNCTIONS_DEPLOYED at 15 s. | GB03 |
Do not wait on an action run’s check_run_id. | It pointed at a check-run left in_progress after the action’s steps had finished. | GB02 |
| Open a pull request with its Supabase changes, or close and reopen it after adding them. | A late migration created no preview in 240 s; close and reopen created one at the first 20 s poll. | GB05 |
Close and reopen the pull request to re-seed a preview after changing seed.sql. | The new row was absent 240 s after the push; close and reopen is the docs’ remedy2 and was not run. | GB05 |
| Expect a merge that changes one app to start a production run on the other project too. | An A-only merge started a production action run on B and put 3 of B’s check-runs on the merge commit. | GB06 |
Identify the ignored project’s bot comment by its [supa]: ref or “has been ignored” text. | B’s comment carried B’s own ref and named apps/b/supabase. | GB04 |
Map a branching project’s comment to its parent through the branch list’s project_ref. | A’s comment carried its preview’s ref, on one-app pull requests only. | GB04 |
Set each secret a preview needs on it or in an encrypted .env.preview. | Do not expect the parent’s: PVLAB_GB07_PARENT on parent A, not referenced through env(), was absent from the inherit preview’s secrets list and its function’s environment; the dotenvx value reached the preview. Setting a secret on the preview itself was not run. | GB07 |
Keep DOTENV_PRIVATE_KEY_PREVIEW on the parent; re-set it on each re-key. | With the dotenvx private key on parent A, the dotenvx preview’s function saw the value (the key’s role is inferred, see Evidence); a file encrypted to a key parent A never held died at clone. Re-encryption itself was not run. | GB07 |
Read the bot comment, not only the branch status, when a preview fails. | A decryption failure at clone read MIGRATIONS_FAILED (GB07); the wrongkey pull request’s bot comment, read by hand and not in the artifact, named the secret and the error. | GB07 |
| In CI, check each secret a preview needs is on its secrets list by name. | An env() reference with no value reached FUNCTIONS_DEPLOYED with every step EXITED and the secret absent. | GB07 |
| Compare secrets by digest: the secrets list returns SHA-256 values. | Parent A listed PVLAB_GB07_PARENT, and the dotenvx preview listed PVLAB_GB07_DOTENV, as the SHA-256 of the value GB07 set. | GB07 |
| Budget for one billed preview per project per matching pull request. | Each branch is billed for its own usage;3 with the setting off, 5 pull requests made 10 previews. | GB01 |
| Wait for the parent’s baseline table on a git-less branch before diffing or writing. | ACTIVE_HEALTHY 1-2 s after create; the baseline table first appeared at 43-53 s (run 4), and a diff read in between proposed drop table for it (one trial by hand). | BN01 |
Do not read GET /diff as a preview of what POST /merge applies on a git-less branch. | A table, RLS, policy, privilege change, function, column and index written through /database/query were all in the diff and none reached the parent after 245-258 s. | BN01, BN03 |
Generate supabase/migrations before merging; a merge applies history rows only. | The same objects through POST /projects/{branch_ref}/database/migrations merged in 42-56 s with a history row, on the branch and merge where the /database/query set stayed out; MS15 saw 201 and no table 20 s later, no pg_net about 10 minutes later, after prisma db push. Migration files on a git-less branch were not measured. | BN02, medium-serverless MS15d (2026-09-30) |
Free a persistent branch with PATCH {persistent: false} before deleting it. | The first DELETE answered 422 Cannot delete persistent branch.; after the PATCH the DELETE answered 200 and the branch was gone from the list after 1 s. | medium-serverless MS15e (2026-09-30) |
Where the docs are silent
Section titled “Where the docs are silent”| Docs | Runtime measured |
|---|---|
| no mention of connecting more than one project to one repository | two projects connected with distinct working directories, both branching |
| ”Supabase changes only” matches “Supabase files”, directory unstated | each project previewed the pull requests changing a migration under its own <workdir>/supabase/ |
the wait examples filter on supabase/** and wait on Supabase Preview by name | with two projects, the wait read the other project’s run |
| ”Supabase changes only” is described for branch creation; what happens to Supabase changes pushed after opening is unstated | a migration pushed later created no preview until a close and reopen |
| Deploy to production is described per project; multiple projects on one repository are not mentioned | both connected projects ran production on a merge that changed only one app |
secrets are branch-specific and read through env(); an env() with no value is not described | the preview reached FUNCTIONS_DEPLOYED, every step EXITED, with the secret absent |
the executor “will automatically retrieve and use” dotenvx .env.preview values; a key mismatch is not described | the run died at clone and the branch status read MIGRATIONS_FAILED |
| blog, 2026-05-04: “Every schema change you make in the SQL Editor or Table Editor is tracked.”8 | untested for those editors: they reject a PAT. Through the Management API, a change written with /database/query left the branch history at remote_schema and stayed out of the merge |
| blog: “When you merge a branch, pg-delta compares the two schemas and generates the correct migration statements.”8 | not observed: the dashboard review diff was not opened. GET /diff default equalled pgdelta=false, which the OpenAPI document calls the Migra path, in 6 of 6 runs, and POST /merge applied history rows only |
the MCP merge_branch tool is described as merging “migrations and edge functions from a development branch to production” (tool description as served 2026-10-10) | consistent with the result: the apply_migration set merged and the execute_sql set did not, once by hand |
None of these has been filed with Supabase as of 2026-09-29. The three rows added from BN01-BN03 were not filed either as of 2026-10-10 (filed: no). The blog’s “tracked” claim is not marked as wrong here: only the Management API routes were tested, and the dashboard routes could behave differently.
Reading the numbers
Section titled “Reading the numbers”- The runs used two pairs of projects, one pair per session, in one
organization and one region, on one day. The per-commit counts could change
without notice: 5 runs from a project that branched and 2 from one that did
not. B posted 6 on the two-app pull request in GB01 with the setting on,
and 5 in GB02 and GB03. On every commit read, every
supabaseapp run was namedSupabase Previewand every connected project posted to it. - Preview first-seen times in the GB01 runs are 30 s poll samples; creation latency was not measured. In GB04 the previews were first seen at 56 s on a 30 s poll, counted from when the run started creating the pull requests.
- The late-change, re-seed and merge results are one pull request each.
- GB01 with the setting off used an earlier revision of the scoping module
that read only the default check-runs view. Its check-run figures come from
a
filter=allsnapshot taken during that run and recorded in the RUNLOG, not from its published artifact. - The wait-by-name result depends on when the action’s first poll lands relative to the check-runs. GB02 recorded one pull request per shape on GitHub-hosted runners; a different start time can pick a different project’s run.
- A single project on its own was not tested. In GB01 with the setting on,
on the A-only pull request, A’s own
skippedrun came 29 s before its next run, and during those 29 s the name-filtered view showed only B’sskippedruns. The run therefore cannot say whether a wait step would ever read a lone project’s ownskipped. - The Vercel integration was not part of the run.
- The merge-without-migrations result in MS15 is from a different experiment
and a different rig: one persistent branch created through the Management
API with no git integration, a schema pushed by
prisma db push, one merge, in one organization. BN01-BN03 repeated the shape on non-persistent branches with the SQL and migrations routes, two runs each, on 2026-10-10, and closed the open question for the Management API migrations route only: BN02 watched a merge of a schema written throughPOST /projects/{branch_ref}/database/migrations, and it applied. No one has watched a merge of adb pushschema that DID carry migration files, or of migration files pushed any other way. - BN01-BN03 ran as three modules in parallel, each with its own parent, so six
runs make two per module. The parent was polled for 245-258 s after the
merge for the
/database/queryset; a later arrival is not excluded by that window, though MS15 read the parent about 10 minutes after. The by-hand observations (GET /projects/{branch_ref}404, thedrop tableread, the MCP pair) are one trial each. - GB07 ran one pull request per secrets shape, on project A only. Not run:
dotenvx with the key absent from the parent as well, the docs’
encrypted:literal directly inconfig.toml, a parent secret referenced throughenv(),[remotes.<name>]blocks on persistent branches,[db.vault]secrets, and asupabase secrets seton the parent after a preview is already open.
Evidence, by module
Section titled “Evidence, by module”Measured claims map to module ids in experiments/github-branching; the run record is its RUNLOG.md, and the redacted artifacts with their measurement tables are under out/2026-09-23/. The second session’s RUNLOG entries and artifacts are at RUNLOG.md and out/2026-09-23/ as of 0c90777. The third session’s (GB07) are at RUNLOG.md and out/2026-09-29/ as of 6f55c11. The MS15 rows come from a different experiment on a different rig, with no git integration: medium-serverless, artifact under out/2026-09-30/. The BN rows (2026-10-10) come from experiments/branching-nogit, its RUNLOG.md and the redacted artifacts under out/2026-10-10/.
| Claim | Status | How it was checked |
|---|---|---|
Two projects connected to one repository with working directories apps/a and apps/b; Branch limit 3 on both as connected | measured | GB00 - connection settings read back from GET /v2/organizations/{slug}/integrations/github/connections, in the published v2 OpenAPI document9 |
| Setting off: 5 of 5 pull requests previewed on both projects | measured | GB01 - five pull requests opened together, both branch lists polled every 30 s for 600 s |
Setting on: previews only for changes under the project’s <workdir>/supabase/ | measured | GB01 - same five shapes, second run |
Runs per project on a commit, skipped runs from a project that did not branch, details_url as the only identifying field | measured | GB01 - check-runs read with filter=all, attributed by the refs in details_url |
Default view showed B’s skipped before A’s first in_progress on an A-only pull request | measured | GB01 - name-filtered default view sampled every 3 s for 150 s |
Docs workflow for app A returned B’s skipped and B’s success | measured | GB02 - the docs’ workflow in the repository, the check-run id read from the action’s log |
An action run’s check_run_id points at a run left in_progress | measured | GB02 - action runs on each preview project matched against the commit’s check-runs |
Branch status per project on a failing and a passing preview; FUNCTIONS_DEPLOYED at 15 s before CREATING_PROJECT | measured | GB03 - branch list and action runs sampled every 15 s for 480 s |
Four kinds of file under A’s <workdir>/supabase/ trigger with the setting on; the bot comments once per project, the ignored one naming its directory | measured | GB04 - four pull requests under apps/a/supabase/, both branch lists polled every 30 s for 600 s, comments read before cleanup |
A migration pushed after opening needs a close and reopen; a pushed seed.sql change does not re-seed | measured | GB05 - one pull request per question, 20 s poll, 240 s phases, the seed row read on the preview with SQL |
| An A-only merge started a production action run on both projects | measured | GB06 - squash merge, both parents’ action runs and migrations lists read for 240 s |
A parent secret not referenced in config.toml absent from the preview; dotenvx value present; wrong key dies at clone with MIGRATIONS_FAILED; unset env() deploys with the secret absent; the list returns SHA-256 digests (2 of 2 checked) | measured | GB07 - four pull requests under apps/a/supabase/, each adding a probe function; per name, the preview’s secrets list and the function’s environment compared by SHA-256, after a parent-side control |
Decryption used the parent’s copy of DOTENV_PRIVATE_KEY_PREVIEW | inferred | GB07 - the key was absent from the three previews that returned readings; no shape ran without it on the parent |
A branch with no git integration serves as isolated staging for a prisma db push schema, and diffs against the parent | measured | MS15a-MS15c (2026-09-30) - create 201, ACTIVE_HEALTHY at 2 s, push exit 0 in 14859 ms, GET /branches/{id}/diff 200 with 2016 chars |
| A merge with no migration files applies nothing to the parent | measured | MS15d, MS15d2 (2026-09-30) - the merge answered 201 and the parent’s pushed table and the branch’s pg_net extension were both absent (20 s and about 10 minutes after) |
A git-less branch reads ACTIVE_HEALTHY 1-2 s after create, before the parent’s baseline table is on it (43-53 s, run 4) | measured | BN01-BN03 - six runs, branch and workflow polled after create; the drop table diff in that window is one trial by hand |
/database/query writes (table, RLS, policy, privileges, function, column, index) are in GET /diff in all three variants and absent from the parent after POST /merge | measured | BN01, BN03 - two runs each; 7 of 7 writes in run 4 and in BN03 run 3, 5 of 7 in BN01 run 3; parent polled 245-258 s |
The same objects through /database/migrations merge with a history row | measured | BN02 - one branch, both sets, one merge; first seen at 42 s (run 4) and 56 s (run 3); the /database/query set absent at 73 s and 87 s |
request_review: true first changes nothing | measured | BN03 - PATCH 200, review_requested_at set, merge outcome as BN01 |
execute_sql set stays out, apply_migration set merges (MCP) | measured | by hand, one trial; not a harness module |
The branch history of a prisma db push branch held only the baseline row | inferred | from the matching outcome of MS15d and BN01; the Prisma path was not re-run |
| The Dashboard SQL Editor and Table Editor routes record a migration row | not run | they reject a PAT, so the blog’s “tracked” is untested for them |
A persistent branch refuses DELETE until persistent: false | measured | MS15e (2026-09-30) - 422 Cannot delete persistent branch., then PATCH 200, then DELETE 200, gone after 1 s |
| The wait loop above | design | not run in CI |
| Automatic branching off on one project, its branches created from CI | not run | not tested whether such a project still posts check-runs |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| GB00 | github-branching | gb00-control.ts | out/2026-09-23 |
| GB01 | github-branching | gb01-pr-scoping.ts | out/2026-09-23 |
| GB02 | github-branching | gb02-wait-by-name.ts | out/2026-09-23 |
| GB03 | github-branching | gb03-per-project-signal.ts | out/2026-09-23 |
| GB04 | github-branching | gb04-supabase-file-kinds.ts | out/2026-09-23 |
| GB05 | github-branching | gb05-late-changes.ts | out/2026-09-23 |
| GB06 | github-branching | gb06-merge-fanout.ts | out/2026-09-23 |
| GB07 | github-branching | gb07-branch-secrets.ts | out/2026-09-29 |
| BN01 | branching-nogit | bn01-sql-path.ts | out/2026-10-10 |
| BN02 | branching-nogit | bn02-migration-path.ts | out/2026-10-10 |
| BN03 | branching-nogit | bn03-merge-request.ts | out/2026-10-10 |
| MS15 | medium-serverless | ms15-branch-prisma.ts | out/2026-09-30 |
Related docs
Section titled “Related docs”- Supabase preview-branch compute sizing - what each preview costs and how to size it, including creating branches from CI.
- Detach a Supabase branch from its git branch - stopping a persistent branch’s redeploys without deleting it.
References
Section titled “References”-
Supabase, “GitHub integration,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/github-integration ↩ ↩2 ↩3 ↩4
-
Supabase, “Working with branches,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/working-with-branches ↩ ↩2 ↩3 ↩4
-
Supabase, “Manage Branching usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/branching ↩ ↩2
-
GitHub, “REST API endpoints for check runs,” GitHub Docs. https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference ↩
-
fountainhead, “src/poll.ts at v1.2.0,” action-wait-for-check. https://github.com/fountainhead/action-wait-for-check/blob/v1.2.0/src/poll.ts ↩
-
Supabase, “Management API OpenAPI document (BranchResponse_Output.status),” api.supabase.com. https://api.supabase.com/api/v1-json ↩ ↩2 ↩3
-
Supabase, “Configuration,” Supabase Docs. https://supabase.com/docs/guides/deployment/branching/configuration ↩
-
Supabase, “Branching Without Git Is Now The Default,” Supabase Blog, 2026-05-04. https://supabase.com/blog/branching-without-git-is-now-the-default ↩ ↩2
-
Supabase, “Management API v2 OpenAPI document,” api.supabase.com. https://api.supabase.com/api/v2-json ↩