@databricks-solutions/lakebase-scm-utils 0.2.46 → 0.2.48

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 (63) hide show
  1. package/dist/{pr-D3CteJaf.d.cts → pr-e-E4aP4j.d.cts} +6 -0
  2. package/dist/{pr-D3CteJaf.d.ts → pr-e-E4aP4j.d.ts} +6 -0
  3. package/dist/scripts/github/index.cjs +2 -0
  4. package/dist/scripts/github/index.d.cts +1 -1
  5. package/dist/scripts/github/index.d.ts +1 -1
  6. package/dist/scripts/github/index.js +2 -0
  7. package/dist/scripts/github/pr.cli.cjs +2 -0
  8. package/dist/scripts/github/pr.cli.js +2 -0
  9. package/dist/scripts/index.cjs +81 -4
  10. package/dist/scripts/index.d.cts +3 -3
  11. package/dist/scripts/index.d.ts +3 -3
  12. package/dist/scripts/index.js +78 -4
  13. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  15. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  16. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  17. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  18. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  19. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  21. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  22. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  23. package/dist/scripts/lakebase/index.cjs +87 -4
  24. package/dist/scripts/lakebase/index.d.cts +32 -14
  25. package/dist/scripts/lakebase/index.d.ts +32 -14
  26. package/dist/scripts/lakebase/index.js +86 -4
  27. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  29. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  31. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  32. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  33. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  36. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  37. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  38. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  39. package/dist/scripts/lakebase/scm-merge.cli.cjs +131 -2
  40. package/dist/scripts/lakebase/scm-merge.cli.js +131 -2
  41. package/dist/scripts/lakebase/scm-prepare-pr.cli.cjs +2 -0
  42. package/dist/scripts/lakebase/scm-prepare-pr.cli.js +2 -0
  43. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  44. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  45. package/dist/scripts/lakebase/scm-wait-ci.cli.cjs +8 -0
  46. package/dist/scripts/lakebase/scm-wait-ci.cli.js +8 -0
  47. package/dist/scripts/lakebase/sync-ci-secrets.cli.cjs +8610 -0
  48. package/dist/scripts/lakebase/sync-ci-secrets.cli.d.cts +4 -0
  49. package/dist/scripts/lakebase/sync-ci-secrets.cli.d.ts +4 -0
  50. package/dist/scripts/lakebase/sync-ci-secrets.cli.js +8597 -0
  51. package/dist/scripts/util/index.cjs +21 -2
  52. package/dist/scripts/util/index.d.cts +9 -1
  53. package/dist/scripts/util/index.d.ts +9 -1
  54. package/dist/scripts/util/index.js +19 -2
  55. package/package.json +3 -2
  56. package/scripts/github/pr.ts +8 -0
  57. package/scripts/lakebase/create-project.ts +14 -3
  58. package/scripts/lakebase/scm-merge.ts +91 -0
  59. package/scripts/lakebase/scm-wait-ci.ts +13 -0
  60. package/scripts/lakebase/sync-ci-secrets.cli.ts +122 -0
  61. package/scripts/util/ci-secrets.ts +14 -1
  62. package/templates/project/common/.gitignore.base +4 -0
  63. package/templates/project/common/scripts/lk +22 -4
@@ -1466,12 +1466,14 @@ var util_exports = {};
1466
1466
  __export(util_exports, {
1467
1467
  LAKEBASE_BRANCH_NAME_MAX: () => LAKEBASE_BRANCH_NAME_MAX,
1468
1468
  PROXY_ENV_KEYS: () => PROXY_ENV_KEYS,
1469
+ REQUIRED_CI_SECRETS: () => REQUIRED_CI_SECRETS,
1469
1470
  copyDirSubstituted: () => copyDirSubstituted,
1470
1471
  delay: () => delay,
1471
1472
  exec: () => exec2,
1472
1473
  extractZipToDir: () => extractZipToDir,
1473
1474
  formatOwnerRepo: () => formatOwnerRepo,
1474
1475
  isCliEntry: () => isCliEntry,
1476
+ missingCiSecrets: () => missingCiSecrets,
1475
1477
  parseOwnerRepo: () => parseOwnerRepo,
1476
1478
  patchPomForLakebase: () => patchPomForLakebase,
1477
1479
  pollUntil: () => pollUntil,
@@ -1659,8 +1661,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1659
1661
  var cached;
1660
1662
  function substrateSelfVersion() {
1661
1663
  if (cached !== void 0) return cached;
1662
- if ("0.2.46".length > 0) {
1663
- cached = "0.2.46";
1664
+ if ("0.2.48".length > 0) {
1665
+ cached = "0.2.48";
1664
1666
  return cached;
1665
1667
  }
1666
1668
  cached = "unknown";
@@ -8580,6 +8582,16 @@ async function setRepoSecrets(ownerRepo, secrets) {
8580
8582
  await setRepoSecret(ownerRepo, name, value);
8581
8583
  }
8582
8584
  }
8585
+ async function listSecretNames(ownerRepo) {
8586
+ try {
8587
+ const { owner, repo } = parseOwnerRepo(ownerRepo);
8588
+ const octokit = await getOctokit();
8589
+ const { data } = await octokit.rest.actions.listRepoSecrets({ owner, repo });
8590
+ return data.secrets.map((s) => s.name);
8591
+ } catch {
8592
+ return [];
8593
+ }
8594
+ }
8583
8595
 
8584
8596
  // scripts/git/remote.ts
8585
8597
  init_cjs_shims();
@@ -8619,6 +8631,11 @@ async function getOwnerRepo(cwd) {
8619
8631
  }
8620
8632
 
8621
8633
  // scripts/util/ci-secrets.ts
8634
+ var REQUIRED_CI_SECRETS = ["DATABRICKS_HOST", "LAKEBASE_PROJECT_ID", "DATABRICKS_TOKEN"];
8635
+ async function missingCiSecrets(ownerRepo) {
8636
+ const present = new Set(await listSecretNames(ownerRepo));
8637
+ return REQUIRED_CI_SECRETS.filter((n) => !present.has(n));
8638
+ }
8622
8639
  async function syncCiSecrets(args) {
8623
8640
  const lifetime = args.lifetimeSeconds ?? 86400;
8624
8641
  const comment = args.comment ?? "GitHub Actions CI";
@@ -8757,12 +8774,14 @@ async function pollUntilDefined(probe, opts) {
8757
8774
  0 && (module.exports = {
8758
8775
  LAKEBASE_BRANCH_NAME_MAX,
8759
8776
  PROXY_ENV_KEYS,
8777
+ REQUIRED_CI_SECRETS,
8760
8778
  copyDirSubstituted,
8761
8779
  delay,
8762
8780
  exec,
8763
8781
  extractZipToDir,
8764
8782
  formatOwnerRepo,
8765
8783
  isCliEntry,
8784
+ missingCiSecrets,
8766
8785
  parseOwnerRepo,
8767
8786
  patchPomForLakebase,
8768
8787
  pollUntil,
@@ -33,6 +33,14 @@ declare function copyDirSubstituted(srcDir: string, destDir: string, args?: Copy
33
33
  declare const LAKEBASE_BRANCH_NAME_MAX = 63;
34
34
  declare function sanitizeBranchName(gitBranch: string): string;
35
35
 
36
+ /** The repo Actions secrets CI needs to provision a per-PR Lakebase branch. Missing ANY of
37
+ * these leaves CI unable to produce a DATABASE_URL, so the test step aborts — the failure
38
+ * that otherwise only surfaces at promotion, an entire feature after creation. */
39
+ declare const REQUIRED_CI_SECRETS: readonly ["DATABRICKS_HOST", "LAKEBASE_PROJECT_ID", "DATABRICKS_TOKEN"];
40
+ /** Which required CI secrets are NOT present on the repo (empty = fully provisioned). Used to
41
+ * VERIFY a sync landed — a `databricks tokens create` or `gh secret set` that fails must be
42
+ * caught here, not swallowed, so CI-auth is never silently absent. */
43
+ declare function missingCiSecrets(ownerRepo: string): Promise<string[]>;
36
44
  interface SyncCiSecretsArgs {
37
45
  /** Project root (used to resolve ownerRepo from `git remote` when not given,
38
46
  * and as the cwd for the `databricks tokens create` call). */
@@ -161,4 +169,4 @@ declare function pollUntilDefined<T>(probe: (ctx: {
161
169
  elapsedMs: number;
162
170
  }) => Promise<T | undefined>, opts: Omit<PollUntilArgs<T>, "probe">): Promise<PollUntilResult<T>>;
163
171
 
164
- export { type CopyDirSubstitutedArgs, LAKEBASE_BRANCH_NAME_MAX, type OwnerRepo, PROXY_ENV_KEYS, type PollProbeDone, type PollProbePending, type PollProbeResult, type PollUntilArgs, type PollUntilDoneResult, type PollUntilResult, type PollUntilTimeoutResult, type SyncCiSecretsArgs, copyDirSubstituted, delay, extractZipToDir, formatOwnerRepo, isCliEntry, parseOwnerRepo, patchPomForLakebase, pollUntil, pollUntilDefined, proxyEnvSubset, sanitizeArtifactId, sanitizeBranchName, syncCiSecrets, withProxyEnv };
172
+ export { type CopyDirSubstitutedArgs, LAKEBASE_BRANCH_NAME_MAX, type OwnerRepo, PROXY_ENV_KEYS, type PollProbeDone, type PollProbePending, type PollProbeResult, type PollUntilArgs, type PollUntilDoneResult, type PollUntilResult, type PollUntilTimeoutResult, REQUIRED_CI_SECRETS, type SyncCiSecretsArgs, copyDirSubstituted, delay, extractZipToDir, formatOwnerRepo, isCliEntry, missingCiSecrets, parseOwnerRepo, patchPomForLakebase, pollUntil, pollUntilDefined, proxyEnvSubset, sanitizeArtifactId, sanitizeBranchName, syncCiSecrets, withProxyEnv };
@@ -33,6 +33,14 @@ declare function copyDirSubstituted(srcDir: string, destDir: string, args?: Copy
33
33
  declare const LAKEBASE_BRANCH_NAME_MAX = 63;
34
34
  declare function sanitizeBranchName(gitBranch: string): string;
35
35
 
36
+ /** The repo Actions secrets CI needs to provision a per-PR Lakebase branch. Missing ANY of
37
+ * these leaves CI unable to produce a DATABASE_URL, so the test step aborts — the failure
38
+ * that otherwise only surfaces at promotion, an entire feature after creation. */
39
+ declare const REQUIRED_CI_SECRETS: readonly ["DATABRICKS_HOST", "LAKEBASE_PROJECT_ID", "DATABRICKS_TOKEN"];
40
+ /** Which required CI secrets are NOT present on the repo (empty = fully provisioned). Used to
41
+ * VERIFY a sync landed — a `databricks tokens create` or `gh secret set` that fails must be
42
+ * caught here, not swallowed, so CI-auth is never silently absent. */
43
+ declare function missingCiSecrets(ownerRepo: string): Promise<string[]>;
36
44
  interface SyncCiSecretsArgs {
37
45
  /** Project root (used to resolve ownerRepo from `git remote` when not given,
38
46
  * and as the cwd for the `databricks tokens create` call). */
@@ -161,4 +169,4 @@ declare function pollUntilDefined<T>(probe: (ctx: {
161
169
  elapsedMs: number;
162
170
  }) => Promise<T | undefined>, opts: Omit<PollUntilArgs<T>, "probe">): Promise<PollUntilResult<T>>;
163
171
 
164
- export { type CopyDirSubstitutedArgs, LAKEBASE_BRANCH_NAME_MAX, type OwnerRepo, PROXY_ENV_KEYS, type PollProbeDone, type PollProbePending, type PollProbeResult, type PollUntilArgs, type PollUntilDoneResult, type PollUntilResult, type PollUntilTimeoutResult, type SyncCiSecretsArgs, copyDirSubstituted, delay, extractZipToDir, formatOwnerRepo, isCliEntry, parseOwnerRepo, patchPomForLakebase, pollUntil, pollUntilDefined, proxyEnvSubset, sanitizeArtifactId, sanitizeBranchName, syncCiSecrets, withProxyEnv };
172
+ export { type CopyDirSubstitutedArgs, LAKEBASE_BRANCH_NAME_MAX, type OwnerRepo, PROXY_ENV_KEYS, type PollProbeDone, type PollProbePending, type PollProbeResult, type PollUntilArgs, type PollUntilDoneResult, type PollUntilResult, type PollUntilTimeoutResult, REQUIRED_CI_SECRETS, type SyncCiSecretsArgs, copyDirSubstituted, delay, extractZipToDir, formatOwnerRepo, isCliEntry, missingCiSecrets, parseOwnerRepo, patchPomForLakebase, pollUntil, pollUntilDefined, proxyEnvSubset, sanitizeArtifactId, sanitizeBranchName, syncCiSecrets, withProxyEnv };
@@ -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.46".length > 0) {
1634
- cached = "0.2.46";
1633
+ if ("0.2.48".length > 0) {
1634
+ cached = "0.2.48";
1635
1635
  return cached;
1636
1636
  }
1637
1637
  cached = "unknown";
@@ -8551,6 +8551,16 @@ async function setRepoSecrets(ownerRepo, secrets) {
8551
8551
  await setRepoSecret(ownerRepo, name, value);
8552
8552
  }
8553
8553
  }
8554
+ async function listSecretNames(ownerRepo) {
8555
+ try {
8556
+ const { owner, repo } = parseOwnerRepo(ownerRepo);
8557
+ const octokit = await getOctokit();
8558
+ const { data } = await octokit.rest.actions.listRepoSecrets({ owner, repo });
8559
+ return data.secrets.map((s) => s.name);
8560
+ } catch {
8561
+ return [];
8562
+ }
8563
+ }
8554
8564
 
8555
8565
  // scripts/git/remote.ts
8556
8566
  init_esm_shims();
@@ -8590,6 +8600,11 @@ async function getOwnerRepo(cwd) {
8590
8600
  }
8591
8601
 
8592
8602
  // scripts/util/ci-secrets.ts
8603
+ var REQUIRED_CI_SECRETS = ["DATABRICKS_HOST", "LAKEBASE_PROJECT_ID", "DATABRICKS_TOKEN"];
8604
+ async function missingCiSecrets(ownerRepo) {
8605
+ const present = new Set(await listSecretNames(ownerRepo));
8606
+ return REQUIRED_CI_SECRETS.filter((n) => !present.has(n));
8607
+ }
8593
8608
  async function syncCiSecrets(args) {
8594
8609
  const lifetime = args.lifetimeSeconds ?? 86400;
8595
8610
  const comment = args.comment ?? "GitHub Actions CI";
@@ -8727,12 +8742,14 @@ async function pollUntilDefined(probe, opts) {
8727
8742
  export {
8728
8743
  LAKEBASE_BRANCH_NAME_MAX,
8729
8744
  PROXY_ENV_KEYS,
8745
+ REQUIRED_CI_SECRETS,
8730
8746
  copyDirSubstituted,
8731
8747
  delay,
8732
8748
  exec2 as exec,
8733
8749
  extractZipToDir,
8734
8750
  formatOwnerRepo,
8735
8751
  isCliEntry,
8752
+ missingCiSecrets,
8736
8753
  parseOwnerRepo,
8737
8754
  patchPomForLakebase,
8738
8755
  pollUntil,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databricks-solutions/lakebase-scm-utils",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
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,
@@ -105,7 +105,8 @@
105
105
  "lakebase-scm-prepare-pr": "./dist/scripts/lakebase/scm-prepare-pr.cli.js",
106
106
  "lakebase-scm-recover-orphans": "./dist/scripts/lakebase/scm-recover-orphans.cli.js",
107
107
  "lakebase-scm-state": "./dist/scripts/lakebase/scm-state.cli.js",
108
- "lakebase-scm-wait-ci": "./dist/scripts/lakebase/scm-wait-ci.cli.js"
108
+ "lakebase-scm-wait-ci": "./dist/scripts/lakebase/scm-wait-ci.cli.js",
109
+ "lakebase-sync-ci-secrets": "./dist/scripts/lakebase/sync-ci-secrets.cli.js"
109
110
  },
110
111
  "scripts": {
111
112
  "build": "tsup",
@@ -67,6 +67,12 @@ export interface PullRequestInfo {
67
67
  isDraft: boolean;
68
68
  ciStatus: "pending" | "success" | "failure";
69
69
  checks: PullRequestCheck[];
70
+ /** GitHub's mergeability triple: true/false/null (null = still computing). */
71
+ mergeable?: boolean | null;
72
+ /** GitHub's `mergeable_state`: `clean`/`unstable`/`behind`/`blocked`/`dirty`/`unknown`.
73
+ * `dirty` = merge conflict with the base — a `pull_request` CI run can't be dispatched
74
+ * (there is no mergeable ref), so a waiter must surface this, not poll until timeout. */
75
+ mergeableState?: string;
70
76
  headBranch: string;
71
77
  baseBranch: string;
72
78
  body?: string;
@@ -184,6 +190,8 @@ export async function getPullRequest(
184
190
  isDraft: pr.draft || false,
185
191
  ciStatus,
186
192
  checks,
193
+ mergeable: pr.mergeable,
194
+ mergeableState: pr.mergeable_state,
187
195
  headBranch: pr.head?.ref || headBranch,
188
196
  baseBranch: pr.base?.ref || "",
189
197
  body: pr.body || undefined,
@@ -30,7 +30,7 @@ import { createLongRunningBranch } from "./long-running-branch.js";
30
30
  import { enableE2eForProject } from "./enable-e2e.js";
31
31
  import { enableInfraForProject } from "./enable-infra.js";
32
32
  import { setupRunner } from "./runner-setup.js";
33
- import { syncCiSecrets } from "../util/ci-secrets.js";
33
+ import { syncCiSecrets, missingCiSecrets } from "../util/ci-secrets.js";
34
34
  import { delay } from "../util/delay.js";
35
35
  import {
36
36
  initWorkflowState,
@@ -437,10 +437,21 @@ export async function createProject(
437
437
  lifetimeSeconds: 86_400,
438
438
  ownerRepo: fullRepoName,
439
439
  });
440
+ // VERIFY the secrets actually landed — syncCiSecrets fail-softs a dropped PAT, and a
441
+ // swallowed failure here only surfaces at the first promotion CI run (no DATABASE_URL),
442
+ // an entire feature later. Name the exact gaps + the one-command repair so it is actionable
443
+ // NOW, not a buried warning.
444
+ const missing = await missingCiSecrets(fullRepoName);
445
+ if (missing.length > 0) {
446
+ const m = `CI auth incomplete — repo secret(s) MISSING: ${missing.join(", ")}. CI cannot provision a per-PR Lakebase branch until fixed. Repair with: lakebase-sync-ci-secrets (run in the project).`;
447
+ warnings.push(m);
448
+ report(`WARNING: ${m}`);
449
+ }
440
450
  } catch (err) {
441
451
  const msg = err instanceof Error ? err.message : String(err);
442
- warnings.push(`CI auth setup failed: ${msg}`);
443
- report(`Warning: CI auth setup failed (${msg})`);
452
+ const m = `CI auth setup failed: ${msg}. CI will fail (no DATABASE_URL) until repaired — run: lakebase-sync-ci-secrets (in the project).`;
453
+ warnings.push(m);
454
+ report(`WARNING: ${m}`);
444
455
  }
445
456
  }
446
457
 
@@ -12,6 +12,7 @@
12
12
  // stable surface for that signal.
13
13
 
14
14
  import {
15
+ getPullRequest,
15
16
  listWorkflowRuns,
16
17
  mergePairedPullRequest,
17
18
  type WorkflowRunSummary,
@@ -68,6 +69,7 @@ export class ScmMergeError extends Error {
68
69
  | "no-pr-url"
69
70
  | "bad-pr-url"
70
71
  | "merge-failed"
72
+ | "promote-conflict"
71
73
  | "migrate-failed"
72
74
  | "migrate-timeout"
73
75
  | "migrate-auth",
@@ -166,6 +168,13 @@ export interface MergeArgs {
166
168
  * implementation ({@link defaultRefreshPromoteWorkflows}); injected in tests.
167
169
  */
168
170
  refreshWorkflows?: () => Promise<{ refreshed: boolean; detail?: string }>;
171
+ /** Pre-merge reconcile of a pipeline.json-ledger promotion conflict to the run side (keeps it
172
+ * tracked). Called ONLY when the PR is CONFLICTING. Defaults to {@link reconcilePromoteRunState};
173
+ * injected in tests. */
174
+ reconcileRunState?: (baseBranch: string) => Promise<{ reconciled: boolean; detail?: string }>;
175
+ /** Fetch the PR's mergeability (to gate the reconcile). Defaults to the real getPullRequest;
176
+ * injected in tests so the gate is exercised without the network. */
177
+ fetchPrState?: (ownerRepo: string, headBranch: string) => Promise<{ mergeableState?: string } | undefined>;
169
178
  }
170
179
 
171
180
  export interface MergeResult {
@@ -248,6 +257,71 @@ function shaMigratePredicate(
248
257
  * working-tree changes. Callers invoke it BEST-EFFORT — a failure here must never
249
258
  * block the merge (the local migrate fallback still keeps git + schema in sync).
250
259
  */
260
+ /** The ONLY paths a promotion merge may auto-resolve to the RUN (feature/head) side: the per-story
261
+ * pipeline.json ledger (per-feature, and the legacy top-level pointer). It is the feature branch's
262
+ * own durable bookkeeping — the tier has no authoritative copy — so on a promotion conflict the
263
+ * head side always wins. Everything else is real divergence and must surface, never auto-resolve. */
264
+ const PROMOTE_RUNSTATE_CONFLICT = /(^|\/)\.consort\/(features\/[^/]+\/)?pipeline\.json$/;
265
+
266
+ /**
267
+ * Clear a promotion-merge conflict that is ONLY the pipeline.json ledger, so GitHub can dispatch CI
268
+ * and merge — WITHOUT untracking the ledger (it's durable run-state that must persist). Merges the
269
+ * base into the PR head and resolves the ledger path(s) to the head (run) side, then pushes. This is
270
+ * the merge-layer fix for the "pipeline.json conflict blocks promotion" defect, replacing the wrong
271
+ * earlier approach of gitignoring/untracking it. Behavior:
272
+ * - a dirty tree under `.consort/` only is checkpointed first (the run's own bookkeeping);
273
+ * - a dirty tree OUTSIDE `.consort/` aborts (never auto-commit app code);
274
+ * - a conflict on any NON-ledger path aborts the merge + surfaces (real divergence isn't resolved);
275
+ * - a clean merge (no conflict) is a no-op (reconciled:false).
276
+ * Only call this when the PR is actually CONFLICTING — a clean promote needs no merge commit.
277
+ */
278
+ export async function reconcilePromoteRunState(
279
+ projectDir: string,
280
+ baseBranch: string,
281
+ ): Promise<{ reconciled: boolean; detail?: string }> {
282
+ const o = { cwd: projectDir, timeout: 30_000 };
283
+ // Working tree must be clean to merge. Checkpoint run-state (.consort/) if that's all that's dirty;
284
+ // refuse on anything else so we never auto-commit app code during a promotion.
285
+ const dirty = (await exec("git status --porcelain", o).catch(() => "")).split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
286
+ const nonRunStateDirty = dirty.filter((p) => !p.startsWith(".consort/"));
287
+ if (nonRunStateDirty.length > 0) {
288
+ throw new ScmMergeError(
289
+ `working tree has uncommitted non-run-state changes (${nonRunStateDirty.slice(0, 5).join(", ")}${nonRunStateDirty.length > 5 ? ", …" : ""}); commit or stash before promoting.`,
290
+ "promote-conflict",
291
+ );
292
+ }
293
+ if (dirty.length > 0) {
294
+ await exec(`git add ${shellEscape(".consort")}`, o);
295
+ await exec(`git commit -m ${shellEscape("chore(run): checkpoint run state before promotion")}`, o);
296
+ }
297
+ await exec(`git fetch origin ${shellEscape(baseBranch)}`, o).catch(() => {});
298
+ let conflicted = false;
299
+ try {
300
+ await exec(`git merge --no-ff -m ${shellEscape(`merge ${baseBranch} for promotion`)} ${shellEscape(`origin/${baseBranch}`)}`, o);
301
+ } catch {
302
+ conflicted = true;
303
+ }
304
+ if (!conflicted) return { reconciled: false }; // clean merge (or already up to date) — nothing to resolve
305
+ const u = (await exec("git diff --name-only --diff-filter=U", o).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
306
+ const other = u.filter((p) => !PROMOTE_RUNSTATE_CONFLICT.test(p));
307
+ if (u.length === 0 || other.length > 0) {
308
+ await exec("git merge --abort", o).catch(() => {});
309
+ throw new ScmMergeError(
310
+ other.length > 0
311
+ ? `promotion conflicts on non-ledger file(s): ${other.join(", ")}. Resolve the real divergence manually, then re-run the promote.`
312
+ : `promotion merge failed with no resolvable conflicts; resolve manually and re-run.`,
313
+ "promote-conflict",
314
+ );
315
+ }
316
+ for (const p of u) {
317
+ await exec(`git checkout --ours -- ${shellEscape(p)}`, o); // keep the run (feature) ledger
318
+ await exec(`git add -- ${shellEscape(p)}`, o);
319
+ }
320
+ await exec(`git commit -m ${shellEscape(`merge ${baseBranch} for promotion (pipeline.json ledger kept at run state)`)}`, o);
321
+ await pushCurrentBranchForPr({ cwd: projectDir });
322
+ return { reconciled: true, detail: `reconciled ${u.length} ledger path(s) to the run side + pushed` };
323
+ }
324
+
251
325
  export async function defaultRefreshPromoteWorkflows(
252
326
  projectDir: string,
253
327
  ): Promise<{ refreshed: boolean; detail?: string }> {
@@ -376,6 +450,23 @@ export async function mergeFeature(args: MergeArgs): Promise<MergeResult> {
376
450
  }
377
451
  }
378
452
 
453
+ // ─── Pre-merge pipeline.json-ledger reconcile ───
454
+ // The per-feature pipeline.json ledger is TRACKED (durable run-state) and can diverge between the
455
+ // feature branch and its tier, so the promotion PR lands CONFLICTING — GitHub won't dispatch CI or
456
+ // merge. When that happens, reconcile the ledger to the run (head) side at the MERGE layer and push,
457
+ // so the PR goes mergeable WITHOUT untracking the ledger. Only runs when the PR is actually dirty;
458
+ // a non-ledger conflict throws (real divergence surfaces). This is the correct replacement for the
459
+ // withdrawn gitignore/untrack approach.
460
+ {
461
+ const fetchState = args.fetchPrState ?? ((o, h) => getPullRequest(o, h));
462
+ const prState = await fetchState(ownerRepo, current.branch).catch(() => undefined);
463
+ if (prState?.mergeableState === "dirty") {
464
+ const reconcile = args.reconcileRunState ?? ((base: string) => reconcilePromoteRunState(args.projectDir, base));
465
+ const r = await reconcile(gitBase); // throws ScmMergeError on a non-ledger conflict (surfaces)
466
+ if (r.reconciled) preflightNotes.push(`Reconciled a pipeline.json-ledger promotion conflict to the run side${r.detail ? ` (${r.detail})` : ""}.`);
467
+ }
468
+ }
469
+
379
470
  let paired: Awaited<ReturnType<typeof mergePairedPullRequest>>;
380
471
  try {
381
472
  paired = await mergePairedPullRequest({
@@ -24,6 +24,7 @@ export class ScmWaitCiError extends Error {
24
24
  | "bad-precondition"
25
25
  | "no-github-remote"
26
26
  | "ci-failed"
27
+ | "pr-conflicting"
27
28
  | "timeout"
28
29
  | "pr-not-found",
29
30
  ) {
@@ -123,6 +124,18 @@ export async function waitForCi(args: WaitCiArgs): Promise<WaitCiResult> {
123
124
  if (lastPr.ciStatus === "success") {
124
125
  return { done: true, value: lastPr };
125
126
  }
127
+ // A CONFLICTING PR (merge conflict with base) has no mergeable ref, so GitHub never
128
+ // dispatches the `pull_request` workflow — CI stays at 0 runs forever. Detect it and
129
+ // surface at once (resolve + re-push re-triggers), rather than spinning to the timeout
130
+ // on a `pending` that can never advance. Guard on `mergeableState==="dirty"` AND no
131
+ // checks yet, so a transient null/unknown while GitHub computes mergeability doesn't
132
+ // false-trip, and a PR that already has runs is judged by ciStatus as before.
133
+ if (lastPr.mergeableState === "dirty" && lastPr.checks.length === 0) {
134
+ throw new ScmWaitCiError(
135
+ `PR ${lastPr.url} is CONFLICTING with ${lastPr.baseBranch} — GitHub will not dispatch CI (0 runs) until the conflict is resolved. Reconcile the branch and re-push; the synchronize event will trigger CI.`,
136
+ "pr-conflicting",
137
+ );
138
+ }
126
139
  if (lastPr.ciStatus === "failure") {
127
140
  const failed = lastPr.checks
128
141
  .filter((c) => /(FAILURE|TIMED_OUT|CANCELLED|ACTION_REQUIRED)/i.test(c.conclusion))
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // CLI: (re)provision the repo's CI auth secrets — the standalone repair path.
3
+ //
4
+ // create-project runs syncCiSecrets ONCE, fail-soft (a failed PAT mint or `gh secret set` is
5
+ // swallowed as a warning), and there was no way to retry without recreating the whole project.
6
+ // So a repo could sit with ZERO CI secrets until a promotion CI run failed an entire feature
7
+ // later (empty DATABRICKS_* → no DATABASE_URL → the test step aborts). This bin mints a fresh
8
+ // CI PAT and sets DATABRICKS_HOST / LAKEBASE_PROJECT_ID / DATABRICKS_TOKEN, then VERIFIES all
9
+ // three landed — exiting non-zero (naming the gaps) instead of leaving CI silently broken.
10
+
11
+ import * as path from "node:path";
12
+ import { isCliEntry } from "../util/cli-entry.js";
13
+ import { readEnvVar } from "./env-file.js";
14
+ import { getOwnerRepo } from "../git/remote.js";
15
+ import { syncCiSecrets, missingCiSecrets, REQUIRED_CI_SECRETS } from "../util/ci-secrets.js";
16
+
17
+ interface ParsedArgs {
18
+ projectDir?: string;
19
+ host?: string;
20
+ projectId?: string;
21
+ repo?: string;
22
+ lifetimeSec?: number;
23
+ comment?: string;
24
+ help?: boolean;
25
+ }
26
+
27
+ function parseArgs(argv: string[]): ParsedArgs {
28
+ const out: ParsedArgs = {};
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ switch (a) {
32
+ case "--project-dir":
33
+ case "--cwd": out.projectDir = argv[++i]; break;
34
+ case "--host": out.host = argv[++i]; break;
35
+ case "--project-id": out.projectId = argv[++i]; break;
36
+ case "--repo": out.repo = argv[++i]; break;
37
+ case "--lifetime-sec": out.lifetimeSec = Number.parseInt(argv[++i], 10); break;
38
+ case "--comment": out.comment = argv[++i]; break;
39
+ case "--help": case "-h": out.help = true; break;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const HELP = `lakebase-sync-ci-secrets
46
+
47
+ (Re)provision the GitHub repo's CI auth: mint a fresh Databricks CI PAT and set
48
+ DATABRICKS_HOST, LAKEBASE_PROJECT_ID, and DATABRICKS_TOKEN as repo Actions secrets,
49
+ then verify all three are present. The standalone repair for the create-time
50
+ "CI auth setup failed" warning — run it from the project, no recreate needed.
51
+
52
+ Usage:
53
+ lakebase-sync-ci-secrets [flags]
54
+
55
+ Flags:
56
+ --project-dir <dir> Project root (default: cwd); resolves host/project-id from its .env
57
+ --host <url> DATABRICKS_HOST (default: .env DATABRICKS_HOST)
58
+ --project-id <id> LAKEBASE_PROJECT_ID (default: .env LAKEBASE_PROJECT_ID)
59
+ --repo <owner/name> Target repo (default: origin remote)
60
+ --lifetime-sec <n> CI PAT lifetime (default: 86400 = 24h)
61
+ --comment <text> Token comment (default: "GitHub Actions CI")
62
+ -h, --help Show this help
63
+
64
+ Exit codes:
65
+ 0 = all three secrets present after sync
66
+ 2 = could not resolve host / project-id / repo
67
+ 3 = sync ran but a required secret is still missing (e.g. PAT mint failed)
68
+ `;
69
+
70
+ export async function runSyncCiSecretsCli(argv: string[]): Promise<number> {
71
+ const args = parseArgs(argv);
72
+ if (args.help) {
73
+ process.stdout.write(`${HELP}\n`);
74
+ return 0;
75
+ }
76
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
77
+ const envPath = path.join(projectDir, ".env");
78
+ const host = args.host ?? readEnvVar(envPath, "DATABRICKS_HOST");
79
+ const projectId = args.projectId ?? readEnvVar(envPath, "LAKEBASE_PROJECT_ID");
80
+ const ownerRepo = args.repo ?? (await getOwnerRepo(projectDir));
81
+
82
+ if (!ownerRepo) {
83
+ process.stderr.write("lakebase-sync-ci-secrets: no GitHub repo (pass --repo or run inside a repo with an origin remote).\n");
84
+ return 2;
85
+ }
86
+ if (!host || !projectId) {
87
+ process.stderr.write(
88
+ `lakebase-sync-ci-secrets: missing ${!host ? "DATABRICKS_HOST" : ""}${!host && !projectId ? " and " : ""}${!projectId ? "LAKEBASE_PROJECT_ID" : ""}` +
89
+ ` — not in ${envPath}; pass --host / --project-id.\n`,
90
+ );
91
+ return 2;
92
+ }
93
+
94
+ process.stdout.write(`Provisioning CI auth for ${ownerRepo} (host ${host}, project ${projectId})…\n`);
95
+ await syncCiSecrets({
96
+ projectDir,
97
+ databricksHost: host,
98
+ lakebaseProjectId: projectId,
99
+ ownerRepo,
100
+ ...(args.comment ? { comment: args.comment } : {}),
101
+ ...(args.lifetimeSec ? { lifetimeSeconds: args.lifetimeSec } : {}),
102
+ });
103
+
104
+ // VERIFY — the whole point of the repair. A silently-dropped PAT (the create-time failure
105
+ // mode) leaves DATABRICKS_TOKEN absent; catch it here and exit non-zero rather than report success.
106
+ const missing = await missingCiSecrets(ownerRepo);
107
+ if (missing.length > 0) {
108
+ process.stderr.write(
109
+ `lakebase-sync-ci-secrets: sync ran but these secrets are still MISSING on ${ownerRepo}: ${missing.join(", ")}.\n` +
110
+ (missing.includes("DATABRICKS_TOKEN")
111
+ ? ` DATABRICKS_TOKEN missing usually means 'databricks tokens create' failed — check your auth for ${host} (e.g. 'databricks auth login --host ${host}'), then re-run.\n`
112
+ : ""),
113
+ );
114
+ return 3;
115
+ }
116
+ process.stdout.write(`CI auth ready: ${REQUIRED_CI_SECRETS.join(", ")} set on ${ownerRepo}.\n`);
117
+ return 0;
118
+ }
119
+
120
+ if (isCliEntry(import.meta.url)) {
121
+ void runSyncCiSecretsCli(process.argv.slice(2)).then((c) => process.exit(c));
122
+ }
@@ -7,9 +7,22 @@
7
7
  // be set), the values have to come from the create-project caller's scope.
8
8
 
9
9
  import { runDatabricks } from "../lakebase/databricks-cli.js";
10
- import { setRepoSecrets } from "../github/secrets.js";
10
+ import { setRepoSecrets, listSecretNames } from "../github/secrets.js";
11
11
  import { getOwnerRepo } from "../git/remote.js";
12
12
 
13
+ /** The repo Actions secrets CI needs to provision a per-PR Lakebase branch. Missing ANY of
14
+ * these leaves CI unable to produce a DATABASE_URL, so the test step aborts — the failure
15
+ * that otherwise only surfaces at promotion, an entire feature after creation. */
16
+ export const REQUIRED_CI_SECRETS = ["DATABRICKS_HOST", "LAKEBASE_PROJECT_ID", "DATABRICKS_TOKEN"] as const;
17
+
18
+ /** Which required CI secrets are NOT present on the repo (empty = fully provisioned). Used to
19
+ * VERIFY a sync landed — a `databricks tokens create` or `gh secret set` that fails must be
20
+ * caught here, not swallowed, so CI-auth is never silently absent. */
21
+ export async function missingCiSecrets(ownerRepo: string): Promise<string[]> {
22
+ const present = new Set(await listSecretNames(ownerRepo));
23
+ return REQUIRED_CI_SECRETS.filter((n) => !present.has(n));
24
+ }
25
+
13
26
  export interface SyncCiSecretsArgs {
14
27
  /** Project root (used to resolve ownerRepo from `git remote` when not given,
15
28
  * and as the cwd for the `databricks tokens create` call). */
@@ -30,6 +30,10 @@ application-local.properties
30
30
  .consort/cycles/
31
31
  .consort/experiments/
32
32
  .consort/pipeline.json
33
+ # NOTE: the PER-FEATURE ledger .consort/features/<id>/pipeline.json is deliberately NOT ignored —
34
+ # it is the durable per-story pipeline state (persisted on the feature branch so it survives across
35
+ # sessions). Ignoring it lost the ledger. A promotion-merge conflict on it is resolved at the MERGE
36
+ # layer (scm-merge reconciles it to the run side), never by untracking it.
33
37
  # The turn recorder's per-run observability: its internal file-hash map
34
38
  # (.recorder-state.json, used for delta computation), the routing-decision
35
39
  # diagnostic stream (routing-decisions.jsonl), and the per-turn record dirs
@@ -297,6 +297,14 @@ if [ "$first" = "--warm" ] || [ "$first" = "--rewarm" ]; then
297
297
  fi
298
298
 
299
299
  bin="$first"; shift
300
+ # A help request (`-h`/`--help`) must NEVER trigger a one-time cold-cache install:
301
+ # printing usage should have no side effects. Treat it like LK_NO_INSTALL so a cold
302
+ # cache SHORT-CIRCUITS with a clear "warm first" note instead of silently kicking off a
303
+ # multi-minute download (the "my --help refreshed the cache?!" surprise). When the cache
304
+ # is already warm, help still prints instantly, exactly as before.
305
+ help_req=0
306
+ for _a in "$@"; do case "$_a" in -h|--help) help_req=1; break ;; esac; done
307
+ [ "$help_req" -eq 1 ] && export LK_NO_INSTALL=1
300
308
  # Deprecation: lakebase-sftdd-* / lakebase-tdd-* are legacy aliases from the
301
309
  # pre-Consort skill names. They still run (routed to the kit below), but are
302
310
  # scheduled for removal in consort v0.4.0 , prefer the consort-* / lakebase-*
@@ -306,8 +314,15 @@ case "$bin" in
306
314
  echo "lk: [deprecated] '${bin}' is a legacy sftdd/tdd-era alias; use the consort-* / lakebase-* name (removed in consort v0.4.0). Still running." >&2 ;;
307
315
  esac
308
316
  slug="$(pkg_for_bin "$bin")"
309
- resolve_pkg "$slug" run; rc=$?
310
- [ "$rc" -eq 97 ] && exit 97
317
+ # Capture the status with `|| rc=$?` so `set -e` does NOT abort on resolve_pkg's
318
+ # non-zero returns (notably 97 = cold cache under LK_NO_INSTALL / a help request) —
319
+ # a bare `resolve_pkg …; rc=$?` exits before rc is ever read.
320
+ rc=0; resolve_pkg "$slug" run || rc=$?
321
+ if [ "$rc" -eq 97 ]; then
322
+ # Cold cache + a help request: do NOT install just to print usage.
323
+ [ "$help_req" -eq 1 ] && { echo "lk: not downloading the toolkit just to show '--help'. The ${slug} cache for this ref is cold; run 'lk --warm' to install it (one-time), then retry '${bin} --help'. A help request never triggers a download." >&2; exit 0; }
324
+ exit 97
325
+ fi
311
326
  [ "$rc" -ne 0 ] && exit "$rc"
312
327
  rel="$(bin_rel "$pkg_dir" "$bin")"
313
328
 
@@ -317,8 +332,11 @@ rel="$(bin_rel "$pkg_dir" "$bin")"
317
332
  # route to the kit up front and never reach here, so an unknown kit bin still fails
318
333
  # fast with no substrate round-trip.
319
334
  if [ -z "$rel" ] && [ "$slug" = "scm-utils" ] && project_uses_kit; then
320
- resolve_pkg kit run; krc=$?
321
- [ "$krc" -eq 97 ] && exit 97
335
+ krc=0; resolve_pkg kit run || krc=$?
336
+ if [ "$krc" -eq 97 ]; then
337
+ [ "$help_req" -eq 1 ] && { echo "lk: not downloading the toolkit just to show '--help'. The kit cache for this ref is cold; run 'lk --warm' to install it (one-time), then retry '${bin} --help'. A help request never triggers a download." >&2; exit 0; }
338
+ exit 97
339
+ fi
322
340
  if [ "$krc" -eq 0 ]; then
323
341
  rel="$(bin_rel "$pkg_dir" "$bin")"
324
342
  [ -n "$rel" ] && slug="kit"