@databricks-solutions/lakebase-scm-utils 0.2.47 → 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.
- package/dist/scripts/index.cjs +55 -2
- package/dist/scripts/index.d.cts +1 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.js +54 -2
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
- package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
- package/dist/scripts/lakebase/detect-language.cli.js +2 -2
- package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
- package/dist/scripts/lakebase/doctor.cli.js +2 -2
- package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
- package/dist/scripts/lakebase/get-connection.cli.js +2 -2
- package/dist/scripts/lakebase/index.cjs +55 -2
- package/dist/scripts/lakebase/index.d.cts +29 -11
- package/dist/scripts/lakebase/index.d.ts +29 -11
- package/dist/scripts/lakebase/index.js +54 -2
- package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
- package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
- package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
- package/dist/scripts/lakebase/new-migration.cli.js +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
- package/dist/scripts/lakebase/scm-merge.cli.cjs +131 -2
- package/dist/scripts/lakebase/scm-merge.cli.js +131 -2
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
- package/dist/scripts/util/index.cjs +2 -2
- package/dist/scripts/util/index.js +2 -2
- package/package.json +1 -1
- package/scripts/lakebase/scm-merge.ts +91 -0
- package/templates/project/common/.gitignore.base +4 -6
|
@@ -8609,6 +8609,84 @@ function wrap(err, context) {
|
|
|
8609
8609
|
}
|
|
8610
8610
|
throw new GitHubPullRequestError(context);
|
|
8611
8611
|
}
|
|
8612
|
+
async function getPullRequest(ownerRepo, headBranch) {
|
|
8613
|
+
try {
|
|
8614
|
+
const { owner, repo } = parseOwnerRepo(ownerRepo);
|
|
8615
|
+
const ok = await octokit();
|
|
8616
|
+
const { data: pulls } = await ok.rest.pulls.list({
|
|
8617
|
+
owner,
|
|
8618
|
+
repo,
|
|
8619
|
+
state: "open",
|
|
8620
|
+
head: `${owner}:${headBranch}`,
|
|
8621
|
+
per_page: 1
|
|
8622
|
+
});
|
|
8623
|
+
if (pulls.length === 0) return void 0;
|
|
8624
|
+
const { data: pr } = await ok.rest.pulls.get({
|
|
8625
|
+
owner,
|
|
8626
|
+
repo,
|
|
8627
|
+
pull_number: pulls[0].number
|
|
8628
|
+
});
|
|
8629
|
+
if (pr.state !== "open") return void 0;
|
|
8630
|
+
let checks = [];
|
|
8631
|
+
let ciStatus = "pending";
|
|
8632
|
+
const headSha = pr.head?.sha;
|
|
8633
|
+
if (headSha) {
|
|
8634
|
+
try {
|
|
8635
|
+
const { data: checksData } = await ok.rest.checks.listForRef({
|
|
8636
|
+
owner,
|
|
8637
|
+
repo,
|
|
8638
|
+
ref: headSha
|
|
8639
|
+
});
|
|
8640
|
+
const runs = checksData.check_runs || [];
|
|
8641
|
+
checks = runs.map((c) => ({
|
|
8642
|
+
name: c.name || "unknown",
|
|
8643
|
+
status: (c.status || "").toUpperCase(),
|
|
8644
|
+
conclusion: (c.conclusion || "").toUpperCase(),
|
|
8645
|
+
detailsUrl: c.details_url || void 0
|
|
8646
|
+
}));
|
|
8647
|
+
ciStatus = parseCiStatus(runs);
|
|
8648
|
+
} catch {
|
|
8649
|
+
ciStatus = "pending";
|
|
8650
|
+
}
|
|
8651
|
+
}
|
|
8652
|
+
return {
|
|
8653
|
+
number: pr.number,
|
|
8654
|
+
title: pr.title,
|
|
8655
|
+
url: pr.html_url || "",
|
|
8656
|
+
state: (pr.state || "open").toUpperCase(),
|
|
8657
|
+
isDraft: pr.draft || false,
|
|
8658
|
+
ciStatus,
|
|
8659
|
+
checks,
|
|
8660
|
+
mergeable: pr.mergeable,
|
|
8661
|
+
mergeableState: pr.mergeable_state,
|
|
8662
|
+
headBranch: pr.head?.ref || headBranch,
|
|
8663
|
+
baseBranch: pr.base?.ref || "",
|
|
8664
|
+
body: pr.body || void 0,
|
|
8665
|
+
additions: pr.additions,
|
|
8666
|
+
deletions: pr.deletions,
|
|
8667
|
+
changedFiles: pr.changed_files
|
|
8668
|
+
};
|
|
8669
|
+
} catch {
|
|
8670
|
+
return void 0;
|
|
8671
|
+
}
|
|
8672
|
+
}
|
|
8673
|
+
function parseCiStatus(rawChecks) {
|
|
8674
|
+
if (rawChecks.length === 0) return "pending";
|
|
8675
|
+
const latestByName = /* @__PURE__ */ new Map();
|
|
8676
|
+
for (const c of rawChecks) {
|
|
8677
|
+
latestByName.set(c.name || "unknown", c);
|
|
8678
|
+
}
|
|
8679
|
+
const states = Array.from(latestByName.values()).map(
|
|
8680
|
+
(c) => (c.conclusion || c.status || "").toUpperCase()
|
|
8681
|
+
);
|
|
8682
|
+
if (states.some((s) => s === "FAILURE" || s === "ERROR" || s === "ACTION_REQUIRED")) {
|
|
8683
|
+
return "failure";
|
|
8684
|
+
}
|
|
8685
|
+
if (states.every((s) => s === "SUCCESS" || s === "NEUTRAL" || s === "SKIPPED")) {
|
|
8686
|
+
return "success";
|
|
8687
|
+
}
|
|
8688
|
+
return "pending";
|
|
8689
|
+
}
|
|
8612
8690
|
async function mergePullRequest(args) {
|
|
8613
8691
|
const method = args.method ?? "merge";
|
|
8614
8692
|
const deleteRemoteBranch = args.deleteRemoteBranch !== false;
|
|
@@ -9303,6 +9381,48 @@ function shaMigratePredicate(mergeCommitSha) {
|
|
|
9303
9381
|
return !!run.headSha && run.headSha === mergeCommitSha;
|
|
9304
9382
|
};
|
|
9305
9383
|
}
|
|
9384
|
+
var PROMOTE_RUNSTATE_CONFLICT = /(^|\/)\.consort\/(features\/[^/]+\/)?pipeline\.json$/;
|
|
9385
|
+
async function reconcilePromoteRunState(projectDir, baseBranch) {
|
|
9386
|
+
const o = { cwd: projectDir, timeout: 3e4 };
|
|
9387
|
+
const dirty = (await exec2("git status --porcelain", o).catch(() => "")).split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
|
|
9388
|
+
const nonRunStateDirty = dirty.filter((p) => !p.startsWith(".consort/"));
|
|
9389
|
+
if (nonRunStateDirty.length > 0) {
|
|
9390
|
+
throw new ScmMergeError(
|
|
9391
|
+
`working tree has uncommitted non-run-state changes (${nonRunStateDirty.slice(0, 5).join(", ")}${nonRunStateDirty.length > 5 ? ", \u2026" : ""}); commit or stash before promoting.`,
|
|
9392
|
+
"promote-conflict"
|
|
9393
|
+
);
|
|
9394
|
+
}
|
|
9395
|
+
if (dirty.length > 0) {
|
|
9396
|
+
await exec2(`git add ${shellEscape(".consort")}`, o);
|
|
9397
|
+
await exec2(`git commit -m ${shellEscape("chore(run): checkpoint run state before promotion")}`, o);
|
|
9398
|
+
}
|
|
9399
|
+
await exec2(`git fetch origin ${shellEscape(baseBranch)}`, o).catch(() => {
|
|
9400
|
+
});
|
|
9401
|
+
let conflicted = false;
|
|
9402
|
+
try {
|
|
9403
|
+
await exec2(`git merge --no-ff -m ${shellEscape(`merge ${baseBranch} for promotion`)} ${shellEscape(`origin/${baseBranch}`)}`, o);
|
|
9404
|
+
} catch {
|
|
9405
|
+
conflicted = true;
|
|
9406
|
+
}
|
|
9407
|
+
if (!conflicted) return { reconciled: false };
|
|
9408
|
+
const u = (await exec2("git diff --name-only --diff-filter=U", o).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
9409
|
+
const other = u.filter((p) => !PROMOTE_RUNSTATE_CONFLICT.test(p));
|
|
9410
|
+
if (u.length === 0 || other.length > 0) {
|
|
9411
|
+
await exec2("git merge --abort", o).catch(() => {
|
|
9412
|
+
});
|
|
9413
|
+
throw new ScmMergeError(
|
|
9414
|
+
other.length > 0 ? `promotion conflicts on non-ledger file(s): ${other.join(", ")}. Resolve the real divergence manually, then re-run the promote.` : `promotion merge failed with no resolvable conflicts; resolve manually and re-run.`,
|
|
9415
|
+
"promote-conflict"
|
|
9416
|
+
);
|
|
9417
|
+
}
|
|
9418
|
+
for (const p of u) {
|
|
9419
|
+
await exec2(`git checkout --ours -- ${shellEscape(p)}`, o);
|
|
9420
|
+
await exec2(`git add -- ${shellEscape(p)}`, o);
|
|
9421
|
+
}
|
|
9422
|
+
await exec2(`git commit -m ${shellEscape(`merge ${baseBranch} for promotion (pipeline.json ledger kept at run state)`)}`, o);
|
|
9423
|
+
await pushCurrentBranchForPr({ cwd: projectDir });
|
|
9424
|
+
return { reconciled: true, detail: `reconciled ${u.length} ledger path(s) to the run side + pushed` };
|
|
9425
|
+
}
|
|
9306
9426
|
async function defaultRefreshPromoteWorkflows(projectDir) {
|
|
9307
9427
|
const drift = detectWorkflowDrift({ projectDir });
|
|
9308
9428
|
const touched = drift.files.filter((f) => f.status === "drifted").map((f) => f.name);
|
|
@@ -9392,6 +9512,15 @@ async function mergeFeature(args) {
|
|
|
9392
9512
|
);
|
|
9393
9513
|
}
|
|
9394
9514
|
}
|
|
9515
|
+
{
|
|
9516
|
+
const fetchState = args.fetchPrState ?? ((o, h) => getPullRequest(o, h));
|
|
9517
|
+
const prState = await fetchState(ownerRepo, current.branch).catch(() => void 0);
|
|
9518
|
+
if (prState?.mergeableState === "dirty") {
|
|
9519
|
+
const reconcile = args.reconcileRunState ?? ((base) => reconcilePromoteRunState(args.projectDir, base));
|
|
9520
|
+
const r = await reconcile(gitBase);
|
|
9521
|
+
if (r.reconciled) preflightNotes.push(`Reconciled a pipeline.json-ledger promotion conflict to the run side${r.detail ? ` (${r.detail})` : ""}.`);
|
|
9522
|
+
}
|
|
9523
|
+
}
|
|
9395
9524
|
let paired;
|
|
9396
9525
|
try {
|
|
9397
9526
|
paired = await mergePairedPullRequest({
|
|
@@ -9627,8 +9756,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
9627
9756
|
var cached;
|
|
9628
9757
|
function substrateSelfVersion() {
|
|
9629
9758
|
if (cached !== void 0) return cached;
|
|
9630
|
-
if ("0.2.
|
|
9631
|
-
cached = "0.2.
|
|
9759
|
+
if ("0.2.48".length > 0) {
|
|
9760
|
+
cached = "0.2.48";
|
|
9632
9761
|
return cached;
|
|
9633
9762
|
}
|
|
9634
9763
|
cached = "unknown";
|
|
@@ -79288,8 +79288,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
79288
79288
|
var cached;
|
|
79289
79289
|
function substrateSelfVersion() {
|
|
79290
79290
|
if (cached !== void 0) return cached;
|
|
79291
|
-
if ("0.2.
|
|
79292
|
-
cached = "0.2.
|
|
79291
|
+
if ("0.2.48".length > 0) {
|
|
79292
|
+
cached = "0.2.48";
|
|
79293
79293
|
return cached;
|
|
79294
79294
|
}
|
|
79295
79295
|
cached = "unknown";
|
|
@@ -389,8 +389,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
389
389
|
var cached;
|
|
390
390
|
function substrateSelfVersion() {
|
|
391
391
|
if (cached !== void 0) return cached;
|
|
392
|
-
if ("0.2.
|
|
393
|
-
cached = "0.2.
|
|
392
|
+
if ("0.2.48".length > 0) {
|
|
393
|
+
cached = "0.2.48";
|
|
394
394
|
return cached;
|
|
395
395
|
}
|
|
396
396
|
cached = "unknown";
|
|
@@ -1661,8 +1661,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
|
|
|
1661
1661
|
var cached;
|
|
1662
1662
|
function substrateSelfVersion() {
|
|
1663
1663
|
if (cached !== void 0) return cached;
|
|
1664
|
-
if ("0.2.
|
|
1665
|
-
cached = "0.2.
|
|
1664
|
+
if ("0.2.48".length > 0) {
|
|
1665
|
+
cached = "0.2.48";
|
|
1666
1666
|
return cached;
|
|
1667
1667
|
}
|
|
1668
1668
|
cached = "unknown";
|
|
@@ -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.
|
|
1634
|
-
cached = "0.2.
|
|
1633
|
+
if ("0.2.48".length > 0) {
|
|
1634
|
+
cached = "0.2.48";
|
|
1635
1635
|
return cached;
|
|
1636
1636
|
}
|
|
1637
1637
|
cached = "unknown";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@databricks-solutions/lakebase-scm-utils",
|
|
3
|
-
"version": "0.2.
|
|
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,
|
|
@@ -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({
|
|
@@ -29,13 +29,11 @@ application-local.properties
|
|
|
29
29
|
# it on the feature branch at each cut so the spec history survives for later stories).
|
|
30
30
|
.consort/cycles/
|
|
31
31
|
.consort/experiments/
|
|
32
|
-
# The live build-pipeline pointer. It lives PER-FEATURE (.consort/features/<id>/pipeline.json);
|
|
33
|
-
# the bare `.consort/pipeline.json` is kept for older layouts. Matching only the top-level path
|
|
34
|
-
# left the per-feature file TRACKED — so it travelled onto staging/main and collided at the
|
|
35
|
-
# promotion merge (two branches editing one run's gate-bookkeeping), the exact "pipeline.json
|
|
36
|
-
# conflict blocks promotion" defect. Both forms are run-state, never the design corpus.
|
|
37
32
|
.consort/pipeline.json
|
|
38
|
-
.consort/features
|
|
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.
|
|
39
37
|
# The turn recorder's per-run observability: its internal file-hash map
|
|
40
38
|
# (.recorder-state.json, used for delta computation), the routing-decision
|
|
41
39
|
# diagnostic stream (routing-decisions.jsonl), and the per-turn record dirs
|