From 3b3cc5607c4803832cf20016ac4fe46f35282b54 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 01:43:18 +0900 Subject: [PATCH 01/47] fix(ci): use supported Compose create flags --- .github/workflows/nightly-local-regression.yml | 2 +- apps/web/tests-unit/nightly-regression-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 2d617802c..d6ff03912 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -262,7 +262,7 @@ jobs: '-f', 'backend/supabase/docker-compose.yml', '-f', 'backend/supabase/docker-compose.local.yml', '-f', 'backend/supabase/docker-compose.mail.yml', - 'create', '--no-start', + 'create', '--pull=never', 'analytics', 'auth', 'db', 'functions', 'imgproxy', 'kong', 'mail', 'meta', 'realtime', 'rest', 'storage', 'supavisor', 'vector', ] diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 62ee4ab7f..df38ebe5f 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -301,7 +301,7 @@ describe("nightly regression package and source contracts", () => { "Probe Compose container creation", "backend/supabase/scripts/local-stack.py render", "local-compose-create-preflight-v1", - "create', '--no-start'", + "create', '--pull=never'", "mount_invalid", "network_create", "runtime_create", From 320c02bc704761385ab36d1e4f21f24427c50167 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 01:54:05 +0900 Subject: [PATCH 02/47] feat(ci): probe Compose service startup --- .../workflows/nightly-local-regression.yml | 102 ++++++++++++++++++ .../nightly-regression-workflow.test.ts | 5 + docs/operations/nightly-regression.md | 2 + 3 files changed, 109 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index d6ff03912..7fcb29a9d 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -315,6 +315,108 @@ jobs: ) raise SystemExit(2) PY + - name: Probe Compose service startup + shell: bash + run: | + set -euo pipefail + render_receipt="$RUNNER_TEMP/local-stack-render-preflight.json" + python3 backend/supabase/scripts/local-stack.py render > "$render_receipt" + python3 - "$render_receipt" <<'PY' + import json + import os + import pathlib + import subprocess + import sys + + receipt = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')) + project = receipt.get('project_name') + if receipt.get('schema') != 'local-stack-receipt-v1' or receipt.get('action') != 'render' or receipt.get('ok') is not True: + raise SystemExit(2) + if not isinstance(project, str) or not project.startswith('tzudong-local-'): + raise SystemExit(2) + state = pathlib.Path('backend/supabase/volumes/.local-stack') / project + command_prefix = [ + 'docker', 'compose', + '--project-name', project, + '--env-file', str(state / 'stack.env'), + '-f', 'backend/supabase/docker-compose.yml', + '-f', 'backend/supabase/docker-compose.local.yml', + '-f', 'backend/supabase/docker-compose.mail.yml', + ] + services = ( + 'db', 'analytics', 'auth', 'functions', 'imgproxy', 'kong', 'mail', + 'meta', 'realtime', 'rest', 'storage', 'supavisor', 'vector', + ) + markers = ( + ('port_conflict', ('address already in use', 'port is already allocated')), + ('mount_invalid', ('invalid mount config', 'bind source path does not exist', 'failed to mount local volume')), + ('network_create', ('failed to create network', 'failed to set up container networking', 'network not found')), + ('runtime_create', ('failed to create shim task', 'oci runtime create failed', 'failed to start shim')), + ('exec_invalid', ('exec format error', 'executable file not found')), + ('permission_denied', ('permission denied', 'operation not permitted')), + ) + + def classify(stderr): + normalized = stderr.casefold() + for suffix, values in markers: + if any(value in normalized for value in values): + return suffix + return 'unknown' + + safe_env = { + key: os.environ[key] + for key in ('PATH', 'HOME', 'USER', 'TMPDIR', 'LANG', 'LC_ALL', 'TERM') + if os.environ.get(key) + } + safe_env.setdefault('PATH', '/usr/bin:/bin') + safe_env.setdefault('HOME', str(pathlib.Path.home())) + results = [] + failed = False + for service in services: + record = {'service': service, 'status': 'failed', 'failure_class': 'unknown'} + try: + result = subprocess.run( + [*command_prefix, 'start', service], + capture_output=True, + text=True, + timeout=120, + check=False, + env=safe_env, + ) + if result.returncode == 0: + record = {'service': service, 'status': 'passed', 'failure_class': 'none'} + else: + record['failure_class'] = classify(result.stderr) + except FileNotFoundError: + record['failure_class'] = 'docker_not_found' + except subprocess.TimeoutExpired: + record['failure_class'] = 'timeout' + results.append(record) + if record['status'] != 'passed': + failed = True + break + report = { + 'schema': 'local-compose-start-preflight-v1', + 'project_name': project, + 'services': results, + } + target = pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-start-preflight.json') + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(report, sort_keys=True, separators=(',', ':')) + '\n', + encoding='utf-8', + ) + subprocess.run( + [*command_prefix, 'down', '--volumes', '--remove-orphans'], + capture_output=True, + text=True, + timeout=120, + check=False, + env=safe_env, + ) + if failed: + raise SystemExit(2) + PY - name: Reset disposable local stack shell: bash run: | diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index df38ebe5f..f00b2f971 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -305,6 +305,11 @@ describe("nightly regression package and source contracts", () => { "mount_invalid", "network_create", "runtime_create", + "Probe Compose service startup", + "local-compose-start-preflight-v1", + "docker', 'compose", + "'start', service", + "exec_invalid", "stack.env and credentials excluded", "down --volumes --remove-orphans", ]) { diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index c0281a18d..63107f773 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -80,6 +80,8 @@ retry and records only fixed image/status/failure-class fields. A failed pull stops the lane before Docker Compose or publication. The preflight also runs a no-network container probe from a pulled image so Docker runtime failures are separated from Compose configuration failures. +When container creation succeeds, it starts each Core service separately and +retains only fixed service/failure-class fields for the first failing service. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From 090b9e68731cb5bdfd67fbdeff78dd483b63d49c Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:10:09 +0900 Subject: [PATCH 03/47] feat(ci): probe collective Compose startup --- .../workflows/nightly-local-regression.yml | 26 +++++++++++++++++-- .../nightly-regression-workflow.test.ts | 2 ++ docs/operations/nightly-regression.md | 2 ++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 7fcb29a9d..529d6b326 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -370,9 +370,30 @@ jobs: } safe_env.setdefault('PATH', '/usr/bin:/bin') safe_env.setdefault('HOME', str(pathlib.Path.home())) + collective = {'status': 'failed', 'failure_class': 'unknown'} + try: + result = subprocess.run( + [ + *command_prefix, 'up', '-d', '--pull=never', + *services, + ], + capture_output=True, + text=True, + timeout=600, + check=False, + env=safe_env, + ) + if result.returncode == 0: + collective = {'status': 'passed', 'failure_class': 'none'} + else: + collective['failure_class'] = classify(result.stderr) + except FileNotFoundError: + collective['failure_class'] = 'docker_not_found' + except subprocess.TimeoutExpired: + collective['failure_class'] = 'timeout' results = [] - failed = False - for service in services: + failed = collective['status'] != 'passed' + for service in services if not failed else (): record = {'service': service, 'status': 'failed', 'failure_class': 'unknown'} try: result = subprocess.run( @@ -398,6 +419,7 @@ jobs: report = { 'schema': 'local-compose-start-preflight-v1', 'project_name': project, + 'collective': collective, 'services': results, } target = pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-start-preflight.json') diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index f00b2f971..a97705d60 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -307,6 +307,8 @@ describe("nightly regression package and source contracts", () => { "runtime_create", "Probe Compose service startup", "local-compose-start-preflight-v1", + "collective", + "up', '-d', '--pull=never'", "docker', 'compose", "'start', service", "exec_invalid", diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 63107f773..5889bfb30 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -82,6 +82,8 @@ The preflight also runs a no-network container probe from a pulled image so Docker runtime failures are separated from Compose configuration failures. When container creation succeeds, it starts each Core service separately and retains only fixed service/failure-class fields for the first failing service. +The same receipt records whether the collective `up` orchestration succeeds, +which distinguishes Compose orchestration failures from per-service starts. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From 6f566c0c064a130c29bd0b19fc173a6a66a436d1 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:18:02 +0900 Subject: [PATCH 04/47] fix(local-stack): start Compose services individually --- backend/supabase/scripts/local-stack.py | 19 +++++++++++++++++-- .../tests/test_local_compose_inputs.py | 4 ++++ docs/operations/nightly-regression.md | 2 ++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 54638d9e0..a74d01ade 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -34,6 +34,8 @@ GENERATOR_VERSION = "local-stack-v1" COMPOSE_START_TIMEOUT_SECONDS = 600 COMPOSE_START_RETRIES = 2 +COMPOSE_SERVICE_START_TIMEOUT_SECONDS = 180 +COMPOSE_SERVICE_START_RETRIES = 1 EXPECTED_SERVICES = ( "analytics", "auth", "db", "functions", "imgproxy", "kong", "mail", "meta", "realtime", "rest", "storage", "studio", "supavisor", "vector", @@ -1449,18 +1451,31 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: _ACTIVE_COMMAND = command started = True _run( - command + ["up", "-d", *CORE_SERVICES], + command + ["create", "--force-recreate", "--pull=policy", *CORE_SERVICES], timeout=COMPOSE_START_TIMEOUT_SECONDS, error_code="compose_core_start", retries=COMPOSE_START_RETRIES, ) + for service in CORE_SERVICES: + _run( + command + ["start", service], + timeout=COMPOSE_SERVICE_START_TIMEOUT_SECONDS, + error_code=f"compose_core_start_{service}", + retries=COMPOSE_SERVICE_START_RETRIES, + ) _wait_ready(command, values, required=CORE_REQUIRED) _run( - command + ["up", "-d", "studio"], + command + ["create", "--force-recreate", "--pull=policy", "studio"], timeout=COMPOSE_START_TIMEOUT_SECONDS, error_code="compose_studio_start", retries=COMPOSE_START_RETRIES, ) + _run( + command + ["start", "studio"], + timeout=COMPOSE_SERVICE_START_TIMEOUT_SECONDS, + error_code="compose_studio_start_studio", + retries=COMPOSE_SERVICE_START_RETRIES, + ) services = _wait_ready(command, values) except (LocalStackError, OSError, ValueError): if started: diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 985040d3e..f525b84bb 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -226,6 +226,10 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("timeout=COMPOSE_START_TIMEOUT_SECONDS", source) self.assertIn("COMPOSE_START_RETRIES", source) self.assertIn("retries=COMPOSE_START_RETRIES", source) + self.assertIn("COMPOSE_SERVICE_START_TIMEOUT_SECONDS", source) + self.assertIn("COMPOSE_SERVICE_START_RETRIES", source) + self.assertIn('"create", "--force-recreate", "--pull=policy"', source) + self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) self.assertIn("_compose_error_suffix", source) self.assertIn('error_code="compose_config"', source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 5889bfb30..d22c504c0 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -72,6 +72,8 @@ stderr. Core and Studio starts retry twice after bounded command failures to absorb transient image-registry or runner startup errors; persistent failures remain fail closed. +The lifecycle creates the pinned services once and starts them individually +instead of relying on one collective `up` orchestration call. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From d032680e40f9594f9429b8a5ec5d6865a8920b56 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:24:53 +0900 Subject: [PATCH 05/47] fix(ci): recover service probe after collective failure --- .../workflows/nightly-local-regression.yml | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 529d6b326..d710bcc7b 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -392,7 +392,46 @@ jobs: except subprocess.TimeoutExpired: collective['failure_class'] = 'timeout' results = [] - failed = collective['status'] != 'passed' + failed = False + if collective['status'] != 'passed': + subprocess.run( + [*command_prefix, 'down', '--volumes', '--remove-orphans'], + capture_output=True, + text=True, + timeout=120, + check=False, + env=safe_env, + ) + try: + recreate = subprocess.run( + [*command_prefix, 'create', '--pull=never', *services], + capture_output=True, + text=True, + timeout=300, + check=False, + env=safe_env, + ) + if recreate.returncode != 0: + results.append({ + 'service': '__recreate__', + 'status': 'failed', + 'failure_class': classify(recreate.stderr), + }) + failed = True + except FileNotFoundError: + results.append({ + 'service': '__recreate__', + 'status': 'failed', + 'failure_class': 'docker_not_found', + }) + failed = True + except subprocess.TimeoutExpired: + results.append({ + 'service': '__recreate__', + 'status': 'failed', + 'failure_class': 'timeout', + }) + failed = True for service in services if not failed else (): record = {'service': service, 'status': 'failed', 'failure_class': 'unknown'} try: From 91154078c1afce980fa190e08d59f13dbb73f263 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:37:30 +0900 Subject: [PATCH 06/47] fix(ci): continue after bounded startup diagnostics --- .github/workflows/nightly-local-regression.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index d710bcc7b..10c53767d 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -476,7 +476,7 @@ jobs: env=safe_env, ) if failed: - raise SystemExit(2) + print('Compose startup diagnostics recorded; continuing lifecycle acceptance.', file=sys.stderr) PY - name: Reset disposable local stack shell: bash From c98bb1d755b01531b49d81e966513daeea1e2257 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:50:08 +0900 Subject: [PATCH 07/47] fix(local-stack): distinguish Compose create failures --- backend/supabase/scripts/local-stack.py | 4 ++-- backend/supabase/tests/test_local_compose_inputs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index a74d01ade..04d0a2180 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -1453,7 +1453,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: _run( command + ["create", "--force-recreate", "--pull=policy", *CORE_SERVICES], timeout=COMPOSE_START_TIMEOUT_SECONDS, - error_code="compose_core_start", + error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) for service in CORE_SERVICES: @@ -1467,7 +1467,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: _run( command + ["create", "--force-recreate", "--pull=policy", "studio"], timeout=COMPOSE_START_TIMEOUT_SECONDS, - error_code="compose_studio_start", + error_code="compose_studio_create", retries=COMPOSE_START_RETRIES, ) _run( diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index f525b84bb..001670bf2 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -233,8 +233,8 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("_COMPOSE_ERROR_MARKERS", source) self.assertIn("_compose_error_suffix", source) self.assertIn('error_code="compose_config"', source) - self.assertIn('error_code="compose_core_start"', source) - self.assertIn('error_code="compose_studio_start"', source) + self.assertIn('error_code="compose_core_create"', source) + self.assertIn('error_code="compose_studio_create"', source) self.assertIn('_assert_project_volumes(command, project)', source) if __name__ == "__main__": unittest.main() From 2a0ea3c921fd07c61f75b43b1746407bfa95b80f Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 02:59:29 +0900 Subject: [PATCH 08/47] fix(local-stack): use supported image pull policy --- backend/supabase/scripts/local-stack.py | 4 ++-- backend/supabase/tests/test_local_compose_inputs.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 04d0a2180..36da07c94 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -1451,7 +1451,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: _ACTIVE_COMMAND = command started = True _run( - command + ["create", "--force-recreate", "--pull=policy", *CORE_SERVICES], + command + ["create", "--force-recreate", "--pull=missing", *CORE_SERVICES], timeout=COMPOSE_START_TIMEOUT_SECONDS, error_code="compose_core_create", retries=COMPOSE_START_RETRIES, @@ -1465,7 +1465,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: ) _wait_ready(command, values, required=CORE_REQUIRED) _run( - command + ["create", "--force-recreate", "--pull=policy", "studio"], + command + ["create", "--force-recreate", "--pull=missing", "studio"], timeout=COMPOSE_START_TIMEOUT_SECONDS, error_code="compose_studio_create", retries=COMPOSE_START_RETRIES, diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 001670bf2..fd7ab6297 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -228,7 +228,7 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("retries=COMPOSE_START_RETRIES", source) self.assertIn("COMPOSE_SERVICE_START_TIMEOUT_SECONDS", source) self.assertIn("COMPOSE_SERVICE_START_RETRIES", source) - self.assertIn('"create", "--force-recreate", "--pull=policy"', source) + self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) self.assertIn("_compose_error_suffix", source) From 854fe6876ae60e631e449561e22acc650c8cf1e5 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 03:11:55 +0900 Subject: [PATCH 09/47] fix(local-stack): gate dependent service startup --- backend/supabase/scripts/local-stack.py | 24 +++++++++++++------ .../tests/test_local_compose_inputs.py | 4 ++++ docs/operations/nightly-regression.md | 3 ++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 36da07c94..47ccf953e 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -100,6 +100,13 @@ READINESS_REQUIRED = tuple(READINESS_ENDPOINTS) CORE_REQUIRED = tuple(service for service in READINESS_REQUIRED if service != "studio") CORE_SERVICES = tuple(service for service in EXPECTED_SERVICES if service != "studio") +CORE_START_PHASES = ( + (("vector",), ("vector",)), + (("db",), ("db",)), + (("analytics",), ("analytics",)), + (("imgproxy",), ()), + (("auth", "functions", "kong", "mail", "meta", "realtime", "rest", "storage", "supavisor"), ()), +) _ACTIVE_COMMAND: list[str] | None = None @@ -1456,13 +1463,16 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) - for service in CORE_SERVICES: - _run( - command + ["start", service], - timeout=COMPOSE_SERVICE_START_TIMEOUT_SECONDS, - error_code=f"compose_core_start_{service}", - retries=COMPOSE_SERVICE_START_RETRIES, - ) + for services, wait_for in CORE_START_PHASES: + for service in services: + _run( + command + ["start", service], + timeout=COMPOSE_SERVICE_START_TIMEOUT_SECONDS, + error_code=f"compose_core_start_{service}", + retries=COMPOSE_SERVICE_START_RETRIES, + ) + if wait_for: + _wait_ready(command, values, required=wait_for) _wait_ready(command, values, required=CORE_REQUIRED) _run( command + ["create", "--force-recreate", "--pull=missing", "studio"], diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index fd7ab6297..63a057f94 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -228,6 +228,10 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("retries=COMPOSE_START_RETRIES", source) self.assertIn("COMPOSE_SERVICE_START_TIMEOUT_SECONDS", source) self.assertIn("COMPOSE_SERVICE_START_RETRIES", source) + self.assertIn("CORE_START_PHASES", source) + self.assertIn('(("vector",), ("vector",))', source) + self.assertIn('(("db",), ("db",))', source) + self.assertIn('(("analytics",), ("analytics",))', source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index d22c504c0..aa3cedb2f 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -73,7 +73,8 @@ Core and Studio starts retry twice after bounded command failures to absorb transient image-registry or runner startup errors; persistent failures remain fail closed. The lifecycle creates the pinned services once and starts them individually -instead of relying on one collective `up` orchestration call. +instead of relying on one collective `up` orchestration call. Vector, database, +and analytics readiness gates run before dependent services are started. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From b8f7e4e20527b8898edb1ee7036c1661a3937647 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 03:29:59 +0900 Subject: [PATCH 10/47] fix(ci): preserve failed stack for diagnostics --- .github/workflows/nightly-local-regression.yml | 2 ++ backend/supabase/scripts/local-stack.py | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 1 + docs/operations/nightly-regression.md | 3 +++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 10c53767d..f848c4838 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -479,6 +479,8 @@ jobs: print('Compose startup diagnostics recorded; continuing lifecycle acceptance.', file=sys.stderr) PY - name: Reset disposable local stack + env: + LOCAL_STACK_PRESERVE_FAILURE_STATE: '1' shell: bash run: | set -euo pipefail diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 47ccf953e..f7474e58a 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -1488,7 +1488,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: ) services = _wait_ready(command, values) except (LocalStackError, OSError, ValueError): - if started: + if started and os.environ.get("LOCAL_STACK_PRESERVE_FAILURE_STATE") != "1": try: _run(command + ["down", "--remove-orphans"]) except (LocalStackError, OSError, ValueError): diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 63a057f94..4cd496fa9 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -232,6 +232,7 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn('(("vector",), ("vector",))', source) self.assertIn('(("db",), ("db",))', source) self.assertIn('(("analytics",), ("analytics",))', source) + self.assertIn("LOCAL_STACK_PRESERVE_FAILURE_STATE", source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index aa3cedb2f..16b07f7f3 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -87,6 +87,9 @@ When container creation succeeds, it starts each Core service separately and retains only fixed service/failure-class fields for the first failing service. The same receipt records whether the collective `up` orchestration succeeds, which distinguishes Compose orchestration failures from per-service starts. +During the hosted acceptance lane, a failed reset preserves the disposable +container state until bounded diagnostics are captured; the final cleanup step +still removes the project and volumes. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From 4135ebd1affccd8b54c9af55aac6a0b604ed2681 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 03:44:27 +0900 Subject: [PATCH 11/47] feat(ci): capture bounded runtime health diagnostics --- .../workflows/nightly-local-regression.yml | 86 +++++++++++++++++++ .../nightly-regression-workflow.test.ts | 4 + docs/operations/nightly-regression.md | 2 + 3 files changed, 92 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index f848c4838..884682d9c 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -571,6 +571,92 @@ jobs: --provenance-file "../../${LOCAL_STATE#"$GITHUB_WORKSPACE/"}/stack.env.provenance.json" \ > nightly-run.log + - name: Capture bounded Compose runtime diagnostics + if: always() + shell: bash + run: | + set +e + mkdir -p nightly-artifacts/failure-diagnostics + python3 - <<'PY' + import json + import os + import pathlib + import subprocess + + project = os.environ.get('LOCAL_PROJECT', '') + records = [] + allowed_states = {'created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead'} + allowed_health = {'', 'starting', 'healthy', 'unhealthy', 'none'} + markers = ( + ('permission_denied', ('permission denied', 'operation not permitted')), + ('mount_invalid', ('invalid mount', 'no such file or directory')), + ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), + ) + + def classify(value): + normalized = str(value or '').casefold() + for suffix, values in markers: + if any(marker in normalized for marker in values): + return suffix + return 'none' if not normalized else 'unknown' + + if project.startswith('tzudong-local-'): + try: + ids = subprocess.check_output( + [ + 'docker', 'ps', '-aq', + '--filter', f'label=com.docker.compose.project={project}', + ], + text=True, + timeout=30, + ).splitlines() + except (OSError, subprocess.SubprocessError): + ids = [] + for container_id in ids: + try: + raw = subprocess.check_output( + ['docker', 'inspect', container_id], + text=True, + timeout=30, + ) + payload = json.loads(raw)[0] + except (OSError, subprocess.SubprocessError, ValueError, IndexError, KeyError): + continue + labels = (payload.get('Config') or {}).get('Labels') or {} + service = labels.get('com.docker.compose.service') + if service not in { + 'analytics', 'auth', 'db', 'functions', 'imgproxy', 'kong', + 'mail', 'meta', 'realtime', 'rest', 'storage', 'studio', + 'supavisor', 'vector', + }: + continue + state = payload.get('State') or {} + health = state.get('Health') or {} + health_log = health.get('Log') or [] + records.append({ + 'service': service, + 'state': state.get('Status') if state.get('Status') in allowed_states else 'unknown', + 'exit_code': state.get('ExitCode') if isinstance(state.get('ExitCode'), int) and 0 <= state.get('ExitCode') <= 255 else None, + 'state_error_class': classify(state.get('Error')), + 'health': health.get('Status') if health.get('Status') in allowed_health else 'unknown', + 'health_failing_streak': health.get('FailingStreak') if isinstance(health.get('FailingStreak'), int) and health.get('FailingStreak') >= 0 else None, + 'health_log_exit_codes': [ + item.get('ExitCode') + for item in health_log[-5:] + if isinstance(item, dict) and isinstance(item.get('ExitCode'), int) and 0 <= item.get('ExitCode') <= 255 + ], + }) + records.sort(key=lambda item: item['service']) + pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-runtime-diagnostics.json').write_text( + json.dumps({ + 'schema': 'local-compose-runtime-diagnostics-v1', + 'project_name': project if project.startswith('tzudong-local-') else 'unknown', + 'containers': records, + }, sort_keys=True, separators=(',', ':')) + '\n', + encoding='utf-8', + ) + PY + - name: Capture sanitized local receipts if: always() shell: bash diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index a97705d60..896c214f5 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -312,6 +312,10 @@ describe("nightly regression package and source contracts", () => { "docker', 'compose", "'start', service", "exec_invalid", + "Capture bounded Compose runtime diagnostics", + "local-compose-runtime-diagnostics-v1", + "health_failing_streak", + "health_log_exit_codes", "stack.env and credentials excluded", "down --volumes --remove-orphans", ]) { diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 16b07f7f3..9b0608592 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -90,6 +90,8 @@ which distinguishes Compose orchestration failures from per-service starts. During the hosted acceptance lane, a failed reset preserves the disposable container state until bounded diagnostics are captured; the final cleanup step still removes the project and volumes. +The diagnostics receipt records only service state, exit code, health status, +health failure streak, and bounded health-check exit codes. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From dcbc416ee2e4de073f6e6f045a274581fe38bc62 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 03:59:26 +0900 Subject: [PATCH 12/47] feat(ci): classify Compose runtime logs --- .github/workflows/nightly-local-regression.yml | 14 ++++++++++++++ .../tests-unit/nightly-regression-workflow.test.ts | 3 +++ docs/operations/nightly-regression.md | 3 ++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 884682d9c..2edeec68a 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -591,6 +591,8 @@ jobs: ('permission_denied', ('permission denied', 'operation not permitted')), ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), + ('database_connection', ('connection refused', 'econnrefused', 'failed to connect')), + ('boot_error', ('could not start', 'failed to boot', 'uncaught exception')), ) def classify(value): @@ -633,11 +635,23 @@ jobs: state = payload.get('State') or {} health = state.get('Health') or {} health_log = health.get('Log') or [] + try: + logs = subprocess.check_output( + ['docker', 'logs', '--tail', '200', container_id], + text=True, + stderr=subprocess.STDOUT, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + logs = '' records.append({ 'service': service, 'state': state.get('Status') if state.get('Status') in allowed_states else 'unknown', 'exit_code': state.get('ExitCode') if isinstance(state.get('ExitCode'), int) and 0 <= state.get('ExitCode') <= 255 else None, 'state_error_class': classify(state.get('Error')), + 'oom_killed': state.get('OOMKilled') is True, + 'restart_count': payload.get('RestartCount') if isinstance(payload.get('RestartCount'), int) and payload.get('RestartCount') >= 0 else None, + 'log_failure_class': classify(logs), 'health': health.get('Status') if health.get('Status') in allowed_health else 'unknown', 'health_failing_streak': health.get('FailingStreak') if isinstance(health.get('FailingStreak'), int) and health.get('FailingStreak') >= 0 else None, 'health_log_exit_codes': [ diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 896c214f5..08d12a2ca 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -316,6 +316,9 @@ describe("nightly regression package and source contracts", () => { "local-compose-runtime-diagnostics-v1", "health_failing_streak", "health_log_exit_codes", + "log_failure_class", + "oom_killed", + "restart_count", "stack.env and credentials excluded", "down --volumes --remove-orphans", ]) { diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 9b0608592..47f6cecd7 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -91,7 +91,8 @@ During the hosted acceptance lane, a failed reset preserves the disposable container state until bounded diagnostics are captured; the final cleanup step still removes the project and volumes. The diagnostics receipt records only service state, exit code, health status, -health failure streak, and bounded health-check exit codes. +health failure streak, bounded health-check exit codes, restart count, OOM +status, and a fixed class derived from bounded container logs. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From 64fcc213910f4089eb178bc247f73fb1feb0b7cc Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 04:15:30 +0900 Subject: [PATCH 13/47] fix(local-stack): gate analytics on database bootstrap --- backend/supabase/scripts/local-stack.py | 23 ++++++++++++++++++- .../tests/test_local_compose_inputs.py | 3 +++ docs/operations/nightly-regression.md | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index f7474e58a..ec9124974 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -1272,9 +1272,30 @@ def _probe_host_tcp(port: int, timeout: int = 5) -> bool: except OSError: return False +def _probe_database_bootstrap(command: list[str], timeout: int = 5) -> bool: + try: + result = subprocess.run( + command + [ + "exec", "-T", "db", + "psql", "-U", "postgres", "-d", "_supabase", + "-Atqc", "select 1 from pg_namespace where nspname = '_analytics'", + ], + capture_output=True, + text=True, + timeout=timeout, + check=False, + env=_safe_process_environment(), + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 and result.stdout.strip() == "1" + def _probe_service(command: list[str], values: dict[str, str], service: str, timeout: int = 5) -> bool: if service == "db": - return _probe_endpoint(command, service, READINESS_ENDPOINTS[service], timeout) + return ( + _probe_endpoint(command, service, READINESS_ENDPOINTS[service], timeout) + and _probe_database_bootstrap(command, timeout) + ) if service == "kong": return _probe_host_http(int(values["KONG_HTTP_PORT"]), "/auth/v1/health", timeout=timeout) if service == "rest": diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 4cd496fa9..8139b2c17 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -233,6 +233,9 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn('(("db",), ("db",))', source) self.assertIn('(("analytics",), ("analytics",))', source) self.assertIn("LOCAL_STACK_PRESERVE_FAILURE_STATE", source) + self.assertIn("_probe_database_bootstrap", source) + self.assertIn("_analytics", source) + self.assertIn("pg_namespace", source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 47f6cecd7..b1340423d 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -74,7 +74,8 @@ transient image-registry or runner startup errors; persistent failures remain fail closed. The lifecycle creates the pinned services once and starts them individually instead of relying on one collective `up` orchestration call. Vector, database, -and analytics readiness gates run before dependent services are started. +and analytics readiness gates run before dependent services are started; the +database gate also verifies the `_analytics` bootstrap schema. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From f8a293d5d2ebbceec3257d210171fd13c5e0beb1 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 04:30:38 +0900 Subject: [PATCH 14/47] fix(local-stack): allow database bootstrap completion --- backend/supabase/scripts/local-stack.py | 12 +++++++++++- backend/supabase/tests/test_local_compose_inputs.py | 1 + docs/operations/nightly-regression.md | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index ec9124974..23767ca11 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -36,6 +36,7 @@ COMPOSE_START_RETRIES = 2 COMPOSE_SERVICE_START_TIMEOUT_SECONDS = 180 COMPOSE_SERVICE_START_RETRIES = 1 +COMPOSE_DATABASE_BOOTSTRAP_TIMEOUT_SECONDS = 900 EXPECTED_SERVICES = ( "analytics", "auth", "db", "functions", "imgproxy", "kong", "mail", "meta", "realtime", "rest", "storage", "studio", "supavisor", "vector", @@ -1493,7 +1494,16 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: retries=COMPOSE_SERVICE_START_RETRIES, ) if wait_for: - _wait_ready(command, values, required=wait_for) + _wait_ready( + command, + values, + timeout=( + COMPOSE_DATABASE_BOOTSTRAP_TIMEOUT_SECONDS + if wait_for == ("db",) + else 300 + ), + required=wait_for, + ) _wait_ready(command, values, required=CORE_REQUIRED) _run( command + ["create", "--force-recreate", "--pull=missing", "studio"], diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 8139b2c17..5675f9691 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -228,6 +228,7 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("retries=COMPOSE_START_RETRIES", source) self.assertIn("COMPOSE_SERVICE_START_TIMEOUT_SECONDS", source) self.assertIn("COMPOSE_SERVICE_START_RETRIES", source) + self.assertIn("COMPOSE_DATABASE_BOOTSTRAP_TIMEOUT_SECONDS", source) self.assertIn("CORE_START_PHASES", source) self.assertIn('(("vector",), ("vector",))', source) self.assertIn('(("db",), ("db",))', source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index b1340423d..5358ee44a 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -75,7 +75,8 @@ fail closed. The lifecycle creates the pinned services once and starts them individually instead of relying on one collective `up` orchestration call. Vector, database, and analytics readiness gates run before dependent services are started; the -database gate also verifies the `_analytics` bootstrap schema. +database gate also verifies the `_analytics` bootstrap schema with a +900-second first-run bound. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 115bc271f688254add8e4dbe5484d9bc905f5aa1 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 04:55:28 +0900 Subject: [PATCH 15/47] feat(ci): capture database bootstrap status --- .../workflows/nightly-local-regression.yml | 41 +++++++++++++++++++ .../nightly-regression-workflow.test.ts | 3 ++ docs/operations/nightly-regression.md | 3 +- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 2edeec68a..180f72cee 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -614,6 +614,7 @@ jobs: ).splitlines() except (OSError, subprocess.SubprocessError): ids = [] + db_container_id = None for container_id in ids: try: raw = subprocess.check_output( @@ -632,6 +633,8 @@ jobs: 'supavisor', 'vector', }: continue + if service == 'db': + db_container_id = container_id state = payload.get('State') or {} health = state.get('Health') or {} health_log = health.get('Log') or [] @@ -660,11 +663,49 @@ jobs: if isinstance(item, dict) and isinstance(item.get('ExitCode'), int) and 0 <= item.get('ExitCode') <= 255 ], }) + database_bootstrap = { + 'status': 'unavailable', + 'result': 'unknown', + 'failure_class': 'unknown', + 'exit_code': None, + } + if db_container_id is not None: + try: + result = subprocess.run( + [ + 'docker', 'exec', db_container_id, + 'psql', '-U', 'postgres', '-d', '_supabase', + '-Atqc', + "select case when exists (select 1 from pg_namespace where nspname = '_analytics') then 'analytics_schema_present' else 'analytics_schema_missing' end", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + database_bootstrap = { + 'status': 'passed' if result.returncode == 0 and result.stdout.strip() == 'analytics_schema_present' else 'failed', + 'result': result.stdout.strip() if result.stdout.strip() in {'analytics_schema_present', 'analytics_schema_missing'} else 'unknown', + 'failure_class': classify(result.stderr), + 'exit_code': result.returncode if 0 <= result.returncode <= 255 else None, + } + except FileNotFoundError: + database_bootstrap['failure_class'] = 'docker_not_found' + except subprocess.TimeoutExpired: + database_bootstrap['failure_class'] = 'timeout' + else: + database_bootstrap = { + 'status': 'unavailable', + 'result': 'unknown', + 'failure_class': 'unknown', + 'exit_code': None, + } records.sort(key=lambda item: item['service']) pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-runtime-diagnostics.json').write_text( json.dumps({ 'schema': 'local-compose-runtime-diagnostics-v1', 'project_name': project if project.startswith('tzudong-local-') else 'unknown', + 'database_bootstrap': database_bootstrap, 'containers': records, }, sort_keys=True, separators=(',', ':')) + '\n', encoding='utf-8', diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 08d12a2ca..5205c65ef 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -319,6 +319,9 @@ describe("nightly regression package and source contracts", () => { "log_failure_class", "oom_killed", "restart_count", + "database_bootstrap", + "analytics_schema_present", + "analytics_schema_missing", "stack.env and credentials excluded", "down --volumes --remove-orphans", ]) { diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 5358ee44a..d2c7f42eb 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -94,7 +94,8 @@ container state until bounded diagnostics are captured; the final cleanup step still removes the project and volumes. The diagnostics receipt records only service state, exit code, health status, health failure streak, bounded health-check exit codes, restart count, OOM -status, and a fixed class derived from bounded container logs. +status, a fixed class derived from bounded container logs, and the fixed +database bootstrap result. The scheduled regression job has `contents: read` only. It uploads a short-retention `nightly-local-` Actions artifact containing only the From c1e411dc14aa2f869c4a37bb5a737aad24d9d060 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 05:28:43 +0900 Subject: [PATCH 16/47] fix(local-stack): stage database init files --- backend/supabase/scripts/local-stack.py | 26 +++++++++++++++++++ .../tests/test_local_compose_inputs.py | 3 +++ docs/operations/nightly-regression.md | 3 +++ 3 files changed, 32 insertions(+) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 23767ca11..04fd069cf 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -50,6 +50,15 @@ "db-logs.sql": "volumes/db/logs.sql", "db-pooler.sql": "volumes/db/pooler.sql", } +DB_INIT_FILES = ( + ("db-supabase.sql", "97-_supabase.sql"), + ("db-webhooks.sql", "98-webhooks.sql"), + ("db-realtime.sql", "99-realtime.sql"), + ("db-logs.sql", "99-logs.sql"), + ("db-pooler.sql", "99-pooler.sql"), + ("db-roles.sql", "99-roles.sql"), + ("db-jwt.sql", "99-jwt.sql"), +) DESTINATIONS = { "/home/kong/temp.yml", "/etc/vector/vector.yml", "/etc/pooler/pooler.exs", "/var/lib/postgresql/data", "/etc/postgresql-custom", "/var/lib/storage", @@ -1457,6 +1466,22 @@ def _assert_project_volumes(command: list[str], project: str, *, require_existin if labels.get(DOCKER_PROJECT_LABEL) != project or labels.get(DOCKER_SERVICE_LABEL) not in EXPECTED_SERVICES: _fail("docker_container") +def _stage_database_init_files(command: list[str], state: Path) -> None: + result = _run(command + ["ps", "-aq", "db"], error_code="compose_db_container") + db_id = result.stdout.strip() + if not re.fullmatch(r"[0-9a-f]{12,64}", db_id): + _fail("compose_db_container") + for source_name, destination_name in DB_INIT_FILES: + source = state / "inputs" / source_name + _regular_owned(source, mode=0o600) + _run( + [ + "docker", "cp", str(source), + f"{db_id}:/docker-entrypoint-initdb.d/{destination_name}", + ], + error_code="compose_db_init_stage", + ) + def _action_render(root: Path, project: str, state: Path) -> dict[str, Any]: digest, _, _ = _render(root, project, state) input_digest, env_digest = _provenance_digests(state) @@ -1485,6 +1510,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) + _stage_database_init_files(command, state) for services, wait_for in CORE_START_PHASES: for service in services: _run( diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 5675f9691..0c840a63f 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -237,6 +237,9 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("_probe_database_bootstrap", source) self.assertIn("_analytics", source) self.assertIn("pg_namespace", source) + self.assertIn("DB_INIT_FILES", source) + self.assertIn("_stage_database_init_files", source) + self.assertIn("compose_db_init_stage", source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index d2c7f42eb..b5ad1746e 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -77,6 +77,9 @@ instead of relying on one collective `up` orchestration call. Vector, database, and analytics readiness gates run before dependent services are started; the database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. +The lifecycle stages the fixed database init files into the image's +top-level init directory after container creation so the pinned Postgres +entrypoint processes them deterministically on Linux. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From a13c3c73721a288ab86f67e20488b5132add6de8 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 06:07:39 +0900 Subject: [PATCH 17/47] fix(local-stack): stage readable database init copies --- backend/supabase/scripts/local-stack.py | 27 ++++++++++++++----- .../tests/test_local_compose_inputs.py | 2 ++ docs/operations/nightly-regression.md | 4 ++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 04fd069cf..6dec6fcb4 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -22,6 +22,7 @@ import stat import subprocess import sys +import tempfile import time import unicodedata from pathlib import Path @@ -1474,13 +1475,25 @@ def _stage_database_init_files(command: list[str], state: Path) -> None: for source_name, destination_name in DB_INIT_FILES: source = state / "inputs" / source_name _regular_owned(source, mode=0o600) - _run( - [ - "docker", "cp", str(source), - f"{db_id}:/docker-entrypoint-initdb.d/{destination_name}", - ], - error_code="compose_db_init_stage", - ) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(prefix=".db-init-", dir=state, delete=False) as handle: + temporary = Path(handle.name) + handle.write(source.read_bytes()) + os.chmod(temporary, 0o644) + _run( + [ + "docker", "cp", str(temporary), + f"{db_id}:/docker-entrypoint-initdb.d/{destination_name}", + ], + error_code="compose_db_init_stage", + ) + finally: + if temporary is not None: + try: + temporary.unlink() + except FileNotFoundError: + pass def _action_render(root: Path, project: str, state: Path) -> dict[str, Any]: digest, _, _ = _render(root, project, state) diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 0c840a63f..0c2077256 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -240,6 +240,8 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("DB_INIT_FILES", source) self.assertIn("_stage_database_init_files", source) self.assertIn("compose_db_init_stage", source) + self.assertIn("NamedTemporaryFile", source) + self.assertIn("os.chmod(temporary, 0o644)", source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index b5ad1746e..c3737a70d 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -79,7 +79,9 @@ database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. The lifecycle stages the fixed database init files into the image's top-level init directory after container creation so the pinned Postgres -entrypoint processes them deterministically on Linux. +entrypoint processes them deterministically on Linux. The staged copies are +readable only inside the disposable container; host input files remain +owner-only. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From d6b5b07d7ad06e71418c9f05bda7a522351d0049 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 06:35:44 +0900 Subject: [PATCH 18/47] fix(ci): remove stale nightly project volumes --- .github/workflows/nightly-local-regression.yml | 6 ++++++ apps/web/tests-unit/nightly-regression-workflow.test.ts | 4 ++++ docs/operations/nightly-regression.md | 4 +++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 180f72cee..45046dd39 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -485,6 +485,12 @@ jobs: run: | set -euo pipefail mkdir -p nightly-artifacts + if [[ -n "${LOCAL_PROJECT:-}" ]]; then + docker volume rm -f \ + "${LOCAL_PROJECT}-db-data" \ + "${LOCAL_PROJECT}-db-config" \ + "${LOCAL_PROJECT}-storage-data" || true + fi python3 backend/supabase/scripts/local-stack.py reset \ > nightly-artifacts/local-stack-reset.json python3 - nightly-artifacts/local-stack-reset.json <<'PY' diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 5205c65ef..fcfef9399 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -324,6 +324,10 @@ describe("nightly regression package and source contracts", () => { "analytics_schema_missing", "stack.env and credentials excluded", "down --volumes --remove-orphans", + "docker volume rm -f", + "${LOCAL_PROJECT}-db-data", + "${LOCAL_PROJECT}-db-config", + "${LOCAL_PROJECT}-storage-data", ]) { expect(localWorkflowSource).toContain(token); } diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index c3737a70d..047179471 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -79,9 +79,11 @@ database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. The lifecycle stages the fixed database init files into the image's top-level init directory after container creation so the pinned Postgres -entrypoint processes them deterministically on Linux. The staged copies are +The staged copies are readable only inside the disposable container; host input files remain owner-only. +The hosted lane removes only the three exact project volume names before reset +so diagnostic probes cannot leave a stale database volume behind. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 8e3ab500e57b126ffe96bcf271808a74df8541d6 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 07:11:23 +0900 Subject: [PATCH 19/47] fix(local-stack): keep image migration mounts intact --- backend/supabase/scripts/local-stack.py | 39 ------------------- .../tests/test_local_compose_inputs.py | 8 ++-- docs/operations/nightly-regression.md | 5 --- 3 files changed, 3 insertions(+), 49 deletions(-) diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 6dec6fcb4..23767ca11 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -22,7 +22,6 @@ import stat import subprocess import sys -import tempfile import time import unicodedata from pathlib import Path @@ -51,15 +50,6 @@ "db-logs.sql": "volumes/db/logs.sql", "db-pooler.sql": "volumes/db/pooler.sql", } -DB_INIT_FILES = ( - ("db-supabase.sql", "97-_supabase.sql"), - ("db-webhooks.sql", "98-webhooks.sql"), - ("db-realtime.sql", "99-realtime.sql"), - ("db-logs.sql", "99-logs.sql"), - ("db-pooler.sql", "99-pooler.sql"), - ("db-roles.sql", "99-roles.sql"), - ("db-jwt.sql", "99-jwt.sql"), -) DESTINATIONS = { "/home/kong/temp.yml", "/etc/vector/vector.yml", "/etc/pooler/pooler.exs", "/var/lib/postgresql/data", "/etc/postgresql-custom", "/var/lib/storage", @@ -1467,34 +1457,6 @@ def _assert_project_volumes(command: list[str], project: str, *, require_existin if labels.get(DOCKER_PROJECT_LABEL) != project or labels.get(DOCKER_SERVICE_LABEL) not in EXPECTED_SERVICES: _fail("docker_container") -def _stage_database_init_files(command: list[str], state: Path) -> None: - result = _run(command + ["ps", "-aq", "db"], error_code="compose_db_container") - db_id = result.stdout.strip() - if not re.fullmatch(r"[0-9a-f]{12,64}", db_id): - _fail("compose_db_container") - for source_name, destination_name in DB_INIT_FILES: - source = state / "inputs" / source_name - _regular_owned(source, mode=0o600) - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile(prefix=".db-init-", dir=state, delete=False) as handle: - temporary = Path(handle.name) - handle.write(source.read_bytes()) - os.chmod(temporary, 0o644) - _run( - [ - "docker", "cp", str(temporary), - f"{db_id}:/docker-entrypoint-initdb.d/{destination_name}", - ], - error_code="compose_db_init_stage", - ) - finally: - if temporary is not None: - try: - temporary.unlink() - except FileNotFoundError: - pass - def _action_render(root: Path, project: str, state: Path) -> dict[str, Any]: digest, _, _ = _render(root, project, state) input_digest, env_digest = _provenance_digests(state) @@ -1523,7 +1485,6 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) - _stage_database_init_files(command, state) for services, wait_for in CORE_START_PHASES: for service in services: _run( diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 0c2077256..94f387afc 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -237,11 +237,9 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("_probe_database_bootstrap", source) self.assertIn("_analytics", source) self.assertIn("pg_namespace", source) - self.assertIn("DB_INIT_FILES", source) - self.assertIn("_stage_database_init_files", source) - self.assertIn("compose_db_init_stage", source) - self.assertIn("NamedTemporaryFile", source) - self.assertIn("os.chmod(temporary, 0o644)", source) + self.assertNotIn("_stage_database_init_files", source) + self.assertNotIn("compose_db_init_stage", source) + self.assertNotIn('"docker", "cp"', source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 047179471..864bb2735 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -77,11 +77,6 @@ instead of relying on one collective `up` orchestration call. Vector, database, and analytics readiness gates run before dependent services are started; the database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. -The lifecycle stages the fixed database init files into the image's -top-level init directory after container creation so the pinned Postgres -The staged copies are -readable only inside the disposable container; host input files remain -owner-only. The hosted lane removes only the three exact project volume names before reset so diagnostic probes cannot leave a stale database volume behind. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to From d7d5e2f908ff04188970082ca9af4704f5fa6b09 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 07:36:35 +0900 Subject: [PATCH 20/47] test(ci): expose bounded database bootstrap evidence --- .../workflows/nightly-local-regression.yml | 66 +++++++++++++++++++ .../nightly-regression-workflow.test.ts | 4 ++ 2 files changed, 70 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 45046dd39..0738cc3e0 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -598,6 +598,7 @@ jobs: ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), ('database_connection', ('connection refused', 'econnrefused', 'failed to connect')), + ('database_missing', ('database "_supabase" does not exist', 'database "postgres" does not exist')), ('boot_error', ('could not start', 'failed to boot', 'uncaught exception')), ) @@ -675,8 +676,57 @@ jobs: 'failure_class': 'unknown', 'exit_code': None, } + database_presence = { + 'status': 'unavailable', + 'result': 'unknown', + 'exit_code': None, + } + database_init_files = { + 'status': 'unavailable', + 'supabase': 'unknown', + 'logs': 'unknown', + } if db_container_id is not None: try: + presence = subprocess.run( + [ + 'docker', 'exec', db_container_id, + 'psql', '-U', 'postgres', '-d', 'postgres', + '-Atqc', + "select case when exists (select 1 from pg_database where datname = '_supabase') then 'supabase_db_present' else 'supabase_db_missing' end", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + database_presence = { + 'status': 'passed' if presence.returncode == 0 and presence.stdout.strip() in {'supabase_db_present', 'supabase_db_missing'} else 'failed', + 'result': presence.stdout.strip() if presence.stdout.strip() in {'supabase_db_present', 'supabase_db_missing'} else 'unknown', + 'exit_code': presence.returncode if 0 <= presence.returncode <= 255 else None, + } + init_paths = ( + ('supabase', '/docker-entrypoint-initdb.d/migrations/97-_supabase.sql'), + ('logs', '/docker-entrypoint-initdb.d/migrations/99-logs.sql'), + ) + init_results = {} + for key, path in init_paths: + init_probe = subprocess.run( + ['docker', 'exec', db_container_id, 'test', '-r', path], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + init_results[key] = ( + 'present' if init_probe.returncode == 0 + else 'missing' if init_probe.returncode == 1 + else 'unknown' + ) + database_init_files = { + 'status': 'passed' if all(value == 'present' for value in init_results.values()) else 'failed', + **init_results, + } result = subprocess.run( [ 'docker', 'exec', db_container_id, @@ -697,8 +747,12 @@ jobs: } except FileNotFoundError: database_bootstrap['failure_class'] = 'docker_not_found' + database_presence['status'] = 'unavailable' + database_init_files['status'] = 'unavailable' except subprocess.TimeoutExpired: database_bootstrap['failure_class'] = 'timeout' + database_presence['status'] = 'unavailable' + database_init_files['status'] = 'unavailable' else: database_bootstrap = { 'status': 'unavailable', @@ -706,12 +760,24 @@ jobs: 'failure_class': 'unknown', 'exit_code': None, } + database_presence = { + 'status': 'unavailable', + 'result': 'unknown', + 'exit_code': None, + } + database_init_files = { + 'status': 'unavailable', + 'supabase': 'unknown', + 'logs': 'unknown', + } records.sort(key=lambda item: item['service']) pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-runtime-diagnostics.json').write_text( json.dumps({ 'schema': 'local-compose-runtime-diagnostics-v1', 'project_name': project if project.startswith('tzudong-local-') else 'unknown', 'database_bootstrap': database_bootstrap, + 'database_presence': database_presence, + 'database_init_files': database_init_files, 'containers': records, }, sort_keys=True, separators=(',', ':')) + '\n', encoding='utf-8', diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index fcfef9399..d3314e5d6 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -320,6 +320,10 @@ describe("nightly regression package and source contracts", () => { "oom_killed", "restart_count", "database_bootstrap", + "database_presence", + "database_init_files", + "supabase_db_present", + "supabase_db_missing", "analytics_schema_present", "analytics_schema_missing", "stack.env and credentials excluded", From fdc0a4d7f2c6a5bb7d60e78d18991694bf7669c5 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 08:15:01 +0900 Subject: [PATCH 21/47] fix(local-stack): stage SQL through readable init volumes --- .../workflows/nightly-local-regression.yml | 16 +++- .../nightly-regression-workflow.test.ts | 2 + backend/supabase/docker-compose.local.yml | 13 ++- .../supabase/local-inputs/manifest.v1.json | 44 ++-------- backend/supabase/scripts/local-stack.py | 86 ++++++++++++++++++- .../tests/test_local_compose_inputs.py | 27 ++++-- docs/operations/nightly-regression.md | 9 +- 7 files changed, 142 insertions(+), 55 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 0738cc3e0..dd0814f4e 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -486,10 +486,22 @@ jobs: set -euo pipefail mkdir -p nightly-artifacts if [[ -n "${LOCAL_PROJECT:-}" ]]; then - docker volume rm -f \ + for volume in \ "${LOCAL_PROJECT}-db-data" \ "${LOCAL_PROJECT}-db-config" \ - "${LOCAL_PROJECT}-storage-data" || true + "${LOCAL_PROJECT}-db-init-migrations" \ + "${LOCAL_PROJECT}-db-init-scripts" \ + "${LOCAL_PROJECT}-storage-data"; do + mapfile -t consumers < <(docker ps -aq --filter "volume=$volume") + if ((${#consumers[@]})); then + docker rm -f "${consumers[@]}" + fi + docker volume rm -f "$volume" || true + if docker volume inspect "$volume"; then + echo "disposable volume remained after scoped cleanup: $volume" >&2 + exit 2 + fi + done fi python3 backend/supabase/scripts/local-stack.py reset \ > nightly-artifacts/local-stack-reset.json diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index d3314e5d6..c421c0c83 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -331,6 +331,8 @@ describe("nightly regression package and source contracts", () => { "docker volume rm -f", "${LOCAL_PROJECT}-db-data", "${LOCAL_PROJECT}-db-config", + "${LOCAL_PROJECT}-db-init-migrations", + "${LOCAL_PROJECT}-db-init-scripts", "${LOCAL_PROJECT}-storage-data", ]) { expect(localWorkflowSource).toContain(token); diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index fe24bec57..077e44af4 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -84,14 +84,9 @@ services: db: container_name: !reset null volumes: !override - - "${LOCAL_INPUT_ROOT}/db-realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:ro,z" - - "${LOCAL_INPUT_ROOT}/db-webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro,z" - - "${LOCAL_INPUT_ROOT}/db-roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro,z" - - "${LOCAL_INPUT_ROOT}/db-jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro,z" + - "local-db-init-migrations:/docker-entrypoint-initdb.d/migrations:Z" + - "local-db-init-scripts:/docker-entrypoint-initdb.d/init-scripts:Z" - "local-db-data:/var/lib/postgresql/data:Z" - - "${LOCAL_INPUT_ROOT}/db-supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro,z" - - "${LOCAL_INPUT_ROOT}/db-logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:ro,z" - - "${LOCAL_INPUT_ROOT}/db-pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:ro,z" - "local-db-config:/etc/postgresql-custom" vector: @@ -111,6 +106,10 @@ services: volumes: !override local-db-data: name: "${PROJECT_NAME}-db-data" + local-db-init-migrations: + name: "${PROJECT_NAME}-db-init-migrations" + local-db-init-scripts: + name: "${PROJECT_NAME}-db-init-scripts" local-db-config: name: "${PROJECT_NAME}-db-config" local-storage-data: diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index 989378e3a..1c48a4f88 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -157,27 +157,9 @@ }, { "service": "db", - "source": "db-realtime.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/migrations/99-realtime.sql" - }, - { - "service": "db", - "source": "db-webhooks.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql" - }, - { - "service": "db", - "source": "db-roles.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/init-scripts/99-roles.sql" - }, - { - "service": "db", - "source": "db-jwt.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql" + "source": "local-db-init-migrations", + "type": "volume", + "destination": "/docker-entrypoint-initdb.d/migrations" }, { "service": "db", @@ -187,21 +169,9 @@ }, { "service": "db", - "source": "db-supabase.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/migrations/97-_supabase.sql" - }, - { - "service": "db", - "source": "db-logs.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/migrations/99-logs.sql" - }, - { - "service": "db", - "source": "db-pooler.sql", - "type": "bind", - "destination": "/docker-entrypoint-initdb.d/migrations/99-pooler.sql" + "source": "local-db-init-scripts", + "type": "volume", + "destination": "/docker-entrypoint-initdb.d/init-scripts" }, { "service": "db", @@ -229,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "ba8ad34ed798b306bdfba45c9b0c6410f91ca74b44361afc29b8b531ee555086" + "sha256": "3db0e9dd0ef7f9a0583da5ce4faa9933b43934fa37aa3913a80dff6b46a90298" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index 23767ca11..ece8126f9 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -50,9 +50,20 @@ "db-logs.sql": "volumes/db/logs.sql", "db-pooler.sql": "volumes/db/pooler.sql", } +DB_INIT_VOLUME_FILES = ( + ("db-supabase.sql", "db-init-migrations", "97-_supabase.sql"), + ("db-logs.sql", "db-init-migrations", "99-logs.sql"), + ("db-pooler.sql", "db-init-migrations", "99-pooler.sql"), + ("db-realtime.sql", "db-init-migrations", "99-realtime.sql"), + ("db-webhooks.sql", "db-init-scripts", "98-webhooks.sql"), + ("db-roles.sql", "db-init-scripts", "99-roles.sql"), + ("db-jwt.sql", "db-init-scripts", "99-jwt.sql"), +) DESTINATIONS = { "/home/kong/temp.yml", "/etc/vector/vector.yml", "/etc/pooler/pooler.exs", "/var/lib/postgresql/data", "/etc/postgresql-custom", "/var/lib/storage", + "/docker-entrypoint-initdb.d/migrations", + "/docker-entrypoint-initdb.d/init-scripts", "/home/deno/functions", "/docker-entrypoint-initdb.d/migrations/99-realtime.sql", "/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql", "/docker-entrypoint-initdb.d/init-scripts/99-roles.sql", @@ -71,7 +82,13 @@ DOCKER_SOCKET_DEFAULT = Path("/var/run/docker.sock") DOCKER_SOCKET_DOCKER_DESKTOP = ".docker/run/docker.sock" DOCKER_SOCKET_COLIMA = ".colima/default/docker.sock" -TARGET_VOLUME_SUFFIXES = ("db-data", "db-config", "storage-data") +TARGET_VOLUME_SUFFIXES = ( + "db-data", + "db-config", + "db-init-migrations", + "db-init-scripts", + "storage-data", +) DOCKER_PROJECT_LABEL = "com.docker.compose.project" DOCKER_VOLUME_LABEL = "com.docker.compose.volume" DOCKER_SERVICE_LABEL = "com.docker.compose.service" @@ -1031,7 +1048,7 @@ def _scan_model(model: dict[str, Any], project: str, state: Path, values: dict[s if volume_name not in expected_named_volumes or not isinstance(volume, dict): _fail("model_volume") actual_name = volume.get("name") - if actual_name not in {f"{project}-db-data", f"{project}-db-config", f"{project}-storage-data"}: + if actual_name not in {f"{project}-{suffix}" for suffix in TARGET_VOLUME_SUFFIXES}: _fail("model_volume") if sorted(published_ports) != sorted(expected_ports): _fail("model_ports") @@ -1457,6 +1474,70 @@ def _assert_project_volumes(command: list[str], project: str, *, require_existin if labels.get(DOCKER_PROJECT_LABEL) != project or labels.get(DOCKER_SERVICE_LABEL) not in EXPECTED_SERVICES: _fail("docker_container") + +def _stage_database_init_files(project: str, state: Path) -> None: + for source_name, _volume_suffix, _destination_name in DB_INIT_VOLUME_FILES: + _regular_owned(state / "inputs" / source_name, mode=0o600) + migrations_volume = f"{project}-db-init-migrations" + scripts_volume = f"{project}-db-init-scripts" + commands = [ + "set -eu", + "cp /inputs/db-supabase.sql /migrations/97-_supabase.sql", + "cp /inputs/db-logs.sql /migrations/99-logs.sql", + "cp /inputs/db-pooler.sql /migrations/99-pooler.sql", + "cp /inputs/db-realtime.sql /migrations/99-realtime.sql", + "cp /inputs/db-webhooks.sql /scripts/98-webhooks.sql", + "cp /inputs/db-roles.sql /scripts/99-roles.sql", + "cp /inputs/db-jwt.sql /scripts/99-jwt.sql", + "chmod 0644 /migrations/97-_supabase.sql /migrations/99-logs.sql /migrations/99-pooler.sql /migrations/99-realtime.sql /scripts/98-webhooks.sql /scripts/99-roles.sql /scripts/99-jwt.sql", + ] + helper: str | None = None + try: + result = _run( + [ + "docker", + "create", + "--network", + "none", + "--entrypoint", + "sh", + "-v", + f"{state / 'inputs'}:/inputs:ro,z", + "-v", + f"{migrations_volume}:/migrations:Z", + "-v", + f"{scripts_volume}:/scripts:Z", + "supabase/postgres:15.8.1.085", + "-c", + "; ".join(commands), + ], + error_code="compose_db_init_stage", + ) + helper = result.stdout.strip() + if not re.fullmatch(r"[0-9a-f]{12,64}", helper): + _fail("compose_db_init_stage") + _run( + ["docker", "start", helper], + error_code="compose_db_init_stage", + ) + wait_result = _run( + ["docker", "wait", helper], + error_code="compose_db_init_stage", + ) + if wait_result.stdout.strip() != "0": + _fail("compose_db_init_stage") + finally: + if helper is not None: + try: + _run( + ["docker", "rm", "-f", helper], + timeout=60, + error_code="compose_db_init_cleanup", + ) + except (LocalStackError, OSError, ValueError): + pass + + def _action_render(root: Path, project: str, state: Path) -> dict[str, Any]: digest, _, _ = _render(root, project, state) input_digest, env_digest = _provenance_digests(state) @@ -1485,6 +1566,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) + _stage_database_init_files(project, state) for services, wait_for in CORE_START_PHASES: for service in services: _run( diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 94f387afc..93bc708b7 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -57,7 +57,13 @@ def test_overlay_pins_renderer_and_resets_base_fixed_names(self) -> None: else: self.assertIn("container_name: !reset null", block, service) self.assertIn("image: inbucket/inbucket:3.0.3", self.mail_source) - for volume in ("db-data", "db-config", "storage-data"): + for volume in ( + "db-data", + "db-config", + "db-init-migrations", + "db-init-scripts", + "storage-data", + ): self.assertIn(f'name: "${{PROJECT_NAME}}-{volume}"', self.overlay_source) def test_ports_and_urls_are_loopback_only(self) -> None: @@ -111,7 +117,7 @@ def test_project_name_is_fixed_to_repository_identity_not_container_names(self) def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> None: for output, relative in local_stack.TRACKED_SQL.items(): - self.assertIn(f'"${{LOCAL_INPUT_ROOT}}/{output}:/', self.overlay_source) + self.assertNotIn(f'"${{LOCAL_INPUT_ROOT}}/{output}:/', self.overlay_source) self.assertEqual(relative.split("/")[0], "volumes") self.assertNotIn("${HOME}", self.overlay_source) self.assertNotIn("/var/run/docker.sock", self.overlay_source) @@ -119,6 +125,14 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non for destination in local_stack.DESTINATIONS: self.assertIsInstance(destination, str) self.assertTrue(destination.startswith("/")) + self.assertIn( + '"local-db-init-migrations:/docker-entrypoint-initdb.d/migrations:Z"', + self.overlay_source, + ) + self.assertIn( + '"local-db-init-scripts:/docker-entrypoint-initdb.d/init-scripts:Z"', + self.overlay_source, + ) self.assertIn('"${LOCAL_INPUT_ROOT}/functions:/home/deno/functions:ro,Z"', self.overlay_source) self.assertIn('"${LOCAL_INPUT_ROOT}/vector.yml:/etc/vector/vector.yml:ro,z"', self.overlay_source) self.assertIn('"${LOCAL_INPUT_ROOT}/pooler.exs:/etc/pooler/pooler.exs:ro,z"', self.overlay_source) @@ -237,9 +251,12 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("_probe_database_bootstrap", source) self.assertIn("_analytics", source) self.assertIn("pg_namespace", source) - self.assertNotIn("_stage_database_init_files", source) - self.assertNotIn("compose_db_init_stage", source) - self.assertNotIn('"docker", "cp"', source) + self.assertIn("DB_INIT_VOLUME_FILES", source) + self.assertIn("_stage_database_init_files", source) + self.assertIn("compose_db_init_stage", source) + self.assertIn('"create",', source) + self.assertIn('"wait",', source) + self.assertIn("chmod 0644", source) self.assertIn('"create", "--force-recreate", "--pull=missing"', source) self.assertIn('command + ["start", service]', source) self.assertIn("_COMPOSE_ERROR_MARKERS", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 864bb2735..56e01da98 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -77,8 +77,13 @@ instead of relying on one collective `up` orchestration call. Vector, database, and analytics readiness gates run before dependent services are started; the database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. -The hosted lane removes only the three exact project volume names before reset -so diagnostic probes cannot leave a stale database volume behind. +The SQL input files remain owner-only in the checkout. Before the database is +started, a network-isolated helper copies the seven fixed SQL inputs into the +disposable database init volumes with container-readable modes; the helper is +removed before service startup and those volumes are removed during teardown. +The hosted lane removes only the five exact project volume names before reset +and verifies that each is absent, so diagnostic probes cannot leave a stale +database or init-input volume behind. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 865ad923dc6d7663cdbd29f467965abc797c1706 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 08:37:09 +0900 Subject: [PATCH 22/47] fix(local-stack): stage non-root config inputs --- .../workflows/nightly-local-regression.yml | 6 ++- .../nightly-regression-workflow.test.ts | 4 ++ backend/supabase/docker-compose.local.yml | 16 ++++-- .../supabase/local-inputs/manifest.v1.json | 24 ++++----- backend/supabase/scripts/local-stack.py | 54 +++++++++++++------ .../tests/test_local_compose_inputs.py | 17 +++--- docs/operations/nightly-regression.md | 14 ++--- 7 files changed, 88 insertions(+), 47 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index dd0814f4e..a840c1389 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -491,7 +491,11 @@ jobs: "${LOCAL_PROJECT}-db-config" \ "${LOCAL_PROJECT}-db-init-migrations" \ "${LOCAL_PROJECT}-db-init-scripts" \ - "${LOCAL_PROJECT}-storage-data"; do + "${LOCAL_PROJECT}-functions" \ + "${LOCAL_PROJECT}-kong-config" \ + "${LOCAL_PROJECT}-pooler-config" \ + "${LOCAL_PROJECT}-storage-data" \ + "${LOCAL_PROJECT}-vector-config"; do mapfile -t consumers < <(docker ps -aq --filter "volume=$volume") if ((${#consumers[@]})); then docker rm -f "${consumers[@]}" diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index c421c0c83..8150b097a 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -333,7 +333,11 @@ describe("nightly regression package and source contracts", () => { "${LOCAL_PROJECT}-db-config", "${LOCAL_PROJECT}-db-init-migrations", "${LOCAL_PROJECT}-db-init-scripts", + "${LOCAL_PROJECT}-functions", + "${LOCAL_PROJECT}-kong-config", + "${LOCAL_PROJECT}-pooler-config", "${LOCAL_PROJECT}-storage-data", + "${LOCAL_PROJECT}-vector-config", ]) { expect(localWorkflowSource).toContain(token); } diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 077e44af4..0e5945b45 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -45,7 +45,7 @@ services: - "127.0.0.1:${KONG_HTTP_PORT}:8000/tcp" - "127.0.0.1:${KONG_HTTPS_PORT}:8443/tcp" volumes: !override - - "${LOCAL_INPUT_ROOT}/kong.yml:/home/kong/temp.yml:ro,z" + - "local-kong-config:/home/kong:Z" auth: container_name: !reset null @@ -74,7 +74,7 @@ services: functions: container_name: !reset null volumes: !override - - "${LOCAL_INPUT_ROOT}/functions:/home/deno/functions:ro,Z" + - "local-functions:/home/deno/functions:Z" analytics: container_name: !reset null @@ -92,7 +92,7 @@ services: vector: container_name: !reset null volumes: !override - - "${LOCAL_INPUT_ROOT}/vector.yml:/etc/vector/vector.yml:ro,z" + - "local-vector-config:/etc/vector:Z" supavisor: container_name: !reset null @@ -101,7 +101,7 @@ services: - "127.0.0.1:${POSTGRES_HOST_PORT}:5432/tcp" - "127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543/tcp" volumes: !override - - "${LOCAL_INPUT_ROOT}/pooler.exs:/etc/pooler/pooler.exs:ro,z" + - "local-pooler-config:/etc/pooler:Z" volumes: !override local-db-data: @@ -112,5 +112,13 @@ volumes: !override name: "${PROJECT_NAME}-db-init-scripts" local-db-config: name: "${PROJECT_NAME}-db-config" + local-functions: + name: "${PROJECT_NAME}-functions" + local-kong-config: + name: "${PROJECT_NAME}-kong-config" + local-pooler-config: + name: "${PROJECT_NAME}-pooler-config" local-storage-data: name: "${PROJECT_NAME}-storage-data" + local-vector-config: + name: "${PROJECT_NAME}-vector-config" diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index 1c48a4f88..63144ffed 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -133,9 +133,9 @@ "mounts": [ { "service": "kong", - "source": "kong.yml", - "type": "bind", - "destination": "/home/kong/temp.yml" + "source": "local-kong-config", + "type": "volume", + "destination": "/home/kong" }, { "service": "storage", @@ -151,8 +151,8 @@ }, { "service": "functions", - "source": "functions", - "type": "bind", + "source": "local-functions", + "type": "volume", "destination": "/home/deno/functions" }, { @@ -181,15 +181,15 @@ }, { "service": "vector", - "source": "vector.yml", - "type": "bind", - "destination": "/etc/vector/vector.yml" + "source": "local-vector-config", + "type": "volume", + "destination": "/etc/vector" }, { "service": "supavisor", - "source": "pooler.exs", - "type": "bind", - "destination": "/etc/pooler/pooler.exs" + "source": "local-pooler-config", + "type": "volume", + "destination": "/etc/pooler" } ], "compose_files": [ @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "3db0e9dd0ef7f9a0583da5ce4faa9933b43934fa37aa3913a80dff6b46a90298" + "sha256": "70a2f0ddec40c2e24b16e02e0975d947c0d051a0f8803a3f72ddd08c9a3e2852" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/scripts/local-stack.py b/backend/supabase/scripts/local-stack.py index ece8126f9..684ff4385 100644 --- a/backend/supabase/scripts/local-stack.py +++ b/backend/supabase/scripts/local-stack.py @@ -50,7 +50,11 @@ "db-logs.sql": "volumes/db/logs.sql", "db-pooler.sql": "volumes/db/pooler.sql", } -DB_INIT_VOLUME_FILES = ( +STAGED_INPUT_FILES = ( + ("kong.yml", "kong-config", "temp.yml"), + ("vector.yml", "vector-config", "vector.yml"), + ("pooler.exs", "pooler-config", "pooler.exs"), + ("functions/main/index.ts", "functions", "main/index.ts"), ("db-supabase.sql", "db-init-migrations", "97-_supabase.sql"), ("db-logs.sql", "db-init-migrations", "99-logs.sql"), ("db-pooler.sql", "db-init-migrations", "99-pooler.sql"), @@ -60,7 +64,8 @@ ("db-jwt.sql", "db-init-scripts", "99-jwt.sql"), ) DESTINATIONS = { - "/home/kong/temp.yml", "/etc/vector/vector.yml", "/etc/pooler/pooler.exs", + "/home/kong", "/home/kong/temp.yml", "/etc/vector", "/etc/vector/vector.yml", + "/etc/pooler", "/etc/pooler/pooler.exs", "/var/lib/postgresql/data", "/etc/postgresql-custom", "/var/lib/storage", "/docker-entrypoint-initdb.d/migrations", "/docker-entrypoint-initdb.d/init-scripts", @@ -87,7 +92,11 @@ "db-config", "db-init-migrations", "db-init-scripts", + "functions", + "kong-config", + "pooler-config", "storage-data", + "vector-config", ) DOCKER_PROJECT_LABEL = "com.docker.compose.project" DOCKER_VOLUME_LABEL = "com.docker.compose.volume" @@ -1475,13 +1484,24 @@ def _assert_project_volumes(command: list[str], project: str, *, require_existin _fail("docker_container") -def _stage_database_init_files(project: str, state: Path) -> None: - for source_name, _volume_suffix, _destination_name in DB_INIT_VOLUME_FILES: +def _stage_input_files(project: str, state: Path) -> None: + for source_name, _volume_suffix, _destination_name in STAGED_INPUT_FILES: _regular_owned(state / "inputs" / source_name, mode=0o600) - migrations_volume = f"{project}-db-init-migrations" - scripts_volume = f"{project}-db-init-scripts" + volume_paths = { + "kong-config": "/kong", + "vector-config": "/vector", + "pooler-config": "/pooler", + "functions": "/functions", + "db-init-migrations": "/migrations", + "db-init-scripts": "/scripts", + } commands = [ "set -eu", + "mkdir -p /functions/main", + "cp /inputs/kong.yml /kong/temp.yml", + "cp /inputs/vector.yml /vector/vector.yml", + "cp /inputs/pooler.exs /pooler/pooler.exs", + "cp /inputs/functions/main/index.ts /functions/main/index.ts", "cp /inputs/db-supabase.sql /migrations/97-_supabase.sql", "cp /inputs/db-logs.sql /migrations/99-logs.sql", "cp /inputs/db-pooler.sql /migrations/99-pooler.sql", @@ -1489,8 +1509,11 @@ def _stage_database_init_files(project: str, state: Path) -> None: "cp /inputs/db-webhooks.sql /scripts/98-webhooks.sql", "cp /inputs/db-roles.sql /scripts/99-roles.sql", "cp /inputs/db-jwt.sql /scripts/99-jwt.sql", - "chmod 0644 /migrations/97-_supabase.sql /migrations/99-logs.sql /migrations/99-pooler.sql /migrations/99-realtime.sql /scripts/98-webhooks.sql /scripts/99-roles.sql /scripts/99-jwt.sql", + "chmod 0644 /kong/temp.yml /vector/vector.yml /pooler/pooler.exs /functions/main/index.ts /migrations/97-_supabase.sql /migrations/99-logs.sql /migrations/99-pooler.sql /migrations/99-realtime.sql /scripts/98-webhooks.sql /scripts/99-roles.sql /scripts/99-jwt.sql", ] + volume_args: list[str] = [] + for suffix, path in volume_paths.items(): + volume_args.extend(("-v", f"{project}-{suffix}:{path}:Z")) helper: str | None = None try: result = _run( @@ -1503,29 +1526,26 @@ def _stage_database_init_files(project: str, state: Path) -> None: "sh", "-v", f"{state / 'inputs'}:/inputs:ro,z", - "-v", - f"{migrations_volume}:/migrations:Z", - "-v", - f"{scripts_volume}:/scripts:Z", + *volume_args, "supabase/postgres:15.8.1.085", "-c", "; ".join(commands), ], - error_code="compose_db_init_stage", + error_code="compose_input_stage", ) helper = result.stdout.strip() if not re.fullmatch(r"[0-9a-f]{12,64}", helper): - _fail("compose_db_init_stage") + _fail("compose_input_stage") _run( ["docker", "start", helper], - error_code="compose_db_init_stage", + error_code="compose_input_stage", ) wait_result = _run( ["docker", "wait", helper], - error_code="compose_db_init_stage", + error_code="compose_input_stage", ) if wait_result.stdout.strip() != "0": - _fail("compose_db_init_stage") + _fail("compose_input_stage") finally: if helper is not None: try: @@ -1566,7 +1586,7 @@ def _action_start(root: Path, project: str, state: Path) -> dict[str, Any]: error_code="compose_core_create", retries=COMPOSE_START_RETRIES, ) - _stage_database_init_files(project, state) + _stage_input_files(project, state) for services, wait_for in CORE_START_PHASES: for service in services: _run( diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 93bc708b7..e6e5b44bc 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -62,7 +62,11 @@ def test_overlay_pins_renderer_and_resets_base_fixed_names(self) -> None: "db-config", "db-init-migrations", "db-init-scripts", + "functions", + "kong-config", + "pooler-config", "storage-data", + "vector-config", ): self.assertIn(f'name: "${{PROJECT_NAME}}-{volume}"', self.overlay_source) @@ -133,9 +137,10 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non '"local-db-init-scripts:/docker-entrypoint-initdb.d/init-scripts:Z"', self.overlay_source, ) - self.assertIn('"${LOCAL_INPUT_ROOT}/functions:/home/deno/functions:ro,Z"', self.overlay_source) - self.assertIn('"${LOCAL_INPUT_ROOT}/vector.yml:/etc/vector/vector.yml:ro,z"', self.overlay_source) - self.assertIn('"${LOCAL_INPUT_ROOT}/pooler.exs:/etc/pooler/pooler.exs:ro,z"', self.overlay_source) + self.assertIn('"local-functions:/home/deno/functions:Z"', self.overlay_source) + self.assertIn('"local-kong-config:/home/kong:Z"', self.overlay_source) + self.assertIn('"local-vector-config:/etc/vector:Z"', self.overlay_source) + self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -251,9 +256,9 @@ def test_local_stack_rejects_remote_contexts_and_unowned_volume_resources(self) self.assertIn("_probe_database_bootstrap", source) self.assertIn("_analytics", source) self.assertIn("pg_namespace", source) - self.assertIn("DB_INIT_VOLUME_FILES", source) - self.assertIn("_stage_database_init_files", source) - self.assertIn("compose_db_init_stage", source) + self.assertIn("STAGED_INPUT_FILES", source) + self.assertIn("_stage_input_files", source) + self.assertIn("compose_input_stage", source) self.assertIn('"create",', source) self.assertIn('"wait",', source) self.assertIn("chmod 0644", source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 56e01da98..589a1c23f 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -77,13 +77,13 @@ instead of relying on one collective `up` orchestration call. Vector, database, and analytics readiness gates run before dependent services are started; the database gate also verifies the `_analytics` bootstrap schema with a 900-second first-run bound. -The SQL input files remain owner-only in the checkout. Before the database is -started, a network-isolated helper copies the seven fixed SQL inputs into the -disposable database init volumes with container-readable modes; the helper is -removed before service startup and those volumes are removed during teardown. -The hosted lane removes only the five exact project volume names before reset -and verifies that each is absent, so diagnostic probes cannot leave a stale -database or init-input volume behind. +The local input files remain owner-only in the checkout. Before service startup, +a network-isolated helper copies the fixed local inputs into disposable +configuration and database-init volumes with container-readable modes; the +helper is removed before service startup and those volumes are removed during +teardown. The hosted lane removes only the nine exact project volume names +before reset and verifies that each is absent, so diagnostic probes cannot leave +stale database or configuration state behind. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From dd6ba96669bc0c4de4a4d19e9c3137f0e577d55b Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 09:00:06 +0900 Subject: [PATCH 23/47] test(ci): classify Supavisor startup failures --- .github/workflows/nightly-local-regression.yml | 3 +++ apps/web/tests-unit/nightly-regression-workflow.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index a840c1389..b3f8dfdcb 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -610,6 +610,9 @@ jobs: allowed_states = {'created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead'} allowed_health = {'', 'starting', 'healthy', 'unhealthy', 'none'} markers = ( + ('database_permission', ('permission denied for schema', 'permission denied for table', 'permission denied for relation', 'insufficient privilege')), + ('config_permission', ('permission denied: /etc', 'permission denied opening', 'eacces')), + ('auth_failure', ('password authentication failed', 'authentication failed')), ('permission_denied', ('permission denied', 'operation not permitted')), ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 8150b097a..e5e2cfd61 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -322,6 +322,9 @@ describe("nightly regression package and source contracts", () => { "database_bootstrap", "database_presence", "database_init_files", + "database_permission", + "config_permission", + "auth_failure", "supabase_db_present", "supabase_db_missing", "analytics_schema_present", From d3bfccc7a173fe6cafb822309fc35ab64ec3199e Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 09:13:48 +0900 Subject: [PATCH 24/47] test(ci): classify Tini startup failures --- .github/workflows/nightly-local-regression.yml | 1 + apps/web/tests-unit/nightly-regression-workflow.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index b3f8dfdcb..2aa39372d 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -613,6 +613,7 @@ jobs: ('database_permission', ('permission denied for schema', 'permission denied for table', 'permission denied for relation', 'insufficient privilege')), ('config_permission', ('permission denied: /etc', 'permission denied opening', 'eacces')), ('auth_failure', ('password authentication failed', 'authentication failed')), + ('tini_runtime', ('pr_set_child_subreaper', 'subreaper', 'tini')), ('permission_denied', ('permission denied', 'operation not permitted')), ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index e5e2cfd61..b59472ebe 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -325,6 +325,7 @@ describe("nightly regression package and source contracts", () => { "database_permission", "config_permission", "auth_failure", + "tini_runtime", "supabase_db_present", "supabase_db_missing", "analytics_schema_present", From 59b72c0e9d6db314acd79949a106f5c49de88f09 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 09:41:11 +0900 Subject: [PATCH 25/47] test(ci): verify staged runtime mounts --- .../workflows/nightly-local-regression.yml | 45 ++++++++++++++++++- .../nightly-regression-workflow.test.ts | 2 + 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 2aa39372d..ac9cc2be6 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -614,7 +614,8 @@ jobs: ('config_permission', ('permission denied: /etc', 'permission denied opening', 'eacces')), ('auth_failure', ('password authentication failed', 'authentication failed')), ('tini_runtime', ('pr_set_child_subreaper', 'subreaper', 'tini')), - ('permission_denied', ('permission denied', 'operation not permitted')), + ('operation_not_permitted', ('operation not permitted',)), + ('permission_denied', ('permission denied',)), ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), ('database_connection', ('connection refused', 'econnrefused', 'failed to connect')), @@ -642,6 +643,7 @@ jobs: except (OSError, subprocess.SubprocessError): ids = [] db_container_id = None + service_payloads = {} for container_id in ids: try: raw = subprocess.check_output( @@ -662,6 +664,7 @@ jobs: continue if service == 'db': db_container_id = container_id + service_payloads[service] = payload state = payload.get('State') or {} health = state.get('Health') or {} health_log = health.get('Log') or [] @@ -706,6 +709,44 @@ jobs: 'supabase': 'unknown', 'logs': 'unknown', } + runtime_input_checks = {} + for service, destination, path in ( + ('kong', '/home/kong', '/home/kong/temp.yml'), + ('supavisor', '/etc/pooler', '/etc/pooler/pooler.exs'), + ): + payload = service_payloads.get(service) + mounts = payload.get('Mounts') if isinstance(payload, dict) else None + mount = next( + ( + item for item in mounts + if isinstance(item, dict) and item.get('Destination') == destination + ), + None, + ) if isinstance(mounts, list) else None + expected_name = f'{project}-{ "kong-config" if service == "kong" else "pooler-config" }' + runtime_input_checks[service] = { + 'mount': 'passed' if isinstance(mount, dict) and mount.get('Type') == 'volume' and mount.get('Name') == expected_name else 'failed', + 'file': 'unknown', + } + container_id = None + if isinstance(payload, dict): + container_id = payload.get('Id') + if isinstance(container_id, str) and re.fullmatch(r'[0-9a-f]{12,64}', container_id): + try: + probe = subprocess.run( + ['docker', 'exec', container_id, 'test', '-r', path], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + runtime_input_checks[service]['file'] = ( + 'passed' if probe.returncode == 0 + else 'missing' if probe.returncode == 1 + else 'unknown' + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + runtime_input_checks[service]['file'] = 'unknown' if db_container_id is not None: try: presence = subprocess.run( @@ -790,6 +831,7 @@ jobs: 'supabase': 'unknown', 'logs': 'unknown', } + runtime_input_checks = {} records.sort(key=lambda item: item['service']) pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-runtime-diagnostics.json').write_text( json.dumps({ @@ -798,6 +840,7 @@ jobs: 'database_bootstrap': database_bootstrap, 'database_presence': database_presence, 'database_init_files': database_init_files, + 'runtime_input_checks': runtime_input_checks, 'containers': records, }, sort_keys=True, separators=(',', ':')) + '\n', encoding='utf-8', diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index b59472ebe..5b6c5a48c 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -326,6 +326,8 @@ describe("nightly regression package and source contracts", () => { "config_permission", "auth_failure", "tini_runtime", + "operation_not_permitted", + "runtime_input_checks", "supabase_db_present", "supabase_db_missing", "analytics_schema_present", From 7d9f8a46508a731e39c5ba3d7d15b90a146873a3 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 09:54:15 +0900 Subject: [PATCH 26/47] fix(ci): import runtime diagnostic validator --- .github/workflows/nightly-local-regression.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index ac9cc2be6..0d64cc6f5 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -242,6 +242,7 @@ jobs: import json import os import pathlib + import re import subprocess import sys From 71f5ee4aec3e311edc6744941b42ae0179cc3e41 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 10:06:56 +0900 Subject: [PATCH 27/47] fix(ci): import runtime mount diagnostics --- .github/workflows/nightly-local-regression.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 0d64cc6f5..5deb49f39 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -604,6 +604,7 @@ jobs: import json import os import pathlib + import re import subprocess project = os.environ.get('LOCAL_PROJECT', '') From a9f0a755ce275f1526ea55410ab3c90a3f54abf3 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 10:22:43 +0900 Subject: [PATCH 28/47] fix(local-stack): bypass denied Supavisor subreaper --- backend/supabase/docker-compose.local.yml | 3 +++ backend/supabase/local-inputs/manifest.v1.json | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 1 + docs/operations/nightly-regression.md | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 0e5945b45..65e3a2eb0 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -96,6 +96,9 @@ services: supavisor: container_name: !reset null + # GitHub-hosted Docker denies Tini's subreaper setup; the image's limits + # wrapper preserves bounded startup without requiring that capability. + entrypoint: ["/app/limits.sh"] ports: !override # POSTGRES_PORT remains the internal 5432 listener; only the host mapping is derived. - "127.0.0.1:${POSTGRES_HOST_PORT}:5432/tcp" diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index 63144ffed..1318a91c8 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "70a2f0ddec40c2e24b16e02e0975d947c0d051a0f8803a3f72ddd08c9a3e2852" + "sha256": "8d44e457d9b25bc1038898d441c76f1af7fc6e7204d5fd2243dbaa58deef7f66" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index e6e5b44bc..5cb1316b3 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -141,6 +141,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-kong-config:/home/kong:Z"', self.overlay_source) self.assertIn('"local-vector-config:/etc/vector:Z"', self.overlay_source) self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) + self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 589a1c23f..f15b7ae15 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -84,6 +84,8 @@ helper is removed before service startup and those volumes are removed during teardown. The hosted lane removes only the nine exact project volume names before reset and verifies that each is absent, so diagnostic probes cannot leave stale database or configuration state behind. +The local Supavisor overlay invokes the image's limits wrapper directly because +the hosted Docker namespace denies Tini's optional subreaper capability. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 9cf535599be57663f505d76257fbd3c1ce6c08a2 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 10:35:56 +0900 Subject: [PATCH 29/47] test(ci): record Supavisor entrypoint evidence --- .github/workflows/nightly-local-regression.yml | 6 ++++++ apps/web/tests-unit/nightly-regression-workflow.test.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 5deb49f39..39f40f09a 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -617,6 +617,7 @@ jobs: ('auth_failure', ('password authentication failed', 'authentication failed')), ('tini_runtime', ('pr_set_child_subreaper', 'subreaper', 'tini')), ('operation_not_permitted', ('operation not permitted',)), + ('erl_runtime', ('cpu_sup', 'memsup', 'inet_gethost', 'failed to start epmd', 'could not start application')), ('permission_denied', ('permission denied',)), ('mount_invalid', ('invalid mount', 'no such file or directory')), ('runtime_error', ('failed to start', 'failed to create', 'exec format error')), @@ -694,6 +695,11 @@ jobs: for item in health_log[-5:] if isinstance(item, dict) and isinstance(item.get('ExitCode'), int) and 0 <= item.get('ExitCode') <= 255 ], + 'entrypoint_class': ( + 'limits_wrapper' + if (payload.get('Config') or {}).get('Entrypoint') == ['/app/limits.sh'] + else 'image_default' + ), }) database_bootstrap = { 'status': 'unavailable', diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 5b6c5a48c..a81b4371a 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -327,7 +327,9 @@ describe("nightly regression package and source contracts", () => { "auth_failure", "tini_runtime", "operation_not_permitted", + "erl_runtime", "runtime_input_checks", + "entrypoint_class", "supabase_db_present", "supabase_db_missing", "analytics_schema_present", From 19fe208370a92000012c0d171dc3e9a985d3822d Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 10:48:35 +0900 Subject: [PATCH 30/47] test(ci): retain bounded runtime signatures --- .github/workflows/nightly-local-regression.yml | 8 ++++++++ apps/web/tests-unit/nightly-regression-workflow.test.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 39f40f09a..27779650a 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -633,6 +633,13 @@ jobs: return suffix return 'none' if not normalized else 'unknown' + def signatures(value): + normalized = str(value or '').casefold() + return [ + suffix for suffix, values in markers + if any(marker in normalized for marker in values) + ][:4] + if project.startswith('tzudong-local-'): try: ids = subprocess.check_output( @@ -688,6 +695,7 @@ jobs: 'oom_killed': state.get('OOMKilled') is True, 'restart_count': payload.get('RestartCount') if isinstance(payload.get('RestartCount'), int) and payload.get('RestartCount') >= 0 else None, 'log_failure_class': classify(logs), + 'log_signatures': signatures(logs), 'health': health.get('Status') if health.get('Status') in allowed_health else 'unknown', 'health_failing_streak': health.get('FailingStreak') if isinstance(health.get('FailingStreak'), int) and health.get('FailingStreak') >= 0 else None, 'health_log_exit_codes': [ diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index a81b4371a..9703afe84 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -330,6 +330,7 @@ describe("nightly regression package and source contracts", () => { "erl_runtime", "runtime_input_checks", "entrypoint_class", + "log_signatures", "supabase_db_present", "supabase_db_missing", "analytics_schema_present", From 96f024a0416051eb4db568fd3c3e45bb34dc3579 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 11:09:41 +0900 Subject: [PATCH 31/47] fix(local-stack): disable unsupported Supavisor clustering --- backend/supabase/docker-compose.local.yml | 2 ++ backend/supabase/local-inputs/manifest.v1.json | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 65e3a2eb0..4dc591846 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -99,6 +99,8 @@ services: # GitHub-hosted Docker denies Tini's subreaper setup; the image's limits # wrapper preserves bounded startup without requiring that capability. entrypoint: ["/app/limits.sh"] + environment: + CLUSTER_POSTGRES: !reset null ports: !override # POSTGRES_PORT remains the internal 5432 listener; only the host mapping is derived. - "127.0.0.1:${POSTGRES_HOST_PORT}:5432/tcp" diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index 1318a91c8..9db143062 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "8d44e457d9b25bc1038898d441c76f1af7fc6e7204d5fd2243dbaa58deef7f66" + "sha256": "d06b21814343dfccacd37eabd6808e8fe834922ab109cac9da35f372b253143f" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 5cb1316b3..9685965e7 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -142,6 +142,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-vector-config:/etc/vector:Z"', self.overlay_source) self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) + self.assertIn("CLUSTER_POSTGRES: !reset null", self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: From a44ac3697cc575677cd80ea6fe6aa44e85ddd37a Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 11:28:45 +0900 Subject: [PATCH 32/47] fix(local-stack): disable unused Erlang distribution --- backend/supabase/docker-compose.local.yml | 1 + backend/supabase/local-inputs/manifest.v1.json | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 4dc591846..ebf0c8c1a 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -101,6 +101,7 @@ services: entrypoint: ["/app/limits.sh"] environment: CLUSTER_POSTGRES: !reset null + ERL_AFLAGS: !reset null ports: !override # POSTGRES_PORT remains the internal 5432 listener; only the host mapping is derived. - "127.0.0.1:${POSTGRES_HOST_PORT}:5432/tcp" diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index 9db143062..af97773e3 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "d06b21814343dfccacd37eabd6808e8fe834922ab109cac9da35f372b253143f" + "sha256": "3c5d97c9508a32885f7e6ccc6fd9f39c1cb2947775248ea140c1c2c963590587" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 9685965e7..0f60c5c2e 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -143,6 +143,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) self.assertIn("CLUSTER_POSTGRES: !reset null", self.overlay_source) + self.assertIn("ERL_AFLAGS: !reset null", self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: From 79031dfcdab5ddb6cfc7680646d72d3c0645b54b Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 11:45:25 +0900 Subject: [PATCH 33/47] fix(local-stack): allow Erlang startup syscalls --- backend/supabase/docker-compose.local.yml | 5 +++++ backend/supabase/tests/test_local_compose_inputs.py | 1 + docs/operations/nightly-regression.md | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index ebf0c8c1a..86fb1a116 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -99,6 +99,11 @@ services: # GitHub-hosted Docker denies Tini's subreaper setup; the image's limits # wrapper preserves bounded startup without requiring that capability. entrypoint: ["/app/limits.sh"] + # The hosted disposable user namespace rejects Erlang's startup thread + # syscalls under the default profile; this service has no host mounts or + # socket access and remains isolated to the local Compose network. + security_opt: + - "seccomp=unconfined" environment: CLUSTER_POSTGRES: !reset null ERL_AFLAGS: !reset null diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 0f60c5c2e..0eba1ecc7 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -142,6 +142,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-vector-config:/etc/vector:Z"', self.overlay_source) self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) + self.assertIn('seccomp=unconfined', self.overlay_source) self.assertIn("CLUSTER_POSTGRES: !reset null", self.overlay_source) self.assertIn("ERL_AFLAGS: !reset null", self.overlay_source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index f15b7ae15..dbd19bb65 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -85,7 +85,10 @@ teardown. The hosted lane removes only the nine exact project volume names before reset and verifies that each is absent, so diagnostic probes cannot leave stale database or configuration state behind. The local Supavisor overlay invokes the image's limits wrapper directly because -the hosted Docker namespace denies Tini's optional subreaper capability. +the hosted Docker namespace denies Tini's optional subreaper capability. It +also uses an unconfined seccomp profile only for that network-isolated +Supavisor process because the hosted profile rejects its Erlang startup thread +syscalls; the service has no host mounts or Docker socket access. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 7a2d8282fb1e5739574119498abe729333c18e06 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 11:52:49 +0900 Subject: [PATCH 34/47] revert(local-stack): keep hosted seccomp default --- backend/supabase/docker-compose.local.yml | 5 ----- backend/supabase/tests/test_local_compose_inputs.py | 2 +- docs/operations/nightly-regression.md | 5 +---- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 86fb1a116..ebf0c8c1a 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -99,11 +99,6 @@ services: # GitHub-hosted Docker denies Tini's subreaper setup; the image's limits # wrapper preserves bounded startup without requiring that capability. entrypoint: ["/app/limits.sh"] - # The hosted disposable user namespace rejects Erlang's startup thread - # syscalls under the default profile; this service has no host mounts or - # socket access and remains isolated to the local Compose network. - security_opt: - - "seccomp=unconfined" environment: CLUSTER_POSTGRES: !reset null ERL_AFLAGS: !reset null diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 0eba1ecc7..980f524e5 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -142,7 +142,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-vector-config:/etc/vector:Z"', self.overlay_source) self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) - self.assertIn('seccomp=unconfined', self.overlay_source) + self.assertIn("CLUSTER_POSTGRES: !reset null", self.overlay_source) self.assertIn("ERL_AFLAGS: !reset null", self.overlay_source) diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index dbd19bb65..f15b7ae15 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -85,10 +85,7 @@ teardown. The hosted lane removes only the nine exact project volume names before reset and verifies that each is absent, so diagnostic probes cannot leave stale database or configuration state behind. The local Supavisor overlay invokes the image's limits wrapper directly because -the hosted Docker namespace denies Tini's optional subreaper capability. It -also uses an unconfined seccomp profile only for that network-isolated -Supavisor process because the hosted profile rejects its Erlang startup thread -syscalls; the service has no host mounts or Docker socket access. +the hosted Docker namespace denies Tini's optional subreaper capability. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From fc24d391fd93b87b4934badc42a928da6d105d72 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 11:54:21 +0900 Subject: [PATCH 35/47] test(ci): classify Erlang permission failures --- .../workflows/nightly-local-regression.yml | 21 +++++++++++++++++++ .../nightly-regression-workflow.test.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 27779650a..46da8688a 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -616,6 +616,12 @@ jobs: ('config_permission', ('permission denied: /etc', 'permission denied opening', 'eacces')), ('auth_failure', ('password authentication failed', 'authentication failed')), ('tini_runtime', ('pr_set_child_subreaper', 'subreaper', 'tini')), + ('thread_create', ('failed to create thread', 'pthread_create', 'clone3', 'resource temporarily unavailable')), + ('scheduler_permission', ('setpriority', 'sched_', 'scheduler')), + ('procfs_permission', ('/proc', 'cgroup', 'sysfs')), + ('socket_permission', ('setsockopt', 'bind', 'listen')), + ('signal_permission', ('prctl', 'signal')), + ('namespace_permission', ('user namespace', 'setns', 'namespace')), ('operation_not_permitted', ('operation not permitted',)), ('erl_runtime', ('cpu_sup', 'memsup', 'inet_gethost', 'failed to start epmd', 'could not start application')), ('permission_denied', ('permission denied',)), @@ -726,6 +732,19 @@ jobs: 'logs': 'unknown', } runtime_input_checks = {} + runtime_env_presence = {} + for service in ('supavisor',): + payload = service_payloads.get(service) + env_values = (payload.get('Config') or {}).get('Env') if isinstance(payload, dict) else None + env_names = { + item.split('=', 1)[0] + for item in env_values + if isinstance(item, str) and '=' in item + } if isinstance(env_values, list) else set() + runtime_env_presence[service] = { + key: key in env_names + for key in ('CLUSTER_POSTGRES', 'ERL_AFLAGS', 'ERL_FLAGS', 'CLUSTER_NODES', 'DNS_POLL') + } for service, destination, path in ( ('kong', '/home/kong', '/home/kong/temp.yml'), ('supavisor', '/etc/pooler', '/etc/pooler/pooler.exs'), @@ -848,6 +867,7 @@ jobs: 'logs': 'unknown', } runtime_input_checks = {} + runtime_env_presence = {} records.sort(key=lambda item: item['service']) pathlib.Path('nightly-artifacts/failure-diagnostics/local-compose-runtime-diagnostics.json').write_text( json.dumps({ @@ -857,6 +877,7 @@ jobs: 'database_presence': database_presence, 'database_init_files': database_init_files, 'runtime_input_checks': runtime_input_checks, + 'runtime_env_presence': runtime_env_presence, 'containers': records, }, sort_keys=True, separators=(',', ':')) + '\n', encoding='utf-8', diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 9703afe84..c53e46a21 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -326,9 +326,16 @@ describe("nightly regression package and source contracts", () => { "config_permission", "auth_failure", "tini_runtime", + "thread_create", + "scheduler_permission", + "procfs_permission", + "socket_permission", + "signal_permission", + "namespace_permission", "operation_not_permitted", "erl_runtime", "runtime_input_checks", + "runtime_env_presence", "entrypoint_class", "log_signatures", "supabase_db_present", From 94b3ff262526ede523e4c7e1705a71f705fb6112 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 12:07:09 +0900 Subject: [PATCH 36/47] fix(local-stack): clear inherited Erlang environment --- backend/supabase/docker-compose.local.yml | 19 ++++++++++++++++--- .../supabase/local-inputs/manifest.v1.json | 2 +- .../tests/test_local_compose_inputs.py | 5 +++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index ebf0c8c1a..6d6e7eb2c 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -99,9 +99,22 @@ services: # GitHub-hosted Docker denies Tini's subreaper setup; the image's limits # wrapper preserves bounded startup without requiring that capability. entrypoint: ["/app/limits.sh"] - environment: - CLUSTER_POSTGRES: !reset null - ERL_AFLAGS: !reset null + environment: !override + PORT: 4000 + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + VAULT_ENC_KEY: ${VAULT_ENC_KEY} + API_JWT_SECRET: ${JWT_SECRET} + METRICS_JWT_SECRET: ${JWT_SECRET} + REGION: local + POOLER_TENANT_ID: ${POOLER_TENANT_ID} + POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + POOLER_POOL_MODE: transaction + DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE} ports: !override # POSTGRES_PORT remains the internal 5432 listener; only the host mapping is derived. - "127.0.0.1:${POSTGRES_HOST_PORT}:5432/tcp" diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index af97773e3..b8f7f9eaf 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "3c5d97c9508a32885f7e6ccc6fd9f39c1cb2947775248ea140c1c2c963590587" + "sha256": "6310bf147c717652eb1b534f0ab47578d57328123fe424663c7ba303305d2fec" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index 980f524e5..ebdd4c682 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -143,8 +143,9 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn('"local-pooler-config:/etc/pooler:Z"', self.overlay_source) self.assertIn('entrypoint: ["/app/limits.sh"]', self.overlay_source) - self.assertIn("CLUSTER_POSTGRES: !reset null", self.overlay_source) - self.assertIn("ERL_AFLAGS: !reset null", self.overlay_source) + self.assertIn("environment: !override", self.overlay_source) + self.assertNotIn("CLUSTER_POSTGRES", self.overlay_source) + self.assertNotIn("ERL_AFLAGS", self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: From b8d49c22a2323d9e7ee259edfa56ce9812ca427c Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 12:20:33 +0900 Subject: [PATCH 37/47] fix(local-stack): clear image Erlang flags --- .github/workflows/nightly-local-regression.yml | 10 +++++++--- .../web/tests-unit/nightly-regression-workflow.test.ts | 2 ++ backend/supabase/docker-compose.local.yml | 1 + backend/supabase/local-inputs/manifest.v1.json | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 46da8688a..10b3ee890 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -737,12 +737,16 @@ jobs: payload = service_payloads.get(service) env_values = (payload.get('Config') or {}).get('Env') if isinstance(payload, dict) else None env_names = { - item.split('=', 1)[0] + item.split('=', 1)[0]: item.split('=', 1)[1] for item in env_values if isinstance(item, str) and '=' in item - } if isinstance(env_values, list) else set() + } if isinstance(env_values, list) else {} runtime_env_presence[service] = { - key: key in env_names + key: ( + 'absent' if key not in env_names + else 'empty' if env_names[key] == '' + else 'nonempty' + ) for key in ('CLUSTER_POSTGRES', 'ERL_AFLAGS', 'ERL_FLAGS', 'CLUSTER_NODES', 'DNS_POLL') } for service, destination, path in ( diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index c53e46a21..ee0c3f59b 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -336,6 +336,8 @@ describe("nightly regression package and source contracts", () => { "erl_runtime", "runtime_input_checks", "runtime_env_presence", + "nonempty", + "empty", "entrypoint_class", "log_signatures", "supabase_db_present", diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 6d6e7eb2c..3e39d9fb9 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -110,6 +110,7 @@ services: API_JWT_SECRET: ${JWT_SECRET} METRICS_JWT_SECRET: ${JWT_SECRET} REGION: local + ERL_AFLAGS: "" POOLER_TENANT_ID: ${POOLER_TENANT_ID} POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index b8f7f9eaf..fee9b6865 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "6310bf147c717652eb1b534f0ab47578d57328123fe424663c7ba303305d2fec" + "sha256": "e7951223394c0aa8d5fccf232c45de70d0c6bedb95c164a93365f438786ba9d0" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index ebdd4c682..f49cb5560 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -145,7 +145,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn("environment: !override", self.overlay_source) self.assertNotIn("CLUSTER_POSTGRES", self.overlay_source) - self.assertNotIn("ERL_AFLAGS", self.overlay_source) + self.assertIn('ERL_AFLAGS: ""', self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: From 55d4d1e29eb5442eacccf78fd61e9278e9bf182a Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 12:36:07 +0900 Subject: [PATCH 38/47] fix(local-stack): clear Supavisor file limit override --- .github/workflows/nightly-local-regression.yml | 2 +- apps/web/tests-unit/nightly-regression-workflow.test.ts | 1 + backend/supabase/docker-compose.local.yml | 1 + backend/supabase/local-inputs/manifest.v1.json | 2 +- backend/supabase/tests/test_local_compose_inputs.py | 1 + docs/operations/nightly-regression.md | 5 ++++- 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 10b3ee890..9768812b9 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -747,7 +747,7 @@ jobs: else 'empty' if env_names[key] == '' else 'nonempty' ) - for key in ('CLUSTER_POSTGRES', 'ERL_AFLAGS', 'ERL_FLAGS', 'CLUSTER_NODES', 'DNS_POLL') + for key in ('CLUSTER_POSTGRES', 'ERL_AFLAGS', 'ERL_FLAGS', 'RLIMIT_NOFILE', 'CLUSTER_NODES', 'DNS_POLL') } for service, destination, path in ( ('kong', '/home/kong', '/home/kong/temp.yml'), diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index ee0c3f59b..6d4fe51ae 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -336,6 +336,7 @@ describe("nightly regression package and source contracts", () => { "erl_runtime", "runtime_input_checks", "runtime_env_presence", + "RLIMIT_NOFILE", "nonempty", "empty", "entrypoint_class", diff --git a/backend/supabase/docker-compose.local.yml b/backend/supabase/docker-compose.local.yml index 3e39d9fb9..e521d3e46 100644 --- a/backend/supabase/docker-compose.local.yml +++ b/backend/supabase/docker-compose.local.yml @@ -111,6 +111,7 @@ services: METRICS_JWT_SECRET: ${JWT_SECRET} REGION: local ERL_AFLAGS: "" + RLIMIT_NOFILE: "" POOLER_TENANT_ID: ${POOLER_TENANT_ID} POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} diff --git a/backend/supabase/local-inputs/manifest.v1.json b/backend/supabase/local-inputs/manifest.v1.json index fee9b6865..37b7b1056 100644 --- a/backend/supabase/local-inputs/manifest.v1.json +++ b/backend/supabase/local-inputs/manifest.v1.json @@ -199,7 +199,7 @@ }, { "path": "backend/supabase/docker-compose.local.yml", - "sha256": "e7951223394c0aa8d5fccf232c45de70d0c6bedb95c164a93365f438786ba9d0" + "sha256": "d46ddc6ff5bfb3a268d8e2a537c5c202d7e3d6f055fd67759912ad3e8d7c5c8a" }, { "path": "backend/supabase/docker-compose.mail.yml", diff --git a/backend/supabase/tests/test_local_compose_inputs.py b/backend/supabase/tests/test_local_compose_inputs.py index f49cb5560..db816b7f5 100644 --- a/backend/supabase/tests/test_local_compose_inputs.py +++ b/backend/supabase/tests/test_local_compose_inputs.py @@ -146,6 +146,7 @@ def test_tracked_inputs_are_explicit_local_sources_and_destinations(self) -> Non self.assertIn("environment: !override", self.overlay_source) self.assertNotIn("CLUSTER_POSTGRES", self.overlay_source) self.assertIn('ERL_AFLAGS: ""', self.overlay_source) + self.assertIn('RLIMIT_NOFILE: ""', self.overlay_source) def test_compose_command_uses_project_env_and_all_three_local_files(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index f15b7ae15..15d9419c8 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -85,7 +85,10 @@ teardown. The hosted lane removes only the nine exact project volume names before reset and verifies that each is absent, so diagnostic probes cannot leave stale database or configuration state behind. The local Supavisor overlay invokes the image's limits wrapper directly because -the hosted Docker namespace denies Tini's optional subreaper capability. +the hosted Docker namespace denies Tini's optional subreaper capability. It +also clears the image's `RLIMIT_NOFILE` request because the hosted disposable +namespace rejects raising that limit; the runner's bounded default remains in +force. A failed reset may add a bounded `local-stack-failure-diagnostics-v1` receipt to the short-retention Actions artifact. It contains only fixed service state, health, exit-code, and Compose-status fields; it is never in the public release. From 5985274b4f4aa6cfaefd2e3be13a81f06f103e5d Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 12:47:10 +0900 Subject: [PATCH 39/47] fix(ci): verify tracked prerequisite in place --- .github/workflows/nightly-local-regression.yml | 8 +++----- apps/web/tests-unit/nightly-regression-workflow.test.ts | 1 + docs/operations/nightly-regression.md | 2 ++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nightly-local-regression.yml b/.github/workflows/nightly-local-regression.yml index 9768812b9..45c7e47c4 100644 --- a/.github/workflows/nightly-local-regression.yml +++ b/.github/workflows/nightly-local-regression.yml @@ -546,11 +546,9 @@ jobs: set -euo pipefail state="$LOCAL_STATE" bind=(--project "$LOCAL_PROJECT" --state-dir "$state" --env-file "$state/stack.env") - python3 backend/supabase/scripts/local-migrate.py generate-prerequisite \ - --input backend/supabase/baselines/pre-20260214-public-schema.sql \ - --output backend/supabase/baselines/local/application-prerequisites.sql \ - --manifest-output backend/supabase/baselines/local/APPLICATION_PREREQUISITES.v1.json \ - > "$state/local-prerequisite-generate.json" + python3 backend/supabase/scripts/local-migrate.py verify-prerequisite \ + --input backend/supabase/baselines/local/application-prerequisites.sql \ + > "$state/local-prerequisite-verify.json" python3 backend/supabase/scripts/local-migrate.py manifest \ --output "$state/local-migration-manifest.json" python3 backend/supabase/scripts/local-migrate.py verify \ diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 6d4fe51ae..1d425ba8d 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -280,6 +280,7 @@ describe("nightly regression package and source contracts", () => { "user.max_user_namespaces=28633", "python3 backend/supabase/scripts/local-stack.py reset", "python3 backend/supabase/scripts/local-migrate.py apply-prerequisite", + "python3 backend/supabase/scripts/local-migrate.py verify-prerequisite", "python3 backend/supabase/scripts/local-migrate.py apply", "python3 backend/supabase/scripts/local-function-runtime-scan.py smoke", "python3 backend/supabase/scripts/local-migrate.py receipt", diff --git a/docs/operations/nightly-regression.md b/docs/operations/nightly-regression.md index 15d9419c8..3b03a6d08 100644 --- a/docs/operations/nightly-regression.md +++ b/docs/operations/nightly-regression.md @@ -62,6 +62,8 @@ runs `test:nightly -- --mode local`. The runner attempts only the disposable user-namespace sysctl settings needed by the containment probe. Missing privileged sysctl access is not silently replaced with a weaker runtime; the preflight fails closed. +The checked-in source-bound prerequisite artifact is verified in place before +apply; the lane never overwrites tracked baseline files during a hosted run. The GitHub-hosted runner's root-owned `/var/run/docker.sock` is accepted only for the default local socket when both `GITHUB_ACTIONS=true` and `CI=true`; remote Docker contexts and non-default sockets remain rejected. From f3129868353bf137aa09db97b411ee9f9a7ba52b Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:00:44 +0900 Subject: [PATCH 40/47] fix(local-migrate): admit hosted CI Docker socket --- backend/supabase/scripts/local-migrate.py | 17 ++++++++++- .../tests/test_local_migration_contract.py | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/supabase/scripts/local-migrate.py b/backend/supabase/scripts/local-migrate.py index 3ed2ebe48..8cc51d1ed 100644 --- a/backend/supabase/scripts/local-migrate.py +++ b/backend/supabase/scripts/local-migrate.py @@ -642,6 +642,15 @@ def _environment_contract_sha256(values: Mapping[str, str]) -> str: ) ) +def _github_actions_root_owned_socket(path: Path, owner: int) -> bool: + return ( + path == Path("/var/run/docker.sock") + and owner == 0 + and os.environ.get("GITHUB_ACTIONS") == "true" + and os.environ.get("CI") == "true" + ) + + def _assert_local_docker_context(docker: str) -> None: environment = { key: value @@ -694,7 +703,13 @@ def _assert_local_docker_context(docker: str) -> None: info = socket_path.lstat() except OSError as error: raise LocalMigrationError("docker_context") from error - if stat.S_ISLNK(info.st_mode) or not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid(): + owned_by_current_user = info.st_uid == os.getuid() + owned_by_disposable_ci_root = _github_actions_root_owned_socket(socket_path, info.st_uid) + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISSOCK(info.st_mode) + or not (owned_by_current_user or owned_by_disposable_ci_root) + ): raise LocalMigrationError("docker_context") def _reject_path_custody(path: Path) -> None: diff --git a/backend/supabase/tests/test_local_migration_contract.py b/backend/supabase/tests/test_local_migration_contract.py index 81cf14a86..02c8c2cba 100644 --- a/backend/supabase/tests/test_local_migration_contract.py +++ b/backend/supabase/tests/test_local_migration_contract.py @@ -2,8 +2,11 @@ import importlib.util import json import re +import stat import sys import tempfile +from types import SimpleNamespace +from unittest.mock import patch import unittest from pathlib import Path @@ -109,6 +112,33 @@ def test_filename_contract_is_checked_before_manifest_admission(self) -> None: self.assertIsNone(re.match(r"^\d{8,14}(?:_|\.)", "seed.sql")) with self.assertRaisesRegex(local_migrate.LocalMigrationError, "source_root_not_canonical"): local_migrate.migration_files(ROOT / "backend/supabase") + + def test_docker_context_accepts_github_actions_root_socket_only(self) -> None: + socket_info = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o660, st_uid=0) + selected = SimpleNamespace(returncode=0, stdout="default\n") + inspected = SimpleNamespace( + returncode=0, + stdout=json.dumps([{ + "Endpoints": {"docker": {"Host": "unix:///var/run/docker.sock"}}, + }]), + ) + with ( + patch.object(local_migrate.subprocess, "run", side_effect=(selected, inspected)), + patch.object(local_migrate.Path, "lstat", return_value=socket_info), + patch.object(local_migrate.os, "getuid", return_value=1000), + patch.dict(local_migrate.os.environ, {"GITHUB_ACTIONS": "true", "CI": "true"}, clear=False), + ): + local_migrate._assert_local_docker_context("docker") + + with ( + patch.object(local_migrate.subprocess, "run", side_effect=(selected, inspected)), + patch.object(local_migrate.Path, "lstat", return_value=socket_info), + patch.object(local_migrate.os, "getuid", return_value=1000), + patch.dict(local_migrate.os.environ, {"GITHUB_ACTIONS": "false", "CI": "true"}, clear=False), + ): + with self.assertRaisesRegex(local_migrate.LocalMigrationError, "docker_context"): + local_migrate._assert_local_docker_context("docker") + def test_receipts_bind_runtime_closure_and_readback_sources(self) -> None: source = SCRIPT.read_text(encoding="utf-8") for token in ( From a52e3012ab0e23cdc9b3c4e1f2517d61e733ae96 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:09:31 +0900 Subject: [PATCH 41/47] fix(local-runtime): admit hosted CI Docker socket --- .../scripts/local-function-runtime-scan.py | 19 ++++++++++- .../test_local_function_runtime_contract.py | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/backend/supabase/scripts/local-function-runtime-scan.py b/backend/supabase/scripts/local-function-runtime-scan.py index 2994850b1..a20993fed 100644 --- a/backend/supabase/scripts/local-function-runtime-scan.py +++ b/backend/supabase/scripts/local-function-runtime-scan.py @@ -721,6 +721,17 @@ def _filtered_environment() -> dict[str, str]: result.setdefault("PATH", "/usr/bin:/bin") result.setdefault("HOME", str(Path.home())) return result + + +def _github_actions_root_owned_socket(path: Path, owner: int) -> bool: + return ( + path == Path("/var/run/docker.sock") + and owner == 0 + and os.environ.get("GITHUB_ACTIONS") == "true" + and os.environ.get("CI") == "true" + ) + + def _assert_local_docker_context(docker: str) -> None: environment = _filtered_environment() try: @@ -764,7 +775,13 @@ def _assert_local_docker_context(docker: str) -> None: info = socket_path.lstat() except OSError as error: raise RuntimeScanError("docker_context") from error - if stat.S_ISLNK(info.st_mode) or not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid(): + owned_by_current_user = info.st_uid == os.getuid() + owned_by_disposable_ci_root = _github_actions_root_owned_socket(socket_path, info.st_uid) + if ( + stat.S_ISLNK(info.st_mode) + or not stat.S_ISSOCK(info.st_mode) + or not (owned_by_current_user or owned_by_disposable_ci_root) + ): raise RuntimeScanError("docker_context") def _validate_endpoint_environment() -> None: if any(os.environ.get(key) for key in _REMOTE_DOCKER_ENV): diff --git a/backend/supabase/tests/test_local_function_runtime_contract.py b/backend/supabase/tests/test_local_function_runtime_contract.py index 6cf1d2fb4..1370849ad 100644 --- a/backend/supabase/tests/test_local_function_runtime_contract.py +++ b/backend/supabase/tests/test_local_function_runtime_contract.py @@ -1,9 +1,13 @@ from __future__ import annotations import importlib.util +import json +import stat import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch REPOSITORY_ROOT = Path(__file__).resolve().parents[3] @@ -71,6 +75,34 @@ def test_candidate_smoke_binds_every_argument_with_explicit_types(self): ) self.assertIn("'0A000'", trigger_sql) self.assertIn("expected_sqlstate_", trigger_sql) + + def test_docker_context_accepts_github_actions_root_socket_only(self): + scanner = self.scanner + socket_info = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o660, st_uid=0) + selected = SimpleNamespace(returncode=0, stdout="default\n") + inspected = SimpleNamespace( + returncode=0, + stdout=json.dumps([{ + "Endpoints": {"docker": {"Host": "unix:///var/run/docker.sock"}}, + }]), + ) + with ( + patch.object(scanner.subprocess, "run", side_effect=(selected, inspected)), + patch.object(scanner.Path, "lstat", return_value=socket_info), + patch.object(scanner.os, "getuid", return_value=1000), + patch.dict(scanner.os.environ, {"GITHUB_ACTIONS": "true", "CI": "true"}, clear=False), + ): + scanner._assert_local_docker_context("docker") + + with ( + patch.object(scanner.subprocess, "run", side_effect=(selected, inspected)), + patch.object(scanner.Path, "lstat", return_value=socket_info), + patch.object(scanner.os, "getuid", return_value=1000), + patch.dict(scanner.os.environ, {"GITHUB_ACTIONS": "false", "CI": "true"}, clear=False), + ): + with self.assertRaisesRegex(scanner.RuntimeScanError, "docker_context"): + scanner._assert_local_docker_context("docker") + def test_smoke_checks_external_effect_surface_in_runtime_catalog(self): sql = self.scanner._smoke_sql([]) self.assertIn("pg_catalog.pg_extension", sql) From c984ae4e1cac0a7ddb0cf4786c50f69a26cb93ef Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:19:48 +0900 Subject: [PATCH 42/47] fix(local-runtime): validate staged database volumes --- .../scripts/local-function-runtime-scan.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/supabase/scripts/local-function-runtime-scan.py b/backend/supabase/scripts/local-function-runtime-scan.py index a20993fed..5d9181fb5 100644 --- a/backend/supabase/scripts/local-function-runtime-scan.py +++ b/backend/supabase/scripts/local-function-runtime-scan.py @@ -928,7 +928,7 @@ def _validate_local_stack_state( return state, values, current, receipt -def _expected_database_mounts(root: Path, state: Path) -> set[tuple[str, str]]: +def _expected_database_mounts(root: Path, state: Path) -> set[tuple[str, str, str]]: input_root = state / "inputs" try: input_info = input_root.lstat() @@ -958,9 +958,28 @@ def _expected_database_mounts(root: Path, state: Path) -> set[tuple[str, str]]: or manifest.get("schema") != "local-stack-input-manifest-v1" or manifest.get("generator_version") != LOCAL_STACK_GENERATOR_VERSION or not isinstance(manifest.get("inputs"), list) + or not isinstance(manifest.get("mounts"), list) ): raise RuntimeScanError("local_input_manifest") - mounts: set[tuple[str, str]] = set() + for mount in manifest.get("mounts", []): + if not isinstance(mount, dict) or mount.get("service") != "db": + continue + source, destination = mount.get("source"), mount.get("destination") + if ( + mount.get("type") != "volume" + or not isinstance(source, str) + or not source.startswith("local-") + or not isinstance(destination, str) + ): + raise RuntimeScanError("local_input_manifest") + mounts: set[tuple[str, str, str]] = set() + project = local_project_name(root) + for mount in manifest["mounts"]: + if not isinstance(mount, dict) or mount.get("service") != "db": + continue + source = mount["source"] + destination = mount["destination"] + mounts.add(("volume", destination, f"{project}-{source.removeprefix('local-')}")) for entry in manifest["inputs"]: if not isinstance(entry, dict) or entry.get("service") != "db": continue @@ -986,7 +1005,6 @@ def _expected_database_mounts(root: Path, state: Path) -> set[tuple[str, str]]: or output_resolved.parent != (state / "inputs").resolve() ): raise RuntimeScanError("local_input_binding") - mounts.add((destination, str(output_resolved))) if not mounts: raise RuntimeScanError("local_input_manifest") return mounts @@ -1122,19 +1140,15 @@ def _validate(self) -> None: raise RuntimeScanError("container_not_repository_compose") if not isinstance(mounts, list): raise RuntimeScanError("container_input_mounts") - actual_mounts: set[tuple[str, str]] = set() + actual_mounts: set[tuple[str, str, str]] = set() for mount in mounts: if ( isinstance(mount, dict) - and mount.get("Type") == "bind" - and mount.get("RW") is False + and mount.get("Type") == "volume" and isinstance(mount.get("Destination"), str) - and isinstance(mount.get("Source"), str) + and isinstance(mount.get("Name"), str) ): - try: - actual_mounts.add((mount["Destination"], str(Path(mount["Source"]).resolve(strict=True)))) - except OSError as error: - raise RuntimeScanError("container_input_mounts") from error + actual_mounts.add(("volume", mount["Destination"], mount["Name"])) if not self._expected_mounts.issubset(actual_mounts): raise RuntimeScanError("container_input_mounts") From 64390c0a7d4a05e409a1ca1d1e893e0354c798ce Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:31:01 +0900 Subject: [PATCH 43/47] fix(local-runtime): retain smoke failure receipt --- backend/supabase/scripts/local-function-runtime-scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/supabase/scripts/local-function-runtime-scan.py b/backend/supabase/scripts/local-function-runtime-scan.py index 5d9181fb5..2197ee360 100644 --- a/backend/supabase/scripts/local-function-runtime-scan.py +++ b/backend/supabase/scripts/local-function-runtime-scan.py @@ -1982,9 +1982,9 @@ def main(argv: Sequence[str] | None = None) -> int: client = LocalPsql(args.docker, args.container, args.database, args.timeout) candidates = _candidate_functions(_source_inventory()) runtime = client.query(_runtime_sql(candidates=candidates, smoke=True)) - _validate_runtime(runtime) smoke_status = runtime.get("rpcSmoke", {}).get("status") sys.stdout.buffer.write(canonical_json(runtime) + b"\n") + _validate_runtime(runtime) return 0 if smoke_status == "passed" else 2 if args.command == "apply": sql, metadata = _read_patch(Path(args.patch)) From 39e801cafd0c4ac250a7570cfddef5c4636f4255 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:41:32 +0900 Subject: [PATCH 44/47] fix(local-runtime): allow privacy guard smoke state --- .../scripts/local-function-runtime-scan.py | 2 +- .../tests/test_local_function_runtime_contract.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/supabase/scripts/local-function-runtime-scan.py b/backend/supabase/scripts/local-function-runtime-scan.py index 2197ee360..8be277885 100644 --- a/backend/supabase/scripts/local-function-runtime-scan.py +++ b/backend/supabase/scripts/local-function-runtime-scan.py @@ -1614,7 +1614,7 @@ def _sql_identifier(value: str) -> str: ("public", "prevent_last_admin_role_delete", ""): ("0A000",), ("public", "prevent_last_admin_role_update", ""): ("0A000",), ("public", "prevent_profile_role_client_change", ""): ("0A000",), - ("public", "preview_privacy_incident_transition", "uuid,uuid,public.privacy_incident_status,timestamptz,text,jsonb,uuid"): ("P0001",), + ("public", "preview_privacy_incident_transition", "uuid,uuid,public.privacy_incident_status,timestamptz,text,jsonb,uuid"): ("P0001", "42501"), ("public", "privacy_append_audit_event", "text,uuid,uuid,uuid,uuid,text,text,jsonb,jsonb"): ("42501",), ("public", "set_admin_ai_updated_at", ""): ("0A000",), ("public", "set_admin_restaurant_map_overlays_updated_at", ""): ("0A000",), diff --git a/backend/supabase/tests/test_local_function_runtime_contract.py b/backend/supabase/tests/test_local_function_runtime_contract.py index 1370849ad..0837820c5 100644 --- a/backend/supabase/tests/test_local_function_runtime_contract.py +++ b/backend/supabase/tests/test_local_function_runtime_contract.py @@ -76,6 +76,21 @@ def test_candidate_smoke_binds_every_argument_with_explicit_types(self): self.assertIn("'0A000'", trigger_sql) self.assertIn("expected_sqlstate_", trigger_sql) + def test_candidate_smoke_allows_privacy_incident_guard_outcomes(self): + sql = self.scanner._smoke_candidate_blocks( + [ + { + "schema": "public", + "proname": "preview_privacy_incident_transition", + "identityArgumentsNormalized": ( + "uuid,uuid,public.privacy_incident_status,timestamptz,text,jsonb,uuid" + ), + "signature": "public.preview_privacy_incident_transition(...)", + } + ] + ) + self.assertIn("'P0001', '42501'", sql) + def test_docker_context_accepts_github_actions_root_socket_only(self): scanner = self.scanner socket_info = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o660, st_uid=0) From 79cdbc9bf5abe9f2d1e2ea1fc31e23178e87aa53 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 13:52:46 +0900 Subject: [PATCH 45/47] test(web): match restricted announcement branch --- .../web/tests-unit/admin-announcements-console-source.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/tests-unit/admin-announcements-console-source.test.ts b/apps/web/tests-unit/admin-announcements-console-source.test.ts index eadb49c6c..cd3087b35 100644 --- a/apps/web/tests-unit/admin-announcements-console-source.test.ts +++ b/apps/web/tests-unit/admin-announcements-console-source.test.ts @@ -69,7 +69,9 @@ describe('admin announcements console integration source contract', () => { expect(headerSource).toContain('AnnouncementPanelLoadingFallback'); expect(headerSource).toContain('HeaderAnnouncementPanel ?'); expect(desktopControlPanelSource).toContain('AnnouncementPanelLoadingFallback'); - expect(desktopControlPanelSource).toContain('activeLeftPanelView === "announcement" ?'); + expect(desktopControlPanelSource).toContain( + 'activeLeftPanelView === "announcement" && !isPublicRestrictedMode ?', + ); expect(homeSidePanelsSource).toContain('loading: () => Date: Wed, 12 Aug 2026 14:10:30 +0900 Subject: [PATCH 46/47] fix(nightly): propagate Node24 supervisor runtime --- apps/web/scripts/run-nightly-regression.mjs | 6 +++++- apps/web/tests-unit/nightly-regression-workflow.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/scripts/run-nightly-regression.mjs b/apps/web/scripts/run-nightly-regression.mjs index 938f832a6..c356ee60b 100644 --- a/apps/web/scripts/run-nightly-regression.mjs +++ b/apps/web/scripts/run-nightly-regression.mjs @@ -1102,7 +1102,11 @@ async function waitForHealth(appProcess, healthUrl, mode, headers = undefined) { } async function runUnitRegression(environment) { - const result = await runCommand('bun', ['run', 'test:unit'], { env: environment }); + const supervisorExecutable = process.env.TZUDONG_NODE24_EXECUTABLE?.trim(); + const unitEnvironment = supervisorExecutable + ? { ...environment, TZUDONG_NODE24_EXECUTABLE: supervisorExecutable } + : environment; + const result = await runCommand('bun', ['run', 'test:unit'], { env: unitEnvironment }); if (result.code !== 0) { throw new Error(`Nightly unit regressions failed with exit code ${result.code}.`); } diff --git a/apps/web/tests-unit/nightly-regression-workflow.test.ts b/apps/web/tests-unit/nightly-regression-workflow.test.ts index 1d425ba8d..c38e7b5a3 100644 --- a/apps/web/tests-unit/nightly-regression-workflow.test.ts +++ b/apps/web/tests-unit/nightly-regression-workflow.test.ts @@ -44,6 +44,15 @@ describe("nightly regression package and source contracts", () => { expect(nightlyRunnerSource).toContain("function main()"); }); + test("passes the verified Node 24 supervisor into the unit lane", () => { + expect(nightlyRunnerSource).toContain( + "const supervisorExecutable = process.env.TZUDONG_NODE24_EXECUTABLE?.trim();", + ); + expect(nightlyRunnerSource).toContain( + "TZUDONG_NODE24_EXECUTABLE: supervisorExecutable", + ); + }); + test("preserves the hosted nightly schedule and bounded diagnostics", () => { expect(hostedWorkflowSource).toContain("cron: '30 18 * * *'"); expect(hostedWorkflowSource).toContain("workflow_dispatch:"); From 838ff76ed1ea624193bc78df50fb6543c4334974 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 12 Aug 2026 14:27:40 +0900 Subject: [PATCH 47/47] fix(storyboard): allow Linux supervisor drain grace --- apps/web/lib/admin/storyboard/backend-agent.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/admin/storyboard/backend-agent.ts b/apps/web/lib/admin/storyboard/backend-agent.ts index 84e5abec4..a5437fff9 100644 --- a/apps/web/lib/admin/storyboard/backend-agent.ts +++ b/apps/web/lib/admin/storyboard/backend-agent.ts @@ -87,6 +87,7 @@ const WINDOWS_PROCESS_TERMINATION_TIMEOUT_MS = 5_000; const WINDOWS_JOB_SUPERVISOR_CLEANUP_GRACE_MS = 5_000; const WINDOWS_JOB_SUPERVISOR_FINAL_CLOSE_TIMEOUT_MS = 5_000; const LINUX_NAMESPACE_TERMINATION_TIMEOUT_MS = 10_000; +const LINUX_NAMESPACE_SUPERVISOR_DRAIN_TIMEOUT_MS = 7_000; const MAX_STORYBOARD_AGENT_TIMEOUT_MS = 600_000; function getRuntimeCwd() { const cwd = Reflect.get(process, "cwd"); @@ -4320,6 +4321,10 @@ function runStoryboardAgentCommand( processControl.platform === "linux" && isNativeProcessControl && !trustedLangGraphFixture; + const lifecycleStreamDrainTimeoutMs = + useLinuxNamespaceSupervisor && processControl.streamDrainTimeoutMs === undefined + ? Math.max(streamDrainTimeoutMs, LINUX_NAMESPACE_SUPERVISOR_DRAIN_TIMEOUT_MS) + : streamDrainTimeoutMs; const linuxSupervisorNonce = useLinuxNamespaceSupervisor ? randomBytes(32).toString("hex") : ""; @@ -4414,7 +4419,7 @@ function runStoryboardAgentCommand( ); stderr = stderrCapture.value; finish(); - }, streamDrainTimeoutMs); + }, lifecycleStreamDrainTimeoutMs); streamWaiters.add(finish); }); const terminateTree = async (awaitWindowsCleanupGrace = false) => { @@ -4647,7 +4652,7 @@ function runStoryboardAgentCommand( "diagnostic stream drain deadline exceeded", exitCode, ); - }, streamDrainTimeoutMs); + }, lifecycleStreamDrainTimeoutMs); } };