From 9e73d556d0498878614d899101287e3df1443e3d Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:05:11 +0200 Subject: [PATCH] feat(self-hosted): add update script (#47851) --- docker/.gitignore | 6 + docker/README.md | 17 +- docker/tests/test-update.sh | 346 +++++++++++++ docker/tests/test-upgrades-manifest.sh | 90 ++++ docker/update.sh | 666 +++++++++++++++++++++++++ docker/upgrades.json | 30 ++ 6 files changed, 1147 insertions(+), 8 deletions(-) create mode 100755 docker/tests/test-update.sh create mode 100755 docker/tests/test-upgrades-manifest.sh create mode 100755 docker/update.sh create mode 100644 docker/upgrades.json diff --git a/docker/.gitignore b/docker/.gitignore index 1f565391aff..bed55270caa 100644 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -1,6 +1,12 @@ volumes/db/data volumes/storage +volumes/snippets +volumes/functions/** +!volumes/functions/main/ +volumes/functions/main/** +!volumes/functions/main/index.ts .env test.http docker-compose.override.yml .supabase-version +backups \ No newline at end of file diff --git a/docker/README.md b/docker/README.md index 56ebaf07cb0..9d710607c9a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -45,19 +45,20 @@ This Docker Compose configuration includes the following services: - **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference - **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration - **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables +- **[Update your deployment](https://supabase.com/docs/guides/self-hosting/updating)** - Update an existing deployment with `update.sh` ## Updates -To update your self-hosted Supabase instance: +Back up your database, then: -1. Review [CHANGELOG.md](./CHANGELOG.md) for breaking changes -2. Check [versions.md](./versions.md) for new image versions -3. Update `docker-compose.yml` if there are configuration changes -4. Pull the latest images: `docker compose pull` -5. Stop services: `docker compose down` -6. Start services with new configuration: `docker compose up -d` +```sh +sh update.sh --dry-run # optional preview +sh update.sh +sh run.sh pull && sh run.sh recreate +``` -**Note:** Consider to always backup your database before updating. +See the **[update guide](https://supabase.com/docs/guides/self-hosting/updating)** for conflicts, +breaking changes, pinning a release, and older installs without `.supabase-version`. ## Community & Support diff --git a/docker/tests/test-update.sh b/docker/tests/test-update.sh new file mode 100755 index 00000000000..1165390ee74 --- /dev/null +++ b/docker/tests/test-update.sh @@ -0,0 +1,346 @@ +#!/bin/sh +# +# Hermetic test for update.sh (the self-hosted in-place update script). +# +# Builds a tiny synthetic "upstream" git repo with two tagged releases +# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key), +# where the target ships a breaking-change manifest with entries at the base and +# inside the window. It then simulates a configured deployment based on v0.9.0 +# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting: +# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not +# ones the user commented out), applies clean merges, reports real conflicts, +# honors .gitignore for user-owned paths, surfaces only the in-window gate +# entries (base-version entry excluded), and advances the stamp ONLY on a clean +# apply. Also covers the clean-apply path, default-target resolution, --from, +# --dry-run, the missing-stamp report-only path, and a malformed manifest being +# refused. +# +# Usage: +# sh tests/test-update.sh # run from the docker/ directory +# + +set -eu + +# Isolate from the developer's global/system git config (gpgsign, hooksPath, +# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's +# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so +# the update.sh subprocess inherits them too. +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +DOCKER_DIR=$(dirname "$SCRIPT_DIR") +UPDATE_SH="$DOCKER_DIR/update.sh" + +[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; } + +WORK=$(mktemp -d) +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT INT TERM + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; } +bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; } + +assert_file_contains() { # + if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi +} +assert_file_missing_pattern() { # + if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi +} +assert_line() { # + if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi +} +assert_no_line() { # + if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi +} +assert_path_exists() { # + if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi +} +assert_path_absent() { # + if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi +} + +# portable in-place sed (BSD + GNU): sedi +sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; } + +# --- 1. Build the synthetic upstream repo ----------------------------------- + +SRC="$WORK/upstream" +mkdir -p "$SRC/docker/volumes/api" +cd "$SRC" +git init -q +git config user.email t@t.t +git config user.name t + +cat > docker/docker-compose.yml <<'EOF' +services: + studio: + image: supabase/studio:OLD + db: + image: supabase/postgres:15 +EOF +cat > docker/.env.example <<'EOF' +POSTGRES_PASSWORD=changeme +JWT_SECRET=changeme +KEEP_ME=base-default +EOF +printf 'base kong\n' > docker/volumes/api/kong.yml +printf 'remove me\n' > docker/old-only.txt +cp "$DOCKER_DIR/.gitignore" docker/.gitignore +mkdir -p docker/volumes/functions/main +printf 'base main\n' > docker/volumes/functions/main/index.ts +git add -A && git commit -qm base && git tag self-hosted/v0.9.0 + +# target commit +cat > docker/docker-compose.yml <<'EOF' +services: + studio: + image: supabase/studio:NEW + db: + image: supabase/postgres:17 +EOF +cat > docker/.env.example <<'EOF' +POSTGRES_PASSWORD=changeme +JWT_SECRET=changeme +KEEP_ME=base-default +NEW_KEY=new-default +EOF +printf 'brand new\n' > docker/new-only.txt +printf 'target main\n' > docker/volumes/functions/main/index.ts +mkdir -p docker/volumes/functions/hello +printf 'target hello\n' > docker/volumes/functions/hello/index.ts +# A gitignored sample under volumes/snippets shipped by upstream: must NOT +# overwrite the user's file of the same name (exercises is_excluded directly). +mkdir -p docker/volumes/snippets +printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql +# Manifest with entries at/below and inside the window: +# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]). +# 1.0.0 inside -> surfaces (carries requires+gate). +# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after), +# guarding the set -e regression where a requires-less final entry +# aborted the script. Do NOT add a version key after 1.1.0. +cat > docker/upgrades.json <<'EOF' +{ + "0.9.0": { + "breaking": true + }, + "1.0.0": { + "breaking": true, + "gate": "utils/demo-migrate.sh", + "migration_guide_url": "https://example.test/guide", + "requires": ["Run the demo migration first."] + }, + "1.1.0": { + "breaking": true + } +} +EOF +git rm -q docker/old-only.txt +git add -A +git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql +# Release tag for the target / default-target (latest self-hosted/v*) path. +git commit -qm target && git tag self-hosted/v1.1.0 + +# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must +# refuse (die) rather than silently skip the gate. Named so latest_release_tag +# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0. +printf '["valid JSON, but not an object"]\n' > docker/upgrades.json +git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest + +# --- helper: lay down a deployment based on v0.9.0 -------------------------- +# make_deploy [conflict] - pass "conflict" to pin the studio image so the +# merge produces a real conflict; omit for a clean apply. + +make_deploy() { # [conflict] + d="$1" + _mode="${2:-clean}" + mkdir -p "$d" + git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1 + cp "$UPDATE_SH" "$d/update.sh" + # configured .env: real secret, an extra user key, and KEEP_ME commented out + # on purpose (must NOT be re-added by the .env key-union). + cp "$d/.env.example" "$d/.env" + sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env" + sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env" + printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env" + # user-owned override (must never be touched) + printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml" + # data dirs with sentinels (must never be touched) + mkdir -p "$d/volumes/db/data" "$d/volumes/storage" + printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt" + printf 'OBJ\n' > "$d/volumes/storage/keep.txt" + # user adds a line to kong (upstream unchanged -> clean merge, must survive) + printf 'user added line\n' >> "$d/volumes/api/kong.yml" + # user-owned paths per .gitignore (must not be touched by the merge) + mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn" + printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql" + printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts" + # user file at the SAME path as the vendor snippet shipped at target: + # is_excluded must skip it so the user's content survives. + printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql" + # legacy sample fn in snapshot at target but gitignored - must not overwrite + mkdir -p "$d/volumes/functions/hello" + printf 'user hello\n' > "$d/volumes/functions/hello/index.ts" + # version stamp pointing at the base (ref only; update.sh derives the rest) + printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version" + if [ "$_mode" = "conflict" ]; then + # user pins the studio image (same line upstream changes -> conflict) + sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml" + fi +} + +echo "" +echo "=== update.sh: apply path (with a conflict) ===" + +DEPLOY="$WORK/deploy" +make_deploy "$DEPLOY" conflict +cd "$DEPLOY" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$? + +sed 's/^/ | /' "$WORK/apply.log" + +if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi +assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved" +assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended" +assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept" +assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented" +assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented" +assert_file_contains "docker-compose.override.yml" "my override" "override untouched" +assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched" +assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched" +assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written" +assert_file_contains "docker-compose.yml" "=======" "conflict separator written" +assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written" +assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict" +assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict" +assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)" +assert_path_exists "new-only.txt" "new upstream file added" +assert_path_exists "old-only.txt" "removed-upstream file left in place" +assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict" +assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary" +assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced" +assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)" +assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)" +assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced" +assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced" +assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced" +assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown" +assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report" +if ls backups/*.tgz >/dev/null 2>&1; then + ok "backup archive created" + for _bk in backups/*.tgz; do break; done + tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true + assert_file_contains "$WORK/bk.list" ".env" "backup includes .env" + assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir" + assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir" + assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir" +else + bad "no backup archive" +fi +assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched" +# seed.sql and hello/index.ts are the only files that are BOTH shipped in the +# target snapshot AND gitignored, so they are the real is_excluded coverage. +# Assert the user's content survives AND no vendor content / conflict markers +# leaked in - i.e. the file was skipped, not merged/conflicted. +assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept" +assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content" +assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)" +assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched" +assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated" +assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept" +assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content" +assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)" + +echo "" +echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ===" + +CLEAN="$WORK/clean" +make_deploy "$CLEAN" +cd "$CLEAN" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$? +if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi +assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced" +assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply" +assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)" +assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)" +assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)" + +echo "" +echo "=== update.sh: default target resolves to latest self-hosted/v* tag ===" + +TAGD="$WORK/tagdefault" +make_deploy "$TAGD" +cd "$TAGD" +SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true +assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag" +assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target" + +echo "" +echo "=== update.sh: --from supplies the base when the stamp is missing ===" + +FROMD="$WORK/fromd" +make_deploy "$FROMD" +cd "$FROMD" +rm -f .supabase-version +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$? +assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)" +assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update" +assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp" + +echo "" +echo "=== update.sh: --dry-run writes nothing ===" + +DRYD="$WORK/dry" +make_deploy "$DRYD" conflict +cd "$DRYD" +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true +assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key" +assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged" +assert_path_absent "backups" "dry-run took no backup" +assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output" + +echo "" +echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ===" + +MISS="$WORK/miss" +make_deploy "$MISS" +cd "$MISS" +rm -f .supabase-version +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$? +if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi +assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced" +assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env" +assert_path_absent "backups" "report-only took no backup" +assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate" +assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release" +assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry" + +echo "" +echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ===" + +BADM="$WORK/badmanifest" +make_deploy "$BADM" +cd "$BADM" +rc=0 +SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$? +if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi +assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced" +assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched" +assert_path_absent "backups" "malformed manifest: no backup taken" +assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced" + +# --- summary ----------------------------------------------------------------- + +echo "" +echo "=== Result: $PASS passed, $FAIL failed ===" +[ "$FAIL" = "0" ] || exit 1 diff --git a/docker/tests/test-upgrades-manifest.sh b/docker/tests/test-upgrades-manifest.sh new file mode 100755 index 00000000000..4fa6a3e2e67 --- /dev/null +++ b/docker/tests/test-upgrades-manifest.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# +# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq. +# +# - json: upgrades.json is valid JSON +# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the +# optional "_schema" documentation block +# - schema: each version-keyed entry has only known fields, with valid types +# (an unknown/misspelled key like "breakng" would silently disarm +# its gate, so it is rejected here) +# - gate: any non-null "gate" points at a script that exists in the repo +# +# The manifest is the source of truth for gating; the CHANGELOG is display only, +# so this test deliberately does NOT cross-check the two. Requires jq (already a +# runtime dependency of update.sh); no yq, no generation step. +# +# Usage: +# sh tests/test-upgrades-manifest.sh # run from the docker/ directory +# +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +DOCKER_DIR=$(dirname "$SCRIPT_DIR") +cd "$DOCKER_DIR" + +JSON=upgrades.json + +command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; } +[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; } + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT INT TERM + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; } +bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; } + +echo "" +echo "=== upgrades.json is valid JSON ===" +if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then + ok "upgrades.json parses" +else + bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)" + echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1 +fi + +echo "" +echo "=== top-level keys are versions (or _schema) ===" +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in + _schema) ok "doc block '_schema' present" ;; + [0-9]*.[0-9]*) ok "version key $k" ;; + *) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;; + esac +done + +echo "" +echo "=== version-keyed entries have only known fields, with valid types ===" +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + errs=$(jq -r --arg k "$k" '.[$k] as $e + | (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown + | [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end), + (if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end), + (if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end), + (if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end), + (if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end) + ] | join("; ")' "$JSON") + if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi +done + +echo "" +echo "=== gate scripts referenced by entries exist ===" +checked=0 +for k in $(jq -r 'keys[]' "$JSON"); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON") + [ -n "$gate" ] || continue + checked=$((checked+1)) + if [ -f "$gate" ]; then + ok "gate for $k exists: $gate" + else + bad "gate for $k missing: $gate" + fi +done +[ "$checked" = 0 ] && echo " (no entries reference a gate script)" + +echo "" +echo "=== Result: $PASS passed, $FAIL failed ===" +[ "$FAIL" = 0 ] || exit 1 diff --git a/docker/update.sh b/docker/update.sh new file mode 100755 index 00000000000..b23151d34c2 --- /dev/null +++ b/docker/update.sh @@ -0,0 +1,666 @@ +#!/bin/sh +# +# Update an existing self-hosted Supabase deployment in place. +# +# The deployment directory mixes vendor-owned files (docker-compose.yml, the +# override files, volumes/*, scripts, .env.example) with user-owned state +# (.env, docker-compose.override.yml, volumes/db/data, volumes/storage, etc.). +# This script pulls a newer version of the Supabase files on top of yours +# using a 3-way merge against the version you started from, so local edits +# survive and genuine conflicts are surfaced rather than silently overwritten. +# +# The version you started from is recorded in .supabase-version (written by +# setup.sh). If it is missing, pass --from or follow the printed guidance. +# +# What it never touches: .env values you set, docker-compose.override.yml, and +# the data directories (volumes/db/data, volumes/storage, etc.). New keys from +# .env.example are appended to your .env; existing values are kept as-is. +# +# By default it updates to the latest self-hosted/v* release tag (or 'master' +# until the first tag exists). Pass --to to pin a specific tag/branch. +# +# Usage: +# sh update.sh # update to the latest release tag +# sh update.sh --dry-run # show what would change, write nothing +# sh update.sh --to # update to a specific tag/branch +# sh update.sh --from # base to merge from (if no .supabase-version) +# sh update.sh --yes # don't prompt, even on breaking changes +# +# Env: +# SUPABASE_REPO_URL Override the upstream repo (default: github supabase/supabase) +# +# Documentation: https://supabase.com/docs/guides/self-hosting/updating +# + +set -e + +cd "$(dirname "$0")" + +# Pipeline (see main at the bottom): +# resolve refs -> fetch base+target snapshots -> [report-only exit] +# -> build manifest gate -> confirm_gate (before any writes) +# -> backup → merge vendor files + .env keys -> summary → stamp +# +# Three trees for every vendor file path: +# - base - upstream at BASE_REF (.supabase-version ref=, or --from) +# - target - upstream at TARGET_REF (--to, or latest self-hosted/v* tag) +# - user's - the deployment directory (cwd); _not_ a git checkout +# +# Git is only used to fetch snapshots (fetch_snapshot) and to run git merge-file. +# +# Breaking-change gate (upgrades.json on the target snapshot): +# - Keyed by version (e.g. "0.7.0"); window = entries in (BASE_VER, TARGET_VER], +# where the bounds are the base/target refs reduced to bare semver +# (self-hosted/vX.Y.Z -> X.Y.Z) and compared with sort -V. +# - If a bound is not a release tag (a commit SHA, or "master"), that side of +# the window is left open and all applicable entries are shown with a warning. +# - Prompt when an entry has breaking:true or a gate script; runs before +# backup/merge so abort leaves the deployment untouched. +# - CHANGELOG.md is never parsed (update.sh only points users at it); routine +# "requires compose update" items are applied by the merge, not listed here. +# +# User-owned paths skipped during merge are defined in .gitignore (loaded from the +# target snapshot). git check-ignore --no-index applies negation rules (e.g. +# volumes/functions/** ignored except volumes/functions/main/index.ts). +# .git/ is excluded here only - never listed in .gitignore. +# +# .env is never 3-way merged: append missing keys from .env.example only. + +# --- globals (set during main) ----------------------------------------------- + +REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}" +STAMP_FILE=".supabase-version" +DRY_RUN=0 +ASSUME_YES=0 +TO_REF="" +FROM_REF="" + +TARGET_REF="" +BASE_REF="" +BASE_VER="" +TARGET_VER="" +REPORT_ONLY=0 + +TMP_ROOT="" +TARGET_DIR="" +BASE_DIR="" +REPORT="" +ENV_ADDED="" +ENV_REMOVED="" +GATE_REPORT="" +GATE_REQUIRED=0 +IGNORE_FILE="" +IGNORE_GIT_DIR="" + +# --- logging ----------------------------------------------------------------- + +log() { printf "===> %s\n" "$*"; } +warn() { printf "WARNING: %s\n" "$*" >&2; } +die() { printf "ERROR: %s\n" "$*" >&2; exit 1; } + +print_help() { + awk 'NR==1 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0" +} + +# --- small helpers ----------------------------------------------------------- + +# load_ignore_file - vendor/user split from the target snapshot's .gitignore. +# Uses a throwaway git dir so check-ignore works on deployment trees that are +# not git repos (and on Apple Git, which rejects --git-dir=/dev/null). +load_ignore_file() { + if [ -f "$TARGET_DIR/.gitignore" ]; then + IGNORE_FILE="$TARGET_DIR/.gitignore" + elif [ -f .gitignore ]; then + IGNORE_FILE="$(pwd)/.gitignore" + else + IGNORE_FILE="" + warn "No .gitignore found; only .git paths are excluded from the merge." + return 0 + fi + case "$IGNORE_FILE" in + /*) ;; + *) IGNORE_FILE="$(cd "$(dirname "$IGNORE_FILE")" && pwd)/$(basename "$IGNORE_FILE")" ;; + esac + IGNORE_GIT_DIR="$TMP_ROOT/ignore-git" + git init -q "$IGNORE_GIT_DIR" +} + +# is_excluded - true when the path is user-owned and must not merge. +is_excluded() { + case "$1" in + .git|.git/*) return 0 ;; + esac + [ -n "$IGNORE_FILE" ] || return 1 + git -C "$IGNORE_GIT_DIR" -c "core.excludesfile=$IGNORE_FILE" \ + check-ignore -q --no-index "$1" 2>/dev/null +} + +read_stamp_ref() { + [ -f "$STAMP_FILE" ] || return 1 + val=$(grep -E '^ref=' "$STAMP_FILE" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d "\r\"' ") + if [ -z "$val" ]; then + val=$(grep -vE '^[[:space:]]*#' "$STAMP_FILE" 2>/dev/null \ + | grep -vE '^[[:space:]]*$' | head -n1 | tr -d "\r\"' ") + fi + [ -n "$val" ] && printf '%s' "$val" +} + +# env_has_key - true if the key appears as KEY=, even commented +# (e.g. "#GOOGLE_ENABLED="), so we never re-add a key the user disabled on purpose. +env_has_key() { + grep -qE "^[[:space:]]*#?[[:space:]]*$1=" "$2" 2>/dev/null +} + +record() { printf '%s:%s\n' "$1" "$2" >> "$REPORT"; } + +count_status() { grep -cE "^$1:" "$REPORT" 2>/dev/null || true; } + +list_status() { + grep -E "^$1:" "$REPORT" 2>/dev/null | cut -d: -f2- | sed 's/^/ /' +} + +# --- upstream snapshots ------------------------------------------------------ + +_sparse_init() { + git -C "$1" init -q + git -C "$1" remote add origin "$REPO_URL" + git -C "$1" config core.sparseCheckout true + git -C "$1" sparse-checkout init --cone >/dev/null 2>&1 + git -C "$1" sparse-checkout set docker >/dev/null 2>&1 +} + +# fetch_snapshot +# Materializes ./docker at into via a shallow fetch. Also the +# seam an artifact source (tarball + sha256) would slot into later. +fetch_snapshot() { + _ref="$1" + _dest="$2" + _work=$(mktemp -d "$TMP_ROOT/fetch.XXXXXX") + if _sparse_init "$_work" \ + && git -C "$_work" fetch --depth=1 -q origin "$_ref" 2>/dev/null \ + && git -C "$_work" checkout -q FETCH_HEAD 2>/dev/null \ + && [ -d "$_work/docker" ]; then + mkdir -p "$_dest" + cp -rf "$_work/docker/." "$_dest/" + rm -rf "$_work" + else + rm -rf "$_work" + return 1 + fi +} + +latest_release_tag() { + git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \ + | sed 's#^.*refs/tags/##' \ + | grep -E '^self-hosted/v[0-9]' \ + | sort -V | tail -n1 +} + +list_files() { + ( cd "$1" && find . -type f | sed 's|^\./||' | grep -vE '^\.git(/|$)' | sort ) +} + +# normalize_version - reduce a ref to bare semver (0.7.0) for comparison, +# or empty when it is not a self-hosted release tag (commit SHA, "master", …). +normalize_version() { + _v="${1#refs/tags/}" + _v="${_v#self-hosted/}" + _v="${_v#v}" + case "$_v" in + [0-9]*.[0-9]*) printf '%s' "$_v" ;; + *) ;; + esac +} + +# ver_gt A B - true when version A is strictly greater than B (sort -V order). +ver_gt() { + if [ "$1" = "$2" ]; then return 1; fi + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] +} + +# ver_in_window VER - true when BASE_VER < VER <= TARGET_VER. An empty bound +# leaves that side open (over-reports a gate rather than hiding one). +ver_in_window() { + if [ -n "$TARGET_VER" ] && ver_gt "$1" "$TARGET_VER"; then return 1; fi + if [ -n "$BASE_VER" ] && ! ver_gt "$1" "$BASE_VER"; then return 1; fi + return 0 +} + +# --- ref resolution ---------------------------------------------------------- + +resolve_target_ref() { + if [ -n "$TO_REF" ]; then + TARGET_REF="$TO_REF" + return + fi + TARGET_REF=$(latest_release_tag) + if [ -n "$TARGET_REF" ]; then + log "Latest release tag: $TARGET_REF" + else + TARGET_REF="master" + warn "No self-hosted/v* release tags found; targeting 'master'. Pin a version with --to." + fi +} + +resolve_base_ref() { + REPORT_ONLY=0 + if [ -n "$FROM_REF" ]; then + BASE_REF="$FROM_REF" + elif BASE_REF=$(read_stamp_ref) && [ -n "$BASE_REF" ]; then + : + else + REPORT_ONLY=1 + BASE_REF="" + fi +} + +print_report_only_guidance() { + warn "No $STAMP_FILE found and no --from given; cannot determine the version you started from." + cat >&2 < $STAMP_FILE + 3. Re-run: sh update.sh + +Or supply it inline for this run: sh update.sh --from + +Continuing in REPORT-ONLY mode. NOTE: this is NOT the full set of changes - +without a base version it can only list files and .env keys that are entirely +NEW to you; it CANNOT show which existing files would change or conflict. Record +a base and re-run for the real preview. Nothing will be written. +EOF +} + +fetch_snapshots() { + TARGET_DIR="$TMP_ROOT/target" + log "Fetching target snapshot ($TARGET_REF)" + fetch_snapshot "$TARGET_REF" "$TARGET_DIR" \ + || die "Could not fetch target snapshot '$TARGET_REF' from $REPO_URL" + + if [ "$REPORT_ONLY" = "1" ]; then + return + fi + + BASE_DIR="$TMP_ROOT/base" + log "Fetching base snapshot ($BASE_REF)" + fetch_snapshot "$BASE_REF" "$BASE_DIR" \ + || die "Could not fetch base snapshot '$BASE_REF' from $REPO_URL" +} + +run_report_only() { + log "Files in '$TARGET_REF' you do NOT have yet (brand-new only; existing files that changed are NOT shown here):" + while IFS= read -r f; do + is_excluded "$f" && continue + [ -f "$f" ] || echo " + $f" + done </dev/null 2>&1; then + die "$_manifest is present but is not a valid JSON object; refusing to update without a working breaking-change gate. Please report this to the maintainers." + fi + + if [ -z "$BASE_VER" ] || [ -z "$TARGET_VER" ]; then + warn "Base or target is not a self-hosted/vX.Y.Z tag; cannot compute an exact" + warn "update window. Showing all applicable manual-action releases - review" + warn "which ones apply to your deployment." + fi + + for k in $(jq -r 'keys[]' "$_manifest" 2>/dev/null); do + case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac + ver_in_window "$k" || continue + + breaking=$(jq -r --arg k "$k" '.[$k].breaking // false' "$_manifest") + gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$_manifest") + url=$(jq -r --arg k "$k" '.[$k].migration_guide_url // empty' "$_manifest") + reqs=$(jq -r --arg k "$k" '.[$k].requires[]? // empty' "$_manifest") + + if [ "$breaking" = "true" ] || [ -n "$gate" ]; then + GATE_REQUIRED=1 + fi + + { + [ "$breaking" = "true" ] && echo "[$k] BREAKING" || echo "[$k]" + [ -n "$gate" ] && echo " gate: '$gate' must be run first (see the steps below for the exact command)" + [ -n "$url" ] && echo " guide: $url" + [ -n "$reqs" ] && printf '%s\n' "$reqs" | sed 's/^/ - /' + } >> "$GATE_REPORT" + done + # Explicit success: the loop's last iteration can end on a false test (e.g. + # an entry with no 'requires'), which would otherwise make this function + # return non-zero and abort the whole script under 'set -e'. + return 0 +} + +confirm_gate() { + [ "$GATE_REQUIRED" = "1" ] || return 0 + [ "$DRY_RUN" != "1" ] || return 0 + [ "$ASSUME_YES" != "1" ] || return 0 + + echo "" >&2 + warn "This update requires manual action - review before continuing:" + sed 's/^/ /' "$GATE_REPORT" >&2 + echo "" >&2 + + if { : > /dev/tty; } 2>/dev/null; then + printf "Have you completed the required steps and want to continue? [y/N]: " > /dev/tty + read -r reply < /dev/tty + case "$reply" in + y|Y|yes|YES) return 0 ;; + *) die "Aborted by user. Nothing was modified." ;; + esac + fi + die "Breaking/gated changes present and no controlling terminal to confirm. Re-run with --yes to proceed." +} + +# --- backup ------------------------------------------------------------------ + +take_backup() { + if [ "$DRY_RUN" = "1" ]; then + log "Dry run: no backup taken, nothing will be written." + return 0 + fi + mkdir -p backups + _backup="backups/pre-update-$(date +%Y%m%d-%H%M%S).tgz" + log "Backing up current configuration to $_backup (excluding data directories)" + tar czf "$_backup" \ + --exclude='./backups' \ + --exclude='./volumes/db/data' \ + --exclude='./volumes/storage' \ + . 2>/dev/null || warn "Backup archive reported errors; review $_backup before relying on it." + warn "This does NOT back up your database. Back it up separately before updating." +} + +# --- vendor file merge ------------------------------------------------------- + +apply_file() { + [ "$DRY_RUN" = "1" ] && return 0 + _dir=$(dirname "$1") + [ "$_dir" = "." ] || mkdir -p "$_dir" + cp -f "$2" "$1" +} + +# Per-file 3-way merge (u=yours, b=base snapshot, t=target snapshot): +# no t → keep u; report removed-upstream +# no u → copy t; report new +# u == t → report unchanged +# u == b → copy t; report updated (user never edited) +# else → git merge-file u b t; report merged-clean or CONFLICT +# If b had no file, an empty file stands in for b. +merge_one_file() { + f="$1" + empty="$2" + merged="$TMP_ROOT/merged.out" + + b="$BASE_DIR/$f" + t="$TARGET_DIR/$f" + u="$f" + + if [ ! -f "$t" ]; then + [ -f "$u" ] && record "removed-upstream" "$f" + return 0 + fi + + if [ ! -f "$u" ]; then + apply_file "$f" "$t" + record "new" "$f" + return 0 + fi + + if cmp -s "$u" "$t"; then + record "unchanged" "$f" + return 0 + fi + + base_for_merge="$b" + [ -f "$base_for_merge" ] || base_for_merge="$empty" + + if cmp -s "$u" "$base_for_merge"; then + apply_file "$f" "$t" + record "updated" "$f" + return 0 + fi + + if git merge-file -p -q \ + -L "yours ($f)" -L "base" -L "new ($TARGET_REF)" \ + "$u" "$base_for_merge" "$t" > "$merged" 2>/dev/null; then + [ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u" + record "merged-clean" "$f" + elif [ -s "$merged" ]; then + # Non-zero exit with output = a normal conflict (markers written). + [ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u" + record "CONFLICT" "$f" + else + # git merge-file errored and produced no output; keep the user's file + # intact rather than truncating it to empty. + record "merge-failed" "$f" + fi +} + +merge_vendor_files() { + _empty="$TMP_ROOT/empty" + : > "$_empty" + : > "$REPORT" + + while IFS= read -r f; do + [ -n "$f" ] || continue + is_excluded "$f" && continue + merge_one_file "$f" "$_empty" + done < "$ENV_ADDED" + : > "$ENV_REMOVED" + [ -f "$_example" ] || return 0 + + while IFS= read -r k; do + env_has_key "$k" .env || echo "$k" >> "$ENV_ADDED" + done <>>>>>> markers):" + list_status CONFLICT + fi + if [ "$(count_status merge-failed)" != "0" ]; then + echo "" + warn "Files git could not merge (left unchanged - update these manually):" + list_status merge-failed + fi + if [ "$(count_status merged-clean)" != "0" ]; then + echo "" + log "Files merged cleanly (review recommended):" + list_status merged-clean + fi + if [ "$(count_status removed-upstream)" != "0" ]; then + echo "" + log "Removed upstream but kept in place (you may no longer need these):" + list_status removed-upstream + fi + if [ -s "$ENV_ADDED" ]; then + echo "" + log ".env keys added (review values):" + sed 's/^/ + /' "$ENV_ADDED" + fi + if [ -s "$ENV_REMOVED" ]; then + echo "" + log ".env keys you have that are gone from the new .env.example (review/remove manually):" + sed 's/^/ - /' "$ENV_REMOVED" + fi + if [ -s "$GATE_REPORT" ]; then + echo "" + log "Required manual steps for this update (from upgrades.json):" + sed 's/^/ /' "$GATE_REPORT" + fi + echo "" + log "For what changed in this update, see CHANGELOG.md (from $BASE_REF to $TARGET_REF)." +} + +write_stamp() { + { + echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh." + echo "# Do not commit or edit by hand. Records the ref this deployment was based on." + echo "ref=$TARGET_REF" + } > "$STAMP_FILE" +} + +print_next_steps() { + echo "" + log "Next steps:" + echo " 1. Review the changes (git diff, or compare against the backup in backups/)." + echo " 2. sh run.sh pull" + echo " 3. sh run.sh recreate" +} + +# --- main -------------------------------------------------------------------- + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --to) [ $# -ge 2 ] || die "--to requires a argument."; TO_REF="$2"; shift 2 ;; + --from) [ $# -ge 2 ] || die "--from requires a argument."; FROM_REF="$2"; shift 2 ;; + -h|--help) print_help; exit 0 ;; + *) echo "Unknown option: $1" >&2; print_help; exit 1 ;; + esac +done + +[ -f docker-compose.yml ] || die "docker-compose.yml not found in $(pwd). Run this from your deployment directory." +[ -f .env ] || die ".env not found in $(pwd). This does not look like a configured deployment." +command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH." +command -v jq >/dev/null 2>&1 || die "jq is required but was not found on PATH." + +resolve_target_ref +resolve_base_ref +TARGET_VER=$(normalize_version "$TARGET_REF") +BASE_VER=$(normalize_version "$BASE_REF") +[ "$REPORT_ONLY" = "1" ] && print_report_only_guidance + +TMP_ROOT=$(mktemp -d) +REPORT="$TMP_ROOT/report" +ENV_ADDED="$TMP_ROOT/env_added" +ENV_REMOVED="$TMP_ROOT/env_removed" +GATE_REPORT="$TMP_ROOT/gate_report" +trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM + +fetch_snapshots +load_ignore_file + +if [ "$REPORT_ONLY" = "1" ]; then + run_report_only + build_gate_report + if [ -s "$GATE_REPORT" ]; then + echo "" + log "Breaking changes / required manual steps in this range (from upgrades.json):" + sed 's/^/ /' "$GATE_REPORT" + warn "Record a base version (see above) and re-run to apply with the gate enforced." + fi + exit 0 +fi + +build_gate_report +confirm_gate + +take_backup +merge_vendor_files +merge_env_file +print_summary + +if [ "$DRY_RUN" = "1" ]; then + echo "" + log "Dry run complete. Re-run without --dry-run to apply." + exit 0 +fi + +if [ "$(count_status CONFLICT)" != "0" ] || [ "$(count_status merge-failed)" != "0" ]; then + echo "" + warn "Update applied WITH CONFLICTS. Resolve the files listed above (remove the" + warn "<<<<<<< ======= >>>>>>> markers, or fix the files git could not merge)" + warn "before starting the stack." + warn "The version stamp was NOT advanced; it will update on your next clean run." + warn "Exiting with status 2." + exit 2 +fi + +write_stamp +print_next_steps +log "Update applied cleanly." diff --git a/docker/upgrades.json b/docker/upgrades.json new file mode 100644 index 00000000000..41489506f00 --- /dev/null +++ b/docker/upgrades.json @@ -0,0 +1,30 @@ +{ + "_schema": { + "description": "Manual-action manifest for self-hosted upgrades, read by update.sh with jq. Hand-edit this file directly; it is the source of truth (no generation step). Keys are self-hosted release versions as bare semver (e.g. \"0.7.0\"), matching the self-hosted/vX.Y.Z tag and the \"## [0.7.0]\" CHANGELOG heading. Only add an entry for a release that needs an action a file diff cannot encode: a data migration, a run-this-first script, or a breaking default. Routine config changes are applied automatically by update.sh's 3-way merge and must NOT be listed here. update.sh gates on entries in (your version, target version], ordered with sort -V.", + "fields": { + "breaking": "bool - requires explicit user confirmation before applying", + "gate": "string|null - script to run before upgrading past this release (e.g. utils/upgrade-pg17.sh)", + "migration_guide_url": "string|null - link to the per-release migration guide", + "requires": "string[] - free-text manual steps shown to the user" + } + }, + "0.6.0": { + "breaking": true, + "gate": "utils/upgrade-pg17.sh", + "migration_guide_url": "https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17", + "requires": [ + "Postgres 17 is now the default. Do NOT start Postgres 17 against an existing Postgres 15 data directory - back up your database first.", + "Run 'sudo bash utils/upgrade-pg17.sh' (needs bash + root) to migrate Postgres 15 -> 17, then recreate containers. To defer, pin Postgres 15 with the docker-compose.pg15.yml override.", + "Includes a security fix for the API gateway (Realtime /api/tenants and /api/openapi routes) - strongly recommended for any instance running Realtime." + ] + }, + "0.7.0": { + "breaking": true, + "gate": null, + "migration_guide_url": "https://github.com/orgs/supabase/discussions/47093", + "requires": [ + "API_EXTERNAL_URL now includes the /auth/v1 path prefix, and SAML SSO endpoints moved to /auth/v1/sso/saml/*. Update custom OAuth provider callback URLs and any SAML configuration accordingly.", + "Anon (publishable) key access to the OpenAPI spec at /rest/v1/ has been removed. Use the service role or secret API key if you relied on it; normal data access via /rest/v1/ is unaffected." + ] + } +}