diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7659eeb799..81c0ef791b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,10 @@ jobs: while IFS= read -r -d '' file; do [ -f "$file" ] || continue case "$file" in - *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts|*.json|*.jsonc|*.css) + # oxfmt formats Markdown too, and `fmt:check` covers the whole + # tree. Leaving .md/.mdx out here let unformatted docs land on + # main and turn this job red on every unrelated PR afterwards. + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts|*.json|*.jsonc|*.css|*.md|*.mdx) format_files+=("$file") ;; esac diff --git a/package.json b/package.json index 666c9e537e..711df411f3 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:agentkit-stream-ownership": "tsx --test scripts/guard-agentkit-stream-ownership.test.ts", "test:modal-layer-integrity": "tsx --test scripts/guard-modal-layer-integrity.test.ts", "test:ci-change-scope": "tsx --test scripts/ci-change-scope.test.ts", - "test:netlify-prebuilt-workflow": "tsx --test scripts/guard-netlify-prebuilt-workflow.test.ts scripts/verify-netlify-prebuilt-client.spec.ts scripts/smoke-check-health.test.ts scripts/check-production-cache-contract.test.ts scripts/cleanup-netlify-pr-previews.test.ts scripts/netlify-prebuilt-target.spec.ts scripts/netlify-pr-preview-targets.test.ts scripts/sync-netlify-preview-database.test.ts", + "test:netlify-migration-url": "tsx --test scripts/netlify-migration-url.test.ts", + "test:netlify-prebuilt-workflow": "tsx --test scripts/guard-netlify-prebuilt-workflow.test.ts scripts/netlify-migration-url.test.ts scripts/verify-netlify-prebuilt-client.spec.ts scripts/smoke-check-health.test.ts scripts/check-production-cache-contract.test.ts scripts/cleanup-netlify-pr-previews.test.ts scripts/netlify-prebuilt-target.spec.ts scripts/netlify-pr-preview-targets.test.ts scripts/sync-netlify-preview-database.test.ts", "test:function-size-baseline": "tsx --test scripts/check-function-size-baseline.test.ts", "test:package-release-workflow": "tsx --test scripts/package-release-workflow.test.ts scripts/create-release-changeset.test.ts scripts/release-everything-workflow.test.ts scripts/prepare-dev-snapshot.test.ts scripts/release-dev.test.ts", "test:oauth-postgres": "pnpm --filter @agent-native/core exec vitest --run src/oauth-tokens/lifecycle.postgres.integration.spec.ts --config vitest.config.ts", diff --git a/scripts/ci-change-scope.test.ts b/scripts/ci-change-scope.test.ts index 43b1f92584..79303efd07 100644 --- a/scripts/ci-change-scope.test.ts +++ b/scripts/ci-change-scope.test.ts @@ -65,7 +65,30 @@ test("selects only docs checks for an all-docs change set", () => { assert.equal(scope.docsOnly, true); assert.equal(scope.full, false); - assert.deepEqual(Object.values(scope.checks).filter(Boolean), []); + // Docs-only change sets still run `lint`: oxfmt --check covers the whole + // tree, so unformatted .md/.mdx would otherwise reach main. + assert.deepEqual( + Object.entries(scope.checks) + .filter(([, enabled]) => enabled) + .map(([name]) => name), + ["lint"], + ); +}); + +test("keeps the format check on for a docs-only change set", () => { + const scope = classifyChangedPaths([ + "packages/core/docs/content/integrations.mdx", + "docs/plans/2026-09-04-booking-host-working-hours-status.md", + ]); + assert.equal(scope.docsOnly, true); + // oxfmt --check runs over the whole tree, so docs can fail it. Skipping lint + // here is how unformatted docs reached main and turned Lint red on every + // other open PR. + assert.equal(scope.checks.lint, true); + assert.equal(scope.checks.typecheck, false); + assert.equal(scope.checks.build, false); + assert.equal(scope.checks.fast_tests, false); + assert.equal(scope.checks.guards, false); }); test("treats docs-app source and config as code, not documentation", () => { @@ -189,5 +212,12 @@ test("does not run code checks for a mixed docs-only package change", () => { assert.equal(scope.docsOnly, true); assert.equal(scope.full, false); - assert.deepEqual(Object.values(scope.checks).filter(Boolean), []); + // Docs-only change sets still run `lint`: oxfmt --check covers the whole + // tree, so unformatted .md/.mdx would otherwise reach main. + assert.deepEqual( + Object.entries(scope.checks) + .filter(([, enabled]) => enabled) + .map(([name]) => name), + ["lint"], + ); }); diff --git a/scripts/ci-change-scope.ts b/scripts/ci-change-scope.ts index a03bf3e8e3..5b67de3072 100644 --- a/scripts/ci-change-scope.ts +++ b/scripts/ci-change-scope.ts @@ -265,8 +265,12 @@ export function classifyChangedPaths(paths: readonly string[]): ChangeScope { full, nonDocsPaths, checks: docsOnly - ? (Object.fromEntries( - CHECK_NAMES.map((name) => [name, false]), + ? // `fmt:check` formats the whole tree, docs included, so it is the one + // check a docs-only change can still fail. Skipping it here let + // unformatted .md/.mdx land on main and turn Lint red on every + // unrelated PR afterwards. + (Object.fromEntries( + CHECK_NAMES.map((name) => [name, name === "lint"]), ) as CheckSelection) : buildChecks(changedPaths, full), workspaceFilters, diff --git a/scripts/netlify-migration-url.test.ts b/scripts/netlify-migration-url.test.ts new file mode 100644 index 0000000000..49b6451efa --- /dev/null +++ b/scripts/netlify-migration-url.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveNetlifyMigrationUrl } from "./netlify-migration-url.ts"; + +const POOLED = + "postgresql://neondb_owner:pw@ep-round-heart-ap9wji9h-pooler.c-7.us-east-1.aws.neon.tech/neondb?sslmode=require"; +const DIRECT = + "postgresql://neondb_owner:pw@ep-round-heart-ap9wji9h.c-7.us-east-1.aws.neon.tech/neondb?sslmode=require"; + +const envVar = (key: string, context: string, value: string) => [ + { key, values: [{ context, value }] }, +]; + +test("returns the direct endpoint when Netlify only exposes a pooled URL", () => { + assert.equal( + resolveNetlifyMigrationUrl( + envVar("NETLIFY_DATABASE_URL", "production", POOLED), + "production", + ), + DIRECT, + ); +}); + +test("strips the pooler suffix from a site database connection string", () => { + assert.equal( + resolveNetlifyMigrationUrl({ connection_string: POOLED }, "production"), + DIRECT, + ); + assert.equal( + resolveNetlifyMigrationUrl( + { connection_strings: { owner: POOLED } }, + "production", + ), + DIRECT, + ); +}); + +test("leaves an already-direct Neon URL and non-Neon hosts unchanged", () => { + assert.equal( + resolveNetlifyMigrationUrl( + envVar("NETLIFY_DATABASE_URL_UNPOOLED", "production", DIRECT), + "production", + ), + DIRECT, + ); + const supabase = + "postgresql://u:p@db-pooler.example.supabase.co:5432/postgres"; + assert.equal( + resolveNetlifyMigrationUrl( + envVar("DATABASE_URL", "production", supabase), + "production", + ), + supabase, + ); +}); + +test("still prefers the unpooled key and the requested context", () => { + const variables = [ + { + key: "NETLIFY_DATABASE_URL", + values: [{ context: "production", value: POOLED }], + }, + { + key: "NETLIFY_DATABASE_URL_UNPOOLED", + values: [{ context: "production", value: DIRECT }], + }, + ]; + assert.equal(resolveNetlifyMigrationUrl(variables, "production"), DIRECT); + assert.equal( + resolveNetlifyMigrationUrl( + envVar("NETLIFY_DATABASE_URL", "branch-deploy", POOLED), + "production", + ), + undefined, + ); +}); diff --git a/scripts/netlify-migration-url.ts b/scripts/netlify-migration-url.ts index 8854f8b6d7..c1cf206a5f 100644 --- a/scripts/netlify-migration-url.ts +++ b/scripts/netlify-migration-url.ts @@ -35,14 +35,29 @@ const isRecord = (value: unknown): value is Record => const isPostgresUrl = (value: unknown): value is string => typeof value === "string" && value.startsWith("postgres"); +/** + * Release migrations run DDL, so they must reach the direct endpoint. Neon's + * PgBouncer runs in transaction mode: a pooled session can land on a replica + * and reject `CREATE TABLE`/`UPDATE` with "cannot execute ... in a read-only + * transaction", and retrying the same URL fails identically every time. + * + * Same rule as `getMigrationDatabaseUrl()` in packages/core/src/db/client.ts. + * The region between `-pooler.` and `.neon.tech` can hold several + * dot-separated labels (`c-7.us-east-1.aws`), and anchoring on `.neon.tech` + * keeps non-Neon hosts untouched. + */ +const stripNeonPooler = (url: string): string => + url.replace(/-pooler(\.[a-z0-9.-]+\.neon\.tech)/, "$1"); + function resolveNetlifyDatabaseUrl( response: NetlifyDatabaseResponse, ): string | undefined { if (isPostgresUrl(response.connection_string)) { - return response.connection_string; + return stripNeonPooler(response.connection_string); } if (!isRecord(response.connection_strings)) return undefined; - return Object.values(response.connection_strings).find(isPostgresUrl); + const pooled = Object.values(response.connection_strings).find(isPostgresUrl); + return pooled === undefined ? undefined : stripNeonPooler(pooled); } export function resolveNetlifyMigrationUrl( @@ -77,7 +92,7 @@ export function resolveNetlifyMigrationUrl( .find(Boolean); const value = selected?.value; if (isPostgresUrl(value)) { - return value; + return stripNeonPooler(value); } }