diff --git a/.github/actions/preview-delivery/action.yml b/.github/actions/preview-delivery/action.yml index 162f305..a5b618d 100644 --- a/.github/actions/preview-delivery/action.yml +++ b/.github/actions/preview-delivery/action.yml @@ -437,8 +437,9 @@ runs: heroku releases --app "$APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$guard_release" != "$before_guard_release" - wait_for_release "$APP_NAME" "$guard_release" + if [ "$guard_release" != "$before_guard_release" ]; then + wait_for_release "$APP_NAME" "$guard_release" + fi if [ "$HAS_DEPLOYED_RELEASE" = true ]; then configure_app @@ -453,8 +454,9 @@ runs: heroku releases --app "$APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$web_release" != "$before_web_release" - wait_for_release "$APP_NAME" "$web_release" + if [ "$web_release" != "$before_web_release" ]; then + wait_for_release "$APP_NAME" "$web_release" + fi before_postgrest_release="$( heroku releases --app "$POSTGREST_APP_NAME" --json | @@ -465,8 +467,9 @@ runs: heroku releases --app "$POSTGREST_APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$postgrest_release" != "$before_postgrest_release" - wait_for_release "$POSTGREST_APP_NAME" "$postgrest_release" + if [ "$postgrest_release" != "$before_postgrest_release" ]; then + wait_for_release "$POSTGREST_APP_NAME" "$postgrest_release" + fi if [ "$HAS_POSTGREST_RELEASE" = true ]; then configure_postgrest diff --git a/.github/actions/production-delivery/action.yml b/.github/actions/production-delivery/action.yml index 2936c76..deb5a3b 100644 --- a/.github/actions/production-delivery/action.yml +++ b/.github/actions/production-delivery/action.yml @@ -36,7 +36,7 @@ inputs: description: Exact canonical production branch ID required: true neon_parent_branch_id: - description: Exact durable checkpoint branch ID + description: Exact parent branch ID, or literal null for project-root production required: true neon_project_id: description: Neon project containing the production branch @@ -547,8 +547,9 @@ runs: heroku releases --app "$APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$guard_release" != "$before_guard_release" - wait_for_release "$APP_NAME" "$guard_release" + if [ "$guard_release" != "$before_guard_release" ]; then + wait_for_release "$APP_NAME" "$guard_release" + fi if [ "$HAS_DEPLOYED_RELEASE" = true ]; then configure_app @@ -563,8 +564,12 @@ runs: heroku releases --app "$APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$web_release" != "$before_web_release" - wait_for_release "$APP_NAME" "$web_release" + if [ "$web_release" != "$before_web_release" ]; then + wait_for_release "$APP_NAME" "$web_release" + else + test -n "$previous_release" + web_release="$previous_release" + fi before_postgrest_release="$( heroku releases --app "$POSTGREST_APP_NAME" --json | @@ -575,8 +580,12 @@ runs: heroku releases --app "$POSTGREST_APP_NAME" --json | jq -r '.[0].version // empty' )" - test "$postgrest_release" != "$before_postgrest_release" - wait_for_release "$POSTGREST_APP_NAME" "$postgrest_release" + if [ "$postgrest_release" != "$before_postgrest_release" ]; then + wait_for_release "$POSTGREST_APP_NAME" "$postgrest_release" + else + test -n "$previous_postgrest_release" + postgrest_release="$previous_postgrest_release" + fi if [ "$HAS_POSTGREST_RELEASE" = true ]; then configure_postgrest diff --git a/docs/40-deployment/neon.md b/docs/40-deployment/neon.md index 81436cf..7c2f779 100644 --- a/docs/40-deployment/neon.md +++ b/docs/40-deployment/neon.md @@ -39,7 +39,9 @@ outputs and are never written to logs or artifacts. `preview-base` is a normal Neon branch because schema-only branching is incompatible with the legacy `authenticated` web role on the current runtime branch. Its one-time bootstrap copies the source branch internally, immediately truncates every known application table, -and verifies the database remains at the repository head. +resets the cloned database identity from `production` to `runtime`, and verifies the database +remains at the repository head. The identity reset is limited to this guarded, data-free +baseline; canonical production remains immutable. Production is not mutated, and production rows never enter a PR-owned branch. The current Neon free-v3 plan has a protected-branch quota of zero, so provider protection @@ -99,10 +101,11 @@ it neither applies nor verifies the schema transition. ## Production Branch -The canonical production branch is named `production`. It is a no-TTL child of the durable -pre-cutover checkpoint, not a rename or in-place migration of `staging`. Its initial -manifest preserved every application-table row count and changed only the Alembic head to -the convergence revision. +The canonical production branch is named `production`. Since the 2026-08-13 project-root +migration it is the no-TTL root and default branch of the active Neon project. It was restored +from a directly encrypted custom archive whose value-free source and target manifests and +normalized schema dumps matched exactly. GitHub stores the expected parent identity as the +literal value `null` so the delivery guard can distinguish this topology from a missing value. Native runtime and PostgREST use role-specific URLs derived from the pooled branch coordinate. The protected GitHub lifecycle process alone receives the direct owner @@ -119,29 +122,20 @@ The current Neon plan cannot protect this branch. GitHub environment isolation, branch-ID/parent guards, serialized release execution, the durable checkpoint, and the encrypted archive are required compensating controls. -## Retained Lineage And Retired Branches +## Current Topology And Retired Project -There is no active Neon staging or develop environment: +There is no active Neon staging or develop environment. The active topology is intentionally +small: -- the stale `develop` branch was deleted on 2026-07-24; -- the historical staging branch was renamed to - `archive/staging-lineage-20250824` (`br-broad-bread-a1j7v4ct`) and its compute endpoint was - deleted; -- the archived branch remains storage-only because Neon refuses to delete a branch while it - is the ancestor of retained children, including `backup/pre-cutover-20260723` and therefore - canonical `production`; -- `master` remains the provider-required default root and is not an application environment. +- root/default `production` is the sole canonical runtime branch; +- one fresh no-TTL `backup/peer-contract-*` child is the production recovery checkpoint; +- `preview-base` is the sanitized no-TTL child of production; +- only open, trusted pull requests own seven-day `preview/core-py/pr-` children. -The retained recovery surface is deliberate: - -- `backup/pre-cutover-20260723` preserves the pre-cutover lineage; -- `backup/peer-contract-20260724-052651` and - `backup/peer-contract-20260724-054730` are no-TTL production recovery branches; -- `preview-base` is the data-free parent for repository-qualified PR branches. - -No runtime, workflow, credential, or documented command may address the archived staging -lineage. Removing it later requires first replacing or deleting every retained descendant; -that is a recovery-retention decision, not routine environment cleanup. +The former project, including its provider-required `master` root and historical staging +lineage, was soft-deleted after production and all open-PR consumers were proven on the new +project. Its GitHub API keys were revoked. Neon retains the deleted project for its provider +recovery window only; no runtime, workflow, or credential may address it. ## Operational Implication diff --git a/scripts/sanitize_preview_base.py b/scripts/sanitize_preview_base.py index 2c97f19..b415792 100644 --- a/scripts/sanitize_preview_base.py +++ b/scripts/sanitize_preview_base.py @@ -15,7 +15,7 @@ from migrations.metadata import get_target_metadata from migrations.settings import MigrationSettings -from app.database_contract import PROTOCOL_SCHEMA +from app.database_contract import INTERNAL_SCHEMA, PROTOCOL_SCHEMA SANITIZE_GUARD = "ALLOW_PREVIEW_BASE_SANITIZE" @@ -41,6 +41,14 @@ def validate_application_tables(actual: set[str], expected: set[str]) -> None: raise ValueError("; ".join(details)) +def validate_source_environment(environment: str) -> None: + """Allow only a production clone or an already-sanitized preview baseline.""" + if environment not in {"production", "runtime"}: + raise ValueError( + "preview base must inherit production or already have runtime identity" + ) + + def sanitize_preview_base(database_url: str) -> tuple[str, ...]: """Truncate known application tables while preserving Alembic lineage.""" if os.getenv(SANITIZE_GUARD) != "1": @@ -81,11 +89,29 @@ def sanitize_preview_base(database_url: str) -> tuple[str, ...]: if current_heads != expected_heads: raise ValueError("preview base is not at the repository Alembic head") + source_environment = connection.execute( + text( + f'SELECT environment FROM "{INTERNAL_SCHEMA}".contract_state ' + "WHERE singleton FOR UPDATE" + ) + ).scalar_one() + validate_source_environment(source_environment) + quoted_tables = ", ".join( f'"{PROTOCOL_SCHEMA}"."{table}"' for table in sorted(expected_tables) ) connection.execute(text(f"TRUNCATE TABLE {quoted_tables} RESTART IDENTITY CASCADE")) + environment_update = connection.execute( + text( + f'UPDATE "{INTERNAL_SCHEMA}".contract_state ' + "SET environment = 'runtime', updated_at = CURRENT_TIMESTAMP " + "WHERE singleton" + ) + ) + if environment_update.rowcount != 1: + raise RuntimeError("preview base environment identity was not reset") + for table in sorted(expected_tables): remaining = connection.execute( text(f'SELECT count(*) FROM "{PROTOCOL_SCHEMA}"."{table}"') diff --git a/tasks/neon-project-root-migration/packet.md b/tasks/neon-project-root-migration/packet.md new file mode 100644 index 0000000..313609d --- /dev/null +++ b/tasks/neon-project-root-migration/packet.md @@ -0,0 +1,85 @@ +# Neon Project Root Migration + +- **Objective**: migrate canonical production, the sanitized `preview-base`, and every active + repository-owned PR preview from Neon project `small-feather-66252738` into one new Neon + project whose root/default branch is canonical `production`; rotate the project-scoped Neon + API key, synchronize GitHub and runtime coordinates, verify the cutover, then retire the old + project. +- **Guardrails**: do not expose credentials or production values; preserve every production + application row, schema object, role contract, and runtime consumer; keep `preview-base` + data-free; rebuild previews only for open trusted PRs; retain an encrypted verified backup + and a working rollback path until the new production and previews pass; do not delete the old + project before every cutover gate is proven. +- **Verification**: encrypted custom archive plus checksum; matching value-free manifests and + Alembic head; empty provider schema diff; passing checked-in readiness; exact role/catalog + checks; successful core and PostgREST production probes; successful preview delivery for all + open trusted PRs; GitHub variables point only to the new identities; old API key no longer + authenticates after rotation; final Neon inventory contains the new project and not the old + project. +- **Current Truth**: migration completed on 2026-08-13. Active project + `proud-sky-36728055` has root/default `production`, one no-TTL recovery child, sanitized + `preview-base`, and seven-day branches for open PRs 45 and 52. GitHub and every Heroku + consumer reference the new project. The old project is soft-deleted and recoverable until + 2026-08-20T11:39:39Z; both old project keys are revoked. +- **Next Step**: keep the encrypted archive through the old-project recovery window, review the + local automation hardening diff, and commit only after an explicit human command. + +## Impact Handshake + +- **Address and Object**: Neon organization `org-falling-queen-46920568`; retired project + `small-feather-66252738`; active project `proud-sky-36728055`; GitHub repository + `InKCre/core-py` repository/environment secrets and variables; canonical deployment truth in + `docs/40-deployment/`; exact workflow/config references discovered by the audit. +- **State Diff**: old immutable-root project owns production and previews -> new project root + owns canonical production, sanitized `preview-base`, and rebuilt active previews; all consumers + reference the new project/key/branch identities; old project is deleted only after verification. +- **Operation**: create and restore provider resources, rotate a project API key, update external + configuration, rebuild disposable preview branches, cut over runtime connections, update local + deployment truth, and finally delete the old project. +- **Blast Radius Forecast**: Neon production and preview databases; GitHub preview and production + workflows; core/PostgREST/Render/Heroku runtime connections; recovery checkpoints; operational + documentation and tests that encode branch topology. +- **Invariants Check**: no product behavior change; no loss or leakage of production data; no + plaintext backup at rest; no credential in logs/files/task material; no closed-PR preview + recreation; no old-project deletion while any runtime or workflow still depends on it. +- **Verification**: pre/post manifests, checksums, schema and role checks, readiness/probes, + provider and GitHub inventories, active-preview workflow results, and explicit rollback probes. +- **Uncertainty**: no unresolved uncertainty affects the completed cutover. PR 45 remains on the + single-Core delivery shape captured by its original event and will gain the current PostgREST + preview on its next synchronize event. + +## Completion Evidence + +- New provider topology: + - `production`: `br-old-recipe-azsvonnw`, root/default, ready, no TTL + - recovery: `br-hidden-bar-azrlrdel`, direct child of production, ready, no TTL + - `preview-base`: `br-summer-violet-azswiwfq`, direct child of production, ready, no TTL, + zero application rows before preview bootstrap + - PR 45: `br-spring-waterfall-azfql7jo`, child of `preview-base`, seven-day TTL + - PR 52: `br-jolly-water-azqn2jbk`, child of `preview-base`, seven-day TTL +- GitHub repository secret `NEON_API_KEY` was rotated to project-scoped key metadata ID + `3263246`. Repository and production-environment project IDs now reference the new project; + production branch parent is the literal `null`; production branch and recovery IDs match the + active topology. +- Encrypted production archive: + `/Volumes/WorkSSD/Development/InKCre/backups/core-py/2026-08-13/project-root-migration/production-final-cutover-20260813.dump.age` + with SHA-256 `d770e13746e891ad45560b86faec54b80e6f9fc6bb070281b6e82f14d36c1da5`. + No plaintext archive was written at rest. +- Final source and target value-free manifests matched byte-for-byte. Normalized schema-only + dumps matched with SHA-256 + `0fb3e9568ae6af80a675feb1fbd321c260d6e62f58f12bf4f3ab2dd01f970836`. +- Production and both Core previews returned HTTP 200 for liveness and readiness. Production + and PR 52 PostgREST passed authenticated read/write, guarded Extension mutation, wrong-secret, + anonymous-denial, and cleanup probes against their exact deployed source contracts. +- Database and application reruns for PRs 45 and 52 completed successfully after rebuilding + their disposable resources. PR 45 intentionally retains its original single-Core preview + shape; the next synchronize event will converge it to the latest two-app workflow. +- Production workflow run `31695670276` proved the new Neon branch/key/recovery guards, + migration, readiness, and manifest transition. Its release step rejected Heroku's successful + same-image no-op; independent production probes passed and the local action now treats that + response idempotently. +- Old API key metadata IDs `2586162` and `3262801` were revoked. Deleting the old project + also removed the repository-level Neon settings managed by its integration, so key + `3263094` was replaced and revoked after those settings were restored explicitly. The old project + `small-feather-66252738` is absent from active inventory and present only in recoverable + inventory through 2026-08-20T11:39:39Z. diff --git a/tests/migrations/test_sanitize_preview_base.py b/tests/migrations/test_sanitize_preview_base.py index 29781c8..499775f 100644 --- a/tests/migrations/test_sanitize_preview_base.py +++ b/tests/migrations/test_sanitize_preview_base.py @@ -2,7 +2,10 @@ import pytest -from scripts.sanitize_preview_base import validate_application_tables +from scripts.sanitize_preview_base import ( + validate_application_tables, + validate_source_environment, +) APPLICATION_TABLES = {"blocks", "relations"} @@ -26,3 +29,14 @@ def test_table_allowlist_rejects_missing_table(): {"blocks"}, APPLICATION_TABLES, ) + + +@pytest.mark.parametrize("environment", ["production", "runtime"]) +def test_source_environment_accepts_production_clone_and_idempotent_rerun(environment): + validate_source_environment(environment) + + +@pytest.mark.parametrize("environment", ["preview", "development", "absent"]) +def test_source_environment_rejects_other_identities(environment): + with pytest.raises(ValueError, match="must inherit production"): + validate_source_environment(environment) diff --git a/tests/test_delivery_release_idempotency.py b/tests/test_delivery_release_idempotency.py new file mode 100644 index 0000000..54fe192 --- /dev/null +++ b/tests/test_delivery_release_idempotency.py @@ -0,0 +1,31 @@ +"""Static guards for repeat delivery of an already-running Heroku image.""" + +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_preview_treats_same_images_as_success(): + delivery = (PROJECT_ROOT / ".github/actions/preview-delivery/action.yml").read_text( + encoding="utf-8" + ) + + assert 'if [ "$guard_release" != "$before_guard_release" ]; then' in delivery + assert 'if [ "$web_release" != "$before_web_release" ]; then' in delivery + assert 'if [ "$postgrest_release" != "$before_postgrest_release" ]; then' in delivery + assert 'test "$guard_release" != "$before_guard_release"' not in delivery + assert 'test "$web_release" != "$before_web_release"' not in delivery + assert 'test "$postgrest_release" != "$before_postgrest_release"' not in delivery + + +def test_production_reuses_last_deployed_release_when_heroku_returns_noop(): + delivery = (PROJECT_ROOT / ".github/actions/production-delivery/action.yml").read_text( + encoding="utf-8" + ) + + assert 'web_release="$previous_release"' in delivery + assert 'postgrest_release="$previous_postgrest_release"' in delivery + assert 'test "$guard_release" != "$before_guard_release"' not in delivery + assert 'test "$web_release" != "$before_web_release"' not in delivery + assert 'test "$postgrest_release" != "$before_postgrest_release"' not in delivery