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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
"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",
Expand Down
34 changes: 32 additions & 2 deletions scripts/ci-change-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"],
);
});
8 changes: 6 additions & 2 deletions scripts/ci-change-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
) as CheckSelection)
: buildChecks(changedPaths, full),
workspaceFilters,
Expand Down
77 changes: 77 additions & 0 deletions scripts/netlify-migration-url.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
21 changes: 18 additions & 3 deletions scripts/netlify-migration-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,29 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
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(
Expand Down Expand Up @@ -77,7 +92,7 @@ export function resolveNetlifyMigrationUrl(
.find(Boolean);
const value = selected?.value;
if (isPostgresUrl(value)) {
return value;
return stripNeonPooler(value);
}
}

Expand Down
Loading