跳到正文
DEV Community· Tomo·· 4 小時前AI 評分55

如何透過合併 Pull Request 部署 Supabase 遷移

Deploy Supabase Migrations by Merging a Pull Request

AI 導讀

Tomo 實測透過合併 GitHub Pull Request,將 Supabase 專案待處理的 SQL 遷移檔按檔名次序套用到資料庫;Free 方案在其測試中費用為 $0。

正文

You can ship database changes by merging a pull request, with no tokens or database passwords copied anywhere. I tried it, broke it on purpose, and then built a small page that answers "did my change land?". Everything is from my run on 2026-10-31. Prices change, so every number is dated.

How this was made: I wrote this with Claude's help: it drafted, and I ran every step, edited the text, and checked the results. The mistakes are mine. This is a personal learning project, not an official guide, and I don't work for Supabase.

The non-technical version (changes as ordered files): https://medium.com/p/b6127c51d676
The code: https://github.com/tomozilla/github-deploy-en-demo

What I did and did not do

  • Did: connected a GitHub repo to a Free project from the dashboard and merged several pull requests. $0. Then a short paid test of preview branches on a throwaway Pro project: about 15 minutes for the project and about 2 minutes for the branch, deleted right after.
  • Did not: run the GitHub Actions + Supabase CLI setup (db lint, supabase test db, approvals). It is a common recipe, and I only read about it. I also did not try private repos, a nested supabase folder, or several PRs at once.
  • Not purely dashboard: I ran supabase init once on my machine to generate config.toml. I did not try writing it by hand. PR 1 was merged in the browser; the later PRs went through the gh CLI. The result is the same.
  • All data is fictional.

One term: a migration here is one SQL file describing one change. The number at the start of the file name decides the order.

1. The repo

The repo needs a supabase folder:

supabase/
  config.toml
  migrations/
    20261031000100_create_notes.sql

The first migration creates a small table:

create table public.notes (
  id bigint generated always as identity primary key,
  body text not null,
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;

create policy "notes are readable by signed-in users"
  on public.notes for select
  to authenticated
  using (true);

2. Connect from the dashboard

Project Settings -> Integrations -> GitHub -> Choose GitHub repository.

My repo was not in the list. Add GitHub Repositories opened GitHub's install page for the Supabase app. It offers "All repositories" or "Only select repositories". I chose the second and picked this one repo. The app asks for read access to code and metadata and read/write on actions, checks, pull requests and workflows.

After installing, my dashboard tab stayed on "Completing GitHub Authorization...". Reopening the dashboard fixed it, and the repo appeared. (I had used a second tab, which may be why.)

The connection form: repository, working directory, production branch and Deploy to production.

  • Working directory: where the supabase folder lives. . for the repo root.
  • Deploy to production: on means a merge to the production branch (main) applies migrations to the production database.
  • On a Free project, Branching is greyed out and needs Pro (section 7).

Press Enable integration. The button turns into "Disable integration".

Right after connecting, Database -> Migrations was empty, although the repo already had migration 1. Files that existed before connecting are not applied until the next merge.

Migrations page right after connecting: nothing applied yet.

3. Merge a pull request

Migration 2 adds a column and two fake rows:

alter table public.notes add column author text not null default 'unknown';

insert into public.notes (body, author) values
  ('First note written from a migration file', 'sample-user-a'),
  ('Second sample note', 'sample-user-b');

On the PR, the Supabase Preview check showed skipped on the Free plan. Skipped means it didn't run, not that it passed.

PR checks: all checks have passed, 1 skipped check.

After the merge, Database -> Migrations listed both files. The integration applies every pending migration in file-name order, including migration 1.

Migrations lists both files.

One oddity: the INSERTED AT column showed times like 00:02:00, exactly the file-name timestamp. I read that as "derived from the file name, not the real apply time". That is my guess, not something I confirmed.

4. Break it on purpose

I merged a migration that adds a column that already exists:

alter table public.notes add column author text;

On the Free plan nothing stopped it on the PR. I created and merged this PR quickly, so I did not look at its checks and can't say what they showed. After the merge, the check on the main commit went red, with the real Postgres error:

The failed check shows: column "author" of relation "notes" already exists.

The message is readable. But the dashboard shows nothing: the overview still says Healthy, and LAST MIGRATION shows the previous change.

Project overview: Healthy, no failure shown.

So on Free, GitHub is the only place you learn about the failure, and only after the merge. Production isn't broken (the change just didn't apply), but nobody sees that unless they open GitHub.

5. One broken file blocks everything after it

I left the broken file in and merged a valid one (an index). It failed with the same old error.

Migrations run in order, so a stuck step 3 blocks steps 4 and 5.

The fix was in the broken file. It had never been applied, so I edited it in place (add column if not exists) and merged. The remaining migrations then applied together.

All four migrations applied.

Editing a file that was already applied is a different story. The usual advice is to fix it with a new file. I didn't test that.

6. What ships and what doesn't

The docs say the integration deploys migrations and the Edge Functions declared in config.toml. I checked both.

Edge Function: I added supabase/functions/applied-migrations/index.ts and this to config.toml:

[functions.applied-migrations]
verify_jwt = false

The function appeared in the dashboard on its own. Calling it without a key returned the function's own error, so verify_jwt = false was honored. (Verify JWT is off only for the demo; see the security notes below.)

The function list shows the function deployed by the integration (URL masked).

A setting: in the same PR I changed [api] max_rows from 1000 to 7. The Data API settings page still said:

Max rows is still 1000.

Dashboard-only settings did not ship, even when I wrote them into config.toml.

7. See the failure before the merge: preview branches (paid)

For a PR-time failure, the integration can create a preview branch per PR: a throwaway copy of the database structure (no data) where the new migrations run. It needs Pro.

On a Pro project the form has branching on: billing note, automatic branching, branch limit.

The form says branch compute is not covered by the spend cap. Because that limit doesn't apply, delete branches when you're done.

On a Pro project I opened a PR with a broken migration (a missing table) and did not merge it. The failure appeared on the PR:

A bot comment on the PR: Migrations failed with the error, All checks have failed.

  • The comment has a task table: Configurations passed, Migrations failed, Seeding and Edge Functions paused.
  • The error text was readable before the merge.
  • The comment also notes that only new migration files are run.

That's the difference: before production, or after.

8. Costs

Item Cost My run
GitHub integration on a Free project $0 (docs: all plans) Measured: nothing billed
Preview branch No fixed fee, usage only; Micro from $0.01344 per hour; not covered by the spend cap (docs, 2026-10-31) About 2 minutes
Extra Micro project (Pro) Same rate, about $10/month About 15 minutes, deleted

At the docs rate my short test was a few cents at most. I did not check an invoice, and I did not check whether partial hours are rounded up. A branch left running adds up.

9. The small app: a deploy board

The integration works, but I wanted an answer to "did my change land?" without hopping between GitHub and the dashboard. So I built one page.

Everything in the repo is applied: all green.

Architecture: the browser asks GitHub for files and the check result, and an Edge Function for the applied list.

The page asks three places and compares:

  1. GitHub: the files in supabase/migrations/.
  2. GitHub: the check on the latest main commit (success or failure, with the real error text).
  3. An Edge Function: what the database records as applied.

A file in the database's list is applied. Otherwise it is waiting, or failed / blocking if the latest check failed.

The Edge Function reads the record table and returns it:

const rows = await sql`
  select version, name from supabase_migrations.schema_migrations order by version`;

It returns 401 if the request has no publishable key. The status logic in board.js turns "one broken file blocks the rest" into the UI:

if (appliedVersions.has(f.version)) return row(f.name, "applied", ...);
// run in order: the first unapplied file is the failure, the rest are waiting
const label = failed ? (firstUnapplied ? "failed / blocking" : "blocked") : ...

Run it: copy app/config.example.js to app/config.js (git-ignored), put in the repo name, the Edge Function URL and the publishable key (never a service_role key), then serve the folder, for example python3 -m http.server 8765. I ran it from a clean copy outside the repo.

With the broken migration and the valid one behind it merged, the board showed:

Failed board: the broken file is failed / blocking, the file behind it is blocked, with GitHub's error text below.

With a wrong key the function returns 401, and the page says it can't trust anything:

Wrong key: the Edge Function answered 401.

If any of the three sources fails to load, the table shows only "No data" plus a yellow banner. A partial table would be false comfort.

Things I got wrong first

  1. I claimed "the database was not changed". My failed banner said so. I had only checked one-statement files. A multi-statement migration that fails halfway might leave partial changes, and I did not test that. The banner now says only what the page can know: how many files are not in the database's applied list.
  2. The error showed my project ID. For a wrong key, the first message contained the project host name. I noticed after taking the screenshot. It now says "The Edge Function answered 401", and I retook the shot.
  3. GitHub's error text came wrapped in a Markdown fence, and the black box showed the backticks. I strip the wrapper.

One thing I avoided from the start: auto-refresh. GitHub allows 60 unauthenticated requests per hour per IP, and each refresh uses 2. A tab left open would eat the quota.

Costs of the board

  • One Edge Function call per page load or refresh. It stayed inside the free allowance in my run.
  • 2 GitHub API calls per refresh. At 60 per hour, about 30 loads and refreshes an hour is the ceiling (my calculation).

Security notes (demo shortcuts)

  • Verify JWT is off on the Edge Function, and it only checks for the project's publishable key. That key is public by design, so this is an entry ticket, not authentication. Safer: keep Verify JWT on and call with a signed-in user's token.
  • Install the GitHub app for selected repositories only.
  • app/config.js is git-ignored. Never put a service_role key there.

If this were for real (untested)

  • Private repos probably need a GitHub token. Keep it server-side (for example in the Edge Function), not in the browser.
  • Notifications: a board you have to open is too late. A failed deploy needs a push.
  • Route B: lint, tests and an approval step with GitHub Actions and the CLI. I didn't run it, so I can't say what it takes. For example, I suspect a lint step needs a local database started first, but that is a guess.

Cleanup

I disabled the integration (Settings -> Integrations -> Disable integration), dropped the table and its migration records, deleted the function and the throwaway Pro project. The GitHub app can be removed at github.com/settings/installations.

What I'd take from this

  • Merging a SQL file applied it, with no tokens copied and no cost on Free.
  • The integration applies all pending files in order. One broken file blocks the rest.
  • On Free, the failure appears only on GitHub, only after the merge.
  • Migrations and declared Edge Functions ship. Dashboard-only settings don't.
  • To see the failure before the merge you need the paid preview branches. Delete them afterwards.

Next topic: what happens to database connections when traffic spikes.

來源:DEV Community · dev.to