@databricks-solutions/lakebase-scm-utils 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/scripts/github/index.cjs +3 -1
  2. package/dist/scripts/github/index.js +3 -1
  3. package/dist/scripts/github/pr.cli.cjs +3 -1
  4. package/dist/scripts/github/pr.cli.js +3 -1
  5. package/dist/scripts/index.cjs +84 -21
  6. package/dist/scripts/index.d.cts +1 -1
  7. package/dist/scripts/index.d.ts +1 -1
  8. package/dist/scripts/index.js +76 -15
  9. package/dist/scripts/lakebase/branch.cli.cjs +3 -1
  10. package/dist/scripts/lakebase/branch.cli.js +3 -1
  11. package/dist/scripts/lakebase/ci-app-endpoint.cli.cjs +3 -1
  12. package/dist/scripts/lakebase/ci-app-endpoint.cli.js +3 -1
  13. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +5 -3
  14. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +5 -3
  15. package/dist/scripts/lakebase/collapse-heads.cli.cjs +5 -3
  16. package/dist/scripts/lakebase/collapse-heads.cli.js +5 -3
  17. package/dist/scripts/lakebase/cut-backup.cli.cjs +3 -1
  18. package/dist/scripts/lakebase/cut-backup.cli.js +3 -1
  19. package/dist/scripts/lakebase/cut-tier.cli.cjs +3 -1
  20. package/dist/scripts/lakebase/cut-tier.cli.js +3 -1
  21. package/dist/scripts/lakebase/detect-language.cli.cjs +5 -3
  22. package/dist/scripts/lakebase/detect-language.cli.js +5 -3
  23. package/dist/scripts/lakebase/doctor.cli.cjs +5 -3
  24. package/dist/scripts/lakebase/doctor.cli.js +5 -3
  25. package/dist/scripts/lakebase/get-connection.cli.cjs +5 -3
  26. package/dist/scripts/lakebase/get-connection.cli.js +5 -3
  27. package/dist/scripts/lakebase/index.cjs +84 -21
  28. package/dist/scripts/lakebase/index.d.cts +21 -1
  29. package/dist/scripts/lakebase/index.d.ts +21 -1
  30. package/dist/scripts/lakebase/index.js +76 -15
  31. package/dist/scripts/lakebase/infra-runner.cli.cjs +5 -3
  32. package/dist/scripts/lakebase/infra-runner.cli.js +5 -3
  33. package/dist/scripts/lakebase/new-migration.cli.cjs +5 -3
  34. package/dist/scripts/lakebase/new-migration.cli.js +5 -3
  35. package/dist/scripts/lakebase/schema-diff.cli.cjs +5 -3
  36. package/dist/scripts/lakebase/schema-diff.cli.js +5 -3
  37. package/dist/scripts/lakebase/schema-migrate.cli.cjs +5 -3
  38. package/dist/scripts/lakebase/schema-migrate.cli.js +5 -3
  39. package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +37 -1
  40. package/dist/scripts/lakebase/scm-abandon-feature.cli.js +37 -1
  41. package/dist/scripts/lakebase/scm-adopt-state.cli.cjs +37 -1
  42. package/dist/scripts/lakebase/scm-adopt-state.cli.js +37 -1
  43. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +45 -9
  44. package/dist/scripts/lakebase/scm-claim-feature.cli.js +39 -3
  45. package/dist/scripts/lakebase/scm-cleanup.cli.cjs +3 -1
  46. package/dist/scripts/lakebase/scm-cleanup.cli.js +3 -1
  47. package/dist/scripts/lakebase/scm-doctor.cli.cjs +56 -29
  48. package/dist/scripts/lakebase/scm-doctor.cli.js +50 -23
  49. package/dist/scripts/lakebase/scm-feature-branch.cli.cjs +1 -0
  50. package/dist/scripts/lakebase/scm-feature-branch.cli.js +1 -0
  51. package/dist/scripts/lakebase/scm-merge.cli.cjs +61 -9
  52. package/dist/scripts/lakebase/scm-merge.cli.js +55 -3
  53. package/dist/scripts/lakebase/scm-prepare-pr.cli.cjs +34 -0
  54. package/dist/scripts/lakebase/scm-prepare-pr.cli.js +34 -0
  55. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +6 -3
  56. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +6 -3
  57. package/dist/scripts/lakebase/scm-recover-orphans.cli.cjs +37 -1
  58. package/dist/scripts/lakebase/scm-recover-orphans.cli.js +37 -1
  59. package/dist/scripts/lakebase/scm-state.cli.cjs +5 -4
  60. package/dist/scripts/lakebase/scm-state.cli.js +4 -3
  61. package/dist/scripts/lakebase/scm-wait-ci.cli.cjs +34 -0
  62. package/dist/scripts/lakebase/scm-wait-ci.cli.js +34 -0
  63. package/dist/scripts/util/index.cjs +5 -3
  64. package/dist/scripts/util/index.js +5 -3
  65. package/package.json +1 -1
  66. package/scripts/lakebase/databricks-cli.ts +7 -1
  67. package/scripts/lakebase/enable-e2e.ts +19 -3
  68. package/scripts/lakebase/scm-doctor.ts +1 -12
  69. package/scripts/lakebase/scm-merge.ts +36 -0
  70. package/scripts/lakebase/scm-workflow-state.ts +49 -0
  71. package/templates/project/common/.github/workflows/merge.yml +17 -6
  72. package/templates/project/common/.github/workflows/pr.yml +17 -6
  73. package/templates/project/common/.gitignore.base +8 -0
  74. package/templates/project/common/scripts/run-tests.sh +18 -1
  75. package/templates/project/python/tests/__pycache__/conftest.cpython-314.pyc +0 -0
  76. package/templates/project/python/tests/conftest.py +10 -3
@@ -288,7 +288,9 @@ function resolveProfile(opts) {
288
288
  function buildInvocation(args, opts) {
289
289
  const base = opts.env ?? process.env;
290
290
  const trimmedHost = effectiveHost(opts)?.replace(/\/+$/, "");
291
- const env = trimmedHost ? { ...base, DATABRICKS_HOST: trimmedHost } : base;
291
+ const env = { ...base };
292
+ if (trimmedHost) env.DATABRICKS_HOST = trimmedHost;
293
+ delete env.DATABRICKS_WORKSPACE_ID;
292
294
  const profile = resolveProfile(opts);
293
295
  const argv = profile && !opts.noProfile && !args.includes("--profile") ? [...args, "--profile", profile] : args;
294
296
  return { argv, env, profile };
@@ -1159,6 +1161,39 @@ async function listLocalBranches(args) {
1159
1161
  // scripts/lakebase/scm-workflow-state.ts
1160
1162
  import * as fs5 from "fs";
1161
1163
  import * as path4 from "path";
1164
+ import { execFileSync as execFileSync4 } from "child_process";
1165
+ function isGitTracked(projectDir, rel) {
1166
+ try {
1167
+ execFileSync4("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
1168
+ return true;
1169
+ } catch {
1170
+ return false;
1171
+ }
1172
+ }
1173
+ function ensureWorkflowStateUntracked(projectDir) {
1174
+ const rel = ".lakebase/workflow-state.json";
1175
+ try {
1176
+ if (isGitTracked(projectDir, rel)) {
1177
+ execFileSync4("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
1178
+ }
1179
+ } catch {
1180
+ }
1181
+ try {
1182
+ const gitignore = path4.join(projectDir, ".gitignore");
1183
+ const existing = fs5.existsSync(gitignore) ? fs5.readFileSync(gitignore, "utf8") : "";
1184
+ if (!existing.split("\n").some((l) => l.trim() === rel)) {
1185
+ const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
1186
+ fs5.appendFileSync(
1187
+ gitignore,
1188
+ `${sep}# Runtime SCM claim state (per working tree): a branch checkout must never restore
1189
+ # a stale committed claim over the live one (issue #203 / Finding 28).
1190
+ ${rel}
1191
+ `
1192
+ );
1193
+ }
1194
+ } catch {
1195
+ }
1196
+ }
1162
1197
  var SCM_STATES = [
1163
1198
  "scaffold-complete",
1164
1199
  "feature-claimed",
@@ -1205,6 +1240,7 @@ function writeWorkflowState(projectDir, state) {
1205
1240
  throw new Error(`Refusing to write invalid SCM state:
1206
1241
  ${summary}`);
1207
1242
  }
1243
+ ensureWorkflowStateUntracked(projectDir);
1208
1244
  const dir = path4.join(projectDir, ".lakebase");
1209
1245
  fs5.mkdirSync(dir, { recursive: true });
1210
1246
  const target = stateFilePath(projectDir);
@@ -77545,6 +77545,7 @@ function isCliEntry(importMetaUrl2) {
77545
77545
  init_cjs_shims();
77546
77546
  var fs = __toESM(require("fs"), 1);
77547
77547
  var path = __toESM(require("path"), 1);
77548
+ var import_node_child_process = require("child_process");
77548
77549
  var SCM_STATES = [
77549
77550
  "scaffold-complete",
77550
77551
  "feature-claimed",
@@ -77821,21 +77822,21 @@ var KIT_REGISTRIES = {
77821
77822
  init_cjs_shims();
77822
77823
  var fs6 = __toESM(require("fs"), 1);
77823
77824
  var path4 = __toESM(require("path"), 1);
77824
- var import_node_child_process3 = require("child_process");
77825
+ var import_node_child_process4 = require("child_process");
77825
77826
 
77826
77827
  // scripts/lakebase/branch-create.ts
77827
77828
  init_cjs_shims();
77828
77829
 
77829
77830
  // scripts/lakebase/databricks-cli.ts
77830
77831
  init_cjs_shims();
77831
- var import_node_child_process2 = require("child_process");
77832
+ var import_node_child_process3 = require("child_process");
77832
77833
  var import_node_util = require("util");
77833
77834
  var import_node_path = require("path");
77834
77835
 
77835
77836
  // scripts/lakebase/databricks-profile.ts
77836
77837
  init_cjs_shims();
77837
77838
  var fs2 = __toESM(require("fs"), 1);
77838
- var import_node_child_process = require("child_process");
77839
+ var import_node_child_process2 = require("child_process");
77839
77840
 
77840
77841
  // scripts/util/exec.ts
77841
77842
  init_cjs_shims();
@@ -77847,7 +77848,7 @@ var fs3 = __toESM(require("fs"), 1);
77847
77848
  var path2 = __toESM(require("path"), 1);
77848
77849
 
77849
77850
  // scripts/lakebase/databricks-cli.ts
77850
- var execFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
77851
+ var execFileP = (0, import_node_util.promisify)(import_node_child_process3.execFile);
77851
77852
 
77852
77853
  // scripts/util/poll-until.ts
77853
77854
  init_cjs_shims();
@@ -27,6 +27,7 @@ function isCliEntry(importMetaUrl) {
27
27
  // scripts/lakebase/scm-workflow-state.ts
28
28
  import * as fs from "fs";
29
29
  import * as path from "path";
30
+ import { execFileSync } from "child_process";
30
31
  var SCM_STATES = [
31
32
  "scaffold-complete",
32
33
  "feature-claimed",
@@ -297,16 +298,16 @@ var KIT_REGISTRIES = {
297
298
  // scripts/lakebase/paired-branch.ts
298
299
  import * as fs5 from "fs";
299
300
  import * as path4 from "path";
300
- import { execFileSync as execFileSync3 } from "child_process";
301
+ import { execFileSync as execFileSync4 } from "child_process";
301
302
 
302
303
  // scripts/lakebase/databricks-cli.ts
303
- import { execFile, execFileSync as execFileSync2 } from "child_process";
304
+ import { execFile, execFileSync as execFileSync3 } from "child_process";
304
305
  import { promisify } from "util";
305
306
  import { join as join3 } from "path";
306
307
 
307
308
  // scripts/lakebase/databricks-profile.ts
308
309
  import * as fs2 from "fs";
309
- import { execFileSync } from "child_process";
310
+ import { execFileSync as execFileSync2 } from "child_process";
310
311
 
311
312
  // scripts/util/exec.ts
312
313
  import * as cp from "child_process";
@@ -8351,6 +8351,39 @@ async function pollUntil(args) {
8351
8351
  init_cjs_shims();
8352
8352
  var fs3 = __toESM(require("fs"), 1);
8353
8353
  var path2 = __toESM(require("path"), 1);
8354
+ var import_node_child_process4 = require("child_process");
8355
+ function isGitTracked(projectDir, rel) {
8356
+ try {
8357
+ (0, import_node_child_process4.execFileSync)("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
8358
+ return true;
8359
+ } catch {
8360
+ return false;
8361
+ }
8362
+ }
8363
+ function ensureWorkflowStateUntracked(projectDir) {
8364
+ const rel = ".lakebase/workflow-state.json";
8365
+ try {
8366
+ if (isGitTracked(projectDir, rel)) {
8367
+ (0, import_node_child_process4.execFileSync)("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
8368
+ }
8369
+ } catch {
8370
+ }
8371
+ try {
8372
+ const gitignore = path2.join(projectDir, ".gitignore");
8373
+ const existing = fs3.existsSync(gitignore) ? fs3.readFileSync(gitignore, "utf8") : "";
8374
+ if (!existing.split("\n").some((l) => l.trim() === rel)) {
8375
+ const sep2 = existing === "" || existing.endsWith("\n") ? "" : "\n";
8376
+ fs3.appendFileSync(
8377
+ gitignore,
8378
+ `${sep2}# Runtime SCM claim state (per working tree): a branch checkout must never restore
8379
+ # a stale committed claim over the live one (issue #203 / Finding 28).
8380
+ ${rel}
8381
+ `
8382
+ );
8383
+ }
8384
+ } catch {
8385
+ }
8386
+ }
8354
8387
  var SCM_STATES = [
8355
8388
  "scaffold-complete",
8356
8389
  "feature-claimed",
@@ -8397,6 +8430,7 @@ function writeWorkflowState(projectDir, state) {
8397
8430
  throw new Error(`Refusing to write invalid SCM state:
8398
8431
  ${summary}`);
8399
8432
  }
8433
+ ensureWorkflowStateUntracked(projectDir);
8400
8434
  const dir = path2.join(projectDir, ".lakebase");
8401
8435
  fs3.mkdirSync(dir, { recursive: true });
8402
8436
  const target = stateFilePath(projectDir);
@@ -8339,6 +8339,39 @@ async function pollUntil(args) {
8339
8339
  init_esm_shims();
8340
8340
  import * as fs3 from "fs";
8341
8341
  import * as path3 from "path";
8342
+ import { execFileSync as execFileSync4 } from "child_process";
8343
+ function isGitTracked(projectDir, rel) {
8344
+ try {
8345
+ execFileSync4("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
8346
+ return true;
8347
+ } catch {
8348
+ return false;
8349
+ }
8350
+ }
8351
+ function ensureWorkflowStateUntracked(projectDir) {
8352
+ const rel = ".lakebase/workflow-state.json";
8353
+ try {
8354
+ if (isGitTracked(projectDir, rel)) {
8355
+ execFileSync4("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
8356
+ }
8357
+ } catch {
8358
+ }
8359
+ try {
8360
+ const gitignore = path3.join(projectDir, ".gitignore");
8361
+ const existing = fs3.existsSync(gitignore) ? fs3.readFileSync(gitignore, "utf8") : "";
8362
+ if (!existing.split("\n").some((l) => l.trim() === rel)) {
8363
+ const sep2 = existing === "" || existing.endsWith("\n") ? "" : "\n";
8364
+ fs3.appendFileSync(
8365
+ gitignore,
8366
+ `${sep2}# Runtime SCM claim state (per working tree): a branch checkout must never restore
8367
+ # a stale committed claim over the live one (issue #203 / Finding 28).
8368
+ ${rel}
8369
+ `
8370
+ );
8371
+ }
8372
+ } catch {
8373
+ }
8374
+ }
8342
8375
  var SCM_STATES = [
8343
8376
  "scaffold-complete",
8344
8377
  "feature-claimed",
@@ -8385,6 +8418,7 @@ function writeWorkflowState(projectDir, state) {
8385
8418
  throw new Error(`Refusing to write invalid SCM state:
8386
8419
  ${summary}`);
8387
8420
  }
8421
+ ensureWorkflowStateUntracked(projectDir);
8388
8422
  const dir = path3.join(projectDir, ".lakebase");
8389
8423
  fs3.mkdirSync(dir, { recursive: true });
8390
8424
  const target = stateFilePath(projectDir);
@@ -1659,8 +1659,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1659
1659
  var cached;
1660
1660
  function substrateSelfVersion() {
1661
1661
  if (cached !== void 0) return cached;
1662
- if ("0.2.41".length > 0) {
1663
- cached = "0.2.41";
1662
+ if ("0.2.43".length > 0) {
1663
+ cached = "0.2.43";
1664
1664
  return cached;
1665
1665
  }
1666
1666
  cached = "unknown";
@@ -1895,7 +1895,9 @@ function resolveProfile(opts) {
1895
1895
  function buildInvocation(args, opts) {
1896
1896
  const base = opts.env ?? process.env;
1897
1897
  const trimmedHost = effectiveHost(opts)?.replace(/\/+$/, "");
1898
- const env = trimmedHost ? { ...base, DATABRICKS_HOST: trimmedHost } : base;
1898
+ const env = { ...base };
1899
+ if (trimmedHost) env.DATABRICKS_HOST = trimmedHost;
1900
+ delete env.DATABRICKS_WORKSPACE_ID;
1899
1901
  const profile = resolveProfile(opts);
1900
1902
  const argv = profile && !opts.noProfile && !args.includes("--profile") ? [...args, "--profile", profile] : args;
1901
1903
  return { argv, env, profile };
@@ -1630,8 +1630,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1630
1630
  var cached;
1631
1631
  function substrateSelfVersion() {
1632
1632
  if (cached !== void 0) return cached;
1633
- if ("0.2.41".length > 0) {
1634
- cached = "0.2.41";
1633
+ if ("0.2.43".length > 0) {
1634
+ cached = "0.2.43";
1635
1635
  return cached;
1636
1636
  }
1637
1637
  cached = "unknown";
@@ -1866,7 +1866,9 @@ function resolveProfile(opts) {
1866
1866
  function buildInvocation(args, opts) {
1867
1867
  const base = opts.env ?? process.env;
1868
1868
  const trimmedHost = effectiveHost(opts)?.replace(/\/+$/, "");
1869
- const env = trimmedHost ? { ...base, DATABRICKS_HOST: trimmedHost } : base;
1869
+ const env = { ...base };
1870
+ if (trimmedHost) env.DATABRICKS_HOST = trimmedHost;
1871
+ delete env.DATABRICKS_WORKSPACE_ID;
1870
1872
  const profile = resolveProfile(opts);
1871
1873
  const argv = profile && !opts.noProfile && !args.includes("--profile") ? [...args, "--profile", profile] : args;
1872
1874
  return { argv, env, profile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databricks-solutions/lakebase-scm-utils",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
4
4
  "description": "Lakebase SCM + substrate engine: database branching, paired-branch SCM workflows, connection/credential minting, schema migration, project scaffold + deploy primitives, and the git/github utility layer. Extracted from consort for portable, standalone consumption by lakebase-scm-extension and the SFTDD orchestration.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -149,7 +149,13 @@ export function buildInvocation(args: string[], opts: DatabricksCliOptions): {
149
149
  // so an in-project call pins the project workspace rather than the DEFAULT
150
150
  // profile's default host. Trailing slash stripped for a stable value.
151
151
  const trimmedHost = effectiveHost(opts)?.replace(/\/+$/, "");
152
- const env: NodeJS.ProcessEnv = trimmedHost ? { ...base, DATABRICKS_HOST: trimmedHost } : base;
152
+ const env: NodeJS.ProcessEnv = { ...base };
153
+ if (trimmedHost) env.DATABRICKS_HOST = trimmedHost;
154
+ // A stray DATABRICKS_WORKSPACE_ID (a bare numeric workspace ID, never a URL)
155
+ // makes the CLI's unified-host path dial https://<id> (issue #204.1). The
156
+ // workspace always resolves via the profile/host chain above, so scrub the
157
+ // stray var from the child env (the documented workaround, centralized).
158
+ delete env.DATABRICKS_WORKSPACE_ID;
153
159
  const profile = resolveProfile(opts);
154
160
  const argv =
155
161
  profile && !opts.noProfile && !args.includes("--profile")
@@ -225,12 +225,27 @@ function runTestsE2eBlock(): string {
225
225
  ' export VITE_PROXY_TARGET="http://127.0.0.1:${E2E_BACKEND_PORT}"',
226
226
  ' echo "Local E2E free-port: backend :$E2E_BACKEND_PORT / client :$E2E_CLIENT_PORT"',
227
227
  'fi',
228
+ // Stale-server hygiene (issue #197): CI=1 on the Playwright invocations below
229
+ // forces EVERY config vintage's reuseExistingServer guard OFF (old `!CI` and
230
+ // new `!!BASE_URL` alike), so the verify never reuses a stale server bound to
231
+ // an unmigrated branch (the false-500 misdiagnosis). A lingering uvicorn/vite
232
+ // on the conventional default ports still serves STALE schema to anything
233
+ // that reaches it, so clear it (TERM, not KILL) before the boot - never when
234
+ // the caller pre-set the ports (the deploy gate owns that server; BASE_URL).
235
+ 'if [ -z "${BASE_URL:-}" ] && command -v lsof >/dev/null 2>&1; then',
236
+ ' for p in "${E2E_BACKEND_PORT:-8000}" "${E2E_CLIENT_PORT:-5173}"; do',
237
+ ' if lsof -iTCP:"$p" -sTCP:LISTEN -n -P >/dev/null 2>&1; then',
238
+ ' echo "Local E2E: clearing a stale listener on :$p before the webServer boot"',
239
+ ' lsof -ti tcp:"$p" 2>/dev/null | xargs kill 2>/dev/null || true',
240
+ ' fi',
241
+ ' done',
242
+ 'fi',
228
243
  'if [ -f "$REPO_ROOT/playwright.config.ts" ] || [ -f "$REPO_ROOT/playwright.config.js" ]; then',
229
244
  ' echo "Running Playwright E2E tests..."',
230
245
  ' if [ -f "$REPO_ROOT/package.json" ] && command -v npm >/dev/null 2>&1; then',
231
- ' (cd "$REPO_ROOT" && npm run test:e2e)',
246
+ ' (cd "$REPO_ROOT" && CI=1 npm run test:e2e)',
232
247
  " else",
233
- ' (cd "$REPO_ROOT" && npx --yes playwright test)',
248
+ ' (cd "$REPO_ROOT" && CI=1 npx --yes playwright test)',
234
249
  " fi",
235
250
  // Python E2E: pytest-playwright + the shipped tests/e2e/conftest.py
236
251
  // (live_server). Gated on the conftest + pyproject so it only fires for a
@@ -269,7 +284,8 @@ function runTestsE2eBlock(): string {
269
284
  'if [ -f "$REPO_ROOT/client/playwright.config.ts" ] || [ -f "$REPO_ROOT/client/playwright.config.js" ] || [ -f "$REPO_ROOT/client/playwright.config.mjs" ]; then',
270
285
  ' echo "Running client Playwright E2E tests..."',
271
286
  ' (cd "$REPO_ROOT/client" && npx --yes playwright install chromium)',
272
- ' (cd "$REPO_ROOT/client" && npx --yes playwright test --pass-with-no-tests)',
287
+ // CI=1: no server reuse on any config vintage (see the stale-server note above).
288
+ ' (cd "$REPO_ROOT/client" && CI=1 npx --yes playwright test --pass-with-no-tests)',
273
289
  "fi",
274
290
  "",
275
291
  ].join("\n");
@@ -9,7 +9,6 @@
9
9
  // an error rather than performing a related-but-different remediation.
10
10
 
11
11
  import * as fs from "node:fs";
12
- import { execFileSync } from "node:child_process";
13
12
  import * as path from "node:path";
14
13
  import {
15
14
  getBranchByName,
@@ -25,6 +24,7 @@ import { recoverOrphans } from "./scm-recover-orphans.js";
25
24
  import { abandonFeatureBranch } from "./scm-abandon-feature.js";
26
25
  import {
27
26
  readWorkflowState,
27
+ isGitTracked,
28
28
  type ScmWorkflowState,
29
29
  type TierTopology,
30
30
  } from "./scm-workflow-state.js";
@@ -106,17 +106,6 @@ function leafOf(b: LakebaseBranchInfo): string {
106
106
  return b.name.split("/").pop() ?? b.name;
107
107
  }
108
108
 
109
- /** True iff `rel` (repo-relative) is git-tracked in projectDir. Best-effort: no
110
- * git / not a repo -> false. */
111
- function isGitTracked(projectDir: string, rel: string): boolean {
112
- try {
113
- execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
114
- return true;
115
- } catch {
116
- return false;
117
- }
118
- }
119
-
120
109
  function worstOf(a: DoctorSeverity, b: DoctorSeverity): DoctorSeverity {
121
110
  const order: DoctorSeverity[] = ["ok", "warn", "fail"];
122
111
  return order[Math.max(order.indexOf(a), order.indexOf(b))] as DoctorSeverity;
@@ -21,6 +21,35 @@ import { pollUntil } from "../util/poll-until.js";
21
21
  import { exec } from "../util/exec.js";
22
22
  import { getCurrentBranch } from "../git/inspect.js";
23
23
  import { resolveGitBase } from "./scm-git-base.js";
24
+
25
+ /**
26
+ * Fast-forward the local tier ref to origin/<tier> after a remote merge, WITHOUT
27
+ * needing a checkout. A promote interrupted after the remote merge (e.g. a
28
+ * downstream migrate failure) otherwise leaves the local tier BEHIND, so the
29
+ * next sprint plans on stale code and the next push rejects non-fast-forward.
30
+ * Mechanism by head position: when HEAD IS the tier, `git pull --ff-only` syncs
31
+ * ref + working tree; otherwise `git fetch origin <tier>:<tier>` fast-forwards
32
+ * the local ref in place (git refuses fetch-into-current-branch, hence the
33
+ * split). Returns null on success; a warning string when the sync fails (e.g.
34
+ * not a fast-forward because the local tier has its own commits), never throws.
35
+ */
36
+ export async function reconcileTierToOrigin(args: { cwd: string; tier: string }): Promise<string | null> {
37
+ const { cwd, tier } = args;
38
+ try {
39
+ const head = await getCurrentBranch({ cwd });
40
+ if (head === tier) {
41
+ await exec(`git pull --ff-only`, { cwd, timeout: 30_000 });
42
+ } else {
43
+ await exec(`git fetch origin ${shellEscape(`${tier}:${tier}`)}`, { cwd, timeout: 30_000 });
44
+ }
45
+ return null;
46
+ } catch (err) {
47
+ return (
48
+ `local reconcile of ${tier} to origin/${tier} failed: ${err instanceof Error ? err.message : String(err)}. ` +
49
+ `Reconcile it by hand (git checkout ${tier} && git pull --ff-only).`
50
+ );
51
+ }
52
+ }
24
53
  import {
25
54
  readWorkflowState,
26
55
  writeWorkflowState,
@@ -328,6 +357,13 @@ export async function mergeFeature(args: MergeArgs): Promise<MergeResult> {
328
357
  }
329
358
  } else {
330
359
  headAfter = head || current.branch;
360
+ // HEAD isn't the feature branch, so the checkout+ff path above didn't run.
361
+ // Reconcile the local tier ref to origin anyway (no checkout needed): a
362
+ // promote interrupted after the remote merge must not leave the tier behind
363
+ // (the stockflow F1 case: local staging missed the merge, the next push
364
+ // rejected non-fast-forward). Best-effort; a failure is a warning, not a throw.
365
+ const reconcileWarning = await reconcileTierToOrigin({ cwd: args.projectDir, tier: switchTo });
366
+ if (reconcileWarning) warnings.push(reconcileWarning);
331
367
  }
332
368
  if (headAfter !== current.branch) {
333
369
  try {
@@ -13,6 +13,52 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ /** True iff `rel` (repo-relative) is git-tracked in projectDir. Best-effort: no
19
+ * git / not a repo -> false. Shared home (scm-doctor's Finding-28 check reads
20
+ * the same predicate). */
21
+ export function isGitTracked(projectDir: string, rel: string): boolean {
22
+ try {
23
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** Ensure the runtime SCM claim state is NOT git-tracked (issue #203 / Finding 28):
31
+ * a tracked .lakebase/workflow-state.json gets a branch-COMMITTED stale claim
32
+ * restored over the live one by every branch checkout / `git reset --hard
33
+ * origin/<tier>`, which then blocks the next feature's claim
34
+ * ("already-claimed-other") until a manual abandon+reclaim. Untrack it
35
+ * (index-only; the working file stays) and cover it in .gitignore, so the live
36
+ * claim survives checkouts. The committed .lakebase/kit-ref + scm-utils-ref stay
37
+ * tracked deliberately (CI resolves them). Idempotent + best-effort (non-git
38
+ * dirs / unwritable .gitignore skip silently – the state write itself still lands). */
39
+ function ensureWorkflowStateUntracked(projectDir: string): void {
40
+ const rel = ".lakebase/workflow-state.json";
41
+ try {
42
+ if (isGitTracked(projectDir, rel)) {
43
+ execFileSync("git", ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", rel], { cwd: projectDir, stdio: "ignore" });
44
+ }
45
+ } catch {
46
+ /* best-effort */
47
+ }
48
+ try {
49
+ const gitignore = path.join(projectDir, ".gitignore");
50
+ const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8") : "";
51
+ if (!existing.split("\n").some((l) => l.trim() === rel)) {
52
+ const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
53
+ fs.appendFileSync(
54
+ gitignore,
55
+ `${sep}# Runtime SCM claim state (per working tree): a branch checkout must never restore\n# a stale committed claim over the live one (issue #203 / Finding 28).\n${rel}\n`,
56
+ );
57
+ }
58
+ } catch {
59
+ /* best-effort */
60
+ }
61
+ }
16
62
 
17
63
  /** All SCM states, in canonical progression order. */
18
64
  export const SCM_STATES = [
@@ -133,6 +179,9 @@ export function writeWorkflowState(
133
179
  .join("\n");
134
180
  throw new Error(`Refusing to write invalid SCM state:\n${summary}`);
135
181
  }
182
+ // Self-heal on every write (issue #203): the live claim survives checkouts
183
+ // only while the state file is untracked.
184
+ ensureWorkflowStateUntracked(projectDir);
136
185
  const dir = path.join(projectDir, ".lakebase");
137
186
  fs.mkdirSync(dir, { recursive: true });
138
187
  const target = stateFilePath(projectDir);
@@ -74,14 +74,25 @@ jobs:
74
74
  SCM_UTILS_REF="$(tr -d '[:space:]' < .lakebase/scm-utils-ref 2>/dev/null || true)"
75
75
  SCM_UTILS_REF="${SCM_UTILS_REF:-v{{LAKEBASE_SCM_UTILS_VERSION}}}"
76
76
  echo "SCM_UTILS_REF=${SCM_UTILS_REF}" >> "$GITHUB_ENV"
77
- # Registry-first: version-tag refs install the published tarball from the
78
- # npm registry (seconds); branch/SHA refs keep the GitHub source form
79
- # (their only source).
77
+ # Registry-first for version tags, with a GitHub fallback when the registry
78
+ # cannot serve the tarball yet: a freshly-published version is blocked by
79
+ # the proxy's same-day security screen for ~24h (a staging merge then fails
80
+ # on npx ETARGET), and a branch/SHA ref only ever has GitHub. Probe the
81
+ # tarball URL once and pick the source that actually serves it, so a
82
+ # same-day publish never stalls CI.
83
+ SPEC=""
80
84
  case "$SCM_UTILS_REF" in
81
- v[0-9]*) echo "SCM_UTILS_NPX_PKG=@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF#v}" >> "$GITHUB_ENV" ;;
82
- [0-9]*) echo "SCM_UTILS_NPX_PKG=@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF}" >> "$GITHUB_ENV" ;;
83
- *) echo "SCM_UTILS_NPX_PKG=github:databricks-solutions/lakebase-scm-utils#${SCM_UTILS_REF}" >> "$GITHUB_ENV" ;;
85
+ v[0-9]*|[0-9]*) SPEC="@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF#v}" ;;
84
86
  esac
87
+ if [ -n "$SPEC" ]; then
88
+ TARBALL_URL="$(npm view "${SPEC}" dist.tarball 2>/dev/null || true)"
89
+ if [ -z "$TARBALL_URL" ] || ! curl -fsSI --max-time 15 "$TARBALL_URL" >/dev/null 2>&1; then
90
+ echo "resolve-substrate: registry cannot serve ${SPEC} yet (same-day publish screen); falling back to GitHub."
91
+ SPEC=""
92
+ fi
93
+ fi
94
+ [ -z "$SPEC" ] && SPEC="github:databricks-solutions/lakebase-scm-utils#${SCM_UTILS_REF}"
95
+ echo "SCM_UTILS_NPX_PKG=${SPEC}" >> "$GITHUB_ENV"
85
96
 
86
97
  - name: Detect project language
87
98
  id: detect-lang
@@ -33,14 +33,25 @@ jobs:
33
33
  SCM_UTILS_REF="$(tr -d '[:space:]' < .lakebase/scm-utils-ref 2>/dev/null || true)"
34
34
  SCM_UTILS_REF="${SCM_UTILS_REF:-v{{LAKEBASE_SCM_UTILS_VERSION}}}"
35
35
  echo "SCM_UTILS_REF=${SCM_UTILS_REF}" >> "$GITHUB_ENV"
36
- # Registry-first: version-tag refs install the published tarball from the
37
- # npm registry (seconds); branch/SHA refs keep the GitHub source form
38
- # (their only source).
36
+ # Registry-first for version tags, with a GitHub fallback when the registry
37
+ # cannot serve the tarball yet: a freshly-published version is blocked by
38
+ # the proxy's same-day security screen for ~24h (a staging merge then fails
39
+ # on npx ETARGET), and a branch/SHA ref only ever has GitHub. Probe the
40
+ # tarball URL once and pick the source that actually serves it, so a
41
+ # same-day publish never stalls CI.
42
+ SPEC=""
39
43
  case "$SCM_UTILS_REF" in
40
- v[0-9]*) echo "SCM_UTILS_NPX_PKG=@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF#v}" >> "$GITHUB_ENV" ;;
41
- [0-9]*) echo "SCM_UTILS_NPX_PKG=@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF}" >> "$GITHUB_ENV" ;;
42
- *) echo "SCM_UTILS_NPX_PKG=github:databricks-solutions/lakebase-scm-utils#${SCM_UTILS_REF}" >> "$GITHUB_ENV" ;;
44
+ v[0-9]*|[0-9]*) SPEC="@databricks-solutions/lakebase-scm-utils@${SCM_UTILS_REF#v}" ;;
43
45
  esac
46
+ if [ -n "$SPEC" ]; then
47
+ TARBALL_URL="$(npm view "${SPEC}" dist.tarball 2>/dev/null || true)"
48
+ if [ -z "$TARBALL_URL" ] || ! curl -fsSI --max-time 15 "$TARBALL_URL" >/dev/null 2>&1; then
49
+ echo "resolve-substrate: registry cannot serve ${SPEC} yet (same-day publish screen); falling back to GitHub."
50
+ SPEC=""
51
+ fi
52
+ fi
53
+ [ -z "$SPEC" ] && SPEC="github:databricks-solutions/lakebase-scm-utils#${SCM_UTILS_REF}"
54
+ echo "SCM_UTILS_NPX_PKG=${SPEC}" >> "$GITHUB_ENV"
44
55
 
45
56
  - name: Detect project language
46
57
  id: detect-lang
@@ -58,6 +58,14 @@ application-local.properties
58
58
  .lakebase/kit-ref.local
59
59
  .lakebase/scm-utils-ref.local
60
60
 
61
+ # The runtime SCM CLAIM state: per working tree, rewritten by every SCM transition
62
+ # (claim / pr-ready / ci-green / merged). If it is git-tracked, a branch checkout
63
+ # or `git reset --hard origin/<tier>` restores a branch-COMMITTED stale claim over
64
+ # the live one, and the next feature's claim then refuses "already-claimed-other"
65
+ # until a manual abandon+reclaim (issue #203 / Finding 28). Never track it; the
66
+ # committed kit-ref / scm-utils-ref pins above stay tracked deliberately.
67
+ .lakebase/workflow-state.json
68
+
61
69
  # Per-agent local memory: each role subagent (memory: project) persists its own
62
70
  # learnings here mid-run. It is local + churny (re-written every run), not feature
63
71
  # code, so it must never be committed nor count as a dirty working tree , an
@@ -213,6 +213,20 @@ ensure_local_e2e_ports() {
213
213
  export VITE_PROXY_TARGET="http://127.0.0.1:${E2E_BACKEND_PORT}"
214
214
  echo "Local E2E free-port: backend :$E2E_BACKEND_PORT / client :$E2E_CLIENT_PORT"
215
215
  fi
216
+ # Stale-server hygiene (issue #197): CI=1 (on the Playwright invocation below)
217
+ # means the webServer never REUSES a stale app, and free_port moved us off any
218
+ # collision - but a lingering uvicorn/vite on the conventional default ports (a
219
+ # prior E2E run, or a deploy whose teardown missed) still serves STALE schema to
220
+ # anything that reaches it. Clear it (TERM, not KILL) before the boot. Never
221
+ # when the caller pre-set the ports (the deploy gate owns that server).
222
+ if [ -z "${BASE_URL:-}" ] && command -v lsof >/dev/null 2>&1; then
223
+ for p in "${E2E_BACKEND_PORT:-8000}" "${E2E_CLIENT_PORT:-5173}"; do
224
+ if lsof -iTCP:"$p" -sTCP:LISTEN -n -P >/dev/null 2>&1; then
225
+ echo "Local E2E: clearing a stale listener on :$p before the webServer boot"
226
+ lsof -ti tcp:"$p" 2>/dev/null | xargs kill 2>/dev/null || true
227
+ fi
228
+ done
229
+ fi
216
230
  }
217
231
 
218
232
  if [ "$#" -eq 0 ] && [ -d "$REPO_ROOT/client/tests/e2e" ] \
@@ -240,8 +254,11 @@ if [ "$#" -eq 0 ] && [ -d "$REPO_ROOT/client/tests/e2e" ] \
240
254
  # Use the client's OWN playwright bin (guaranteed present by the check/install above), not npx,
241
255
  # so browser install + the run are deterministic under the deploy gate. install chromium first
242
256
  # (playwright test does not auto-install browsers); a failing E2E fails the run (set -e).
257
+ # CI=1: force EVERY playwright.config vintage's reuseExistingServer guard OFF
258
+ # (old `!CI` and new `!!BASE_URL` alike) so the verify never reuses a stale
259
+ # server bound to an unmigrated branch (issue #197's false-500 misdiagnosis).
243
260
  ensure_local_e2e_ports
244
- ( cd "$REPO_ROOT/client" && ./node_modules/.bin/playwright install chromium && npm run test:e2e )
261
+ ( cd "$REPO_ROOT/client" && ./node_modules/.bin/playwright install chromium && CI=1 npm run test:e2e )
245
262
  fi
246
263
  fi
247
264
 
@@ -41,9 +41,16 @@ def _restore_migration_head_after_each_test():
41
41
 
42
42
  from alembic import command
43
43
  from alembic.config import Config
44
+ from alembic.util.exc import CommandError
45
+ from sqlalchemy.exc import OperationalError
44
46
 
45
47
  ini = str(Path(__file__).resolve().parent.parent / "alembic.ini")
46
48
  command.upgrade(Config(ini), "head")
47
- except Exception:
48
- # Best-effort isolation; never fail a test on teardown restoration.
49
- pass
49
+ except (CommandError, OperationalError) as exc:
50
+ # Best-effort isolation, but never SILENT and never an umbrella swallow:
51
+ # surface a restore failure instead of hiding it (a broken migration must
52
+ # be visible, not masked as routine teardown noise). CommandError wraps
53
+ # alembic's own failures; OperationalError covers DBAPI/connection errors.
54
+ # Everything else (a real schema error) propagates and fails loudly, which
55
+ # is the correct behavior for a genuinely broken restore.
56
+ print(f"WARNING: migration-head restore failed during teardown: {exc}")