Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .github/actions/preview-delivery/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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
Expand Down
23 changes: 16 additions & 7 deletions .github/actions/production-delivery/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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
Expand Down
44 changes: 19 additions & 25 deletions docs/40-deployment/neon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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-<number>` 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

Expand Down
28 changes: 27 additions & 1 deletion scripts/sanitize_preview_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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":
Expand Down Expand Up @@ -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}"')
Expand Down
85 changes: 85 additions & 0 deletions tasks/neon-project-root-migration/packet.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion tests/migrations/test_sanitize_preview_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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)
31 changes: 31 additions & 0 deletions tests/test_delivery_release_idempotency.py
Original file line number Diff line number Diff line change
@@ -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
Loading