@treeseed/sdk 0.6.22 → 0.6.24

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.
@@ -1,6 +1,74 @@
1
1
  export declare const STAGING_BRANCH = "staging";
2
2
  export declare const PRODUCTION_BRANCH = "main";
3
3
  export declare function headCommit(repoDir: any, ref?: string): string;
4
+ export declare function inspectDetachedHeadRepair(repoDir: any, expectedBranches?: string[]): {
5
+ repoDir: any;
6
+ branchName: string;
7
+ detached: boolean;
8
+ dirty: boolean;
9
+ headSha: string | null;
10
+ targetBranch: string;
11
+ targetSha: string | null;
12
+ repairable: boolean;
13
+ repaired: boolean;
14
+ blocker: null;
15
+ } | {
16
+ repoDir: any;
17
+ branchName: null;
18
+ detached: boolean;
19
+ dirty: boolean;
20
+ headSha: string;
21
+ targetBranch: string;
22
+ targetSha: string;
23
+ repairable: boolean;
24
+ repaired: boolean;
25
+ blocker: null;
26
+ } | {
27
+ repoDir: any;
28
+ branchName: null;
29
+ detached: boolean;
30
+ dirty: boolean;
31
+ headSha: string | null;
32
+ targetBranch: null;
33
+ targetSha: null;
34
+ repairable: boolean;
35
+ repaired: boolean;
36
+ blocker: string;
37
+ };
38
+ export declare function reattachDetachedHeadIfSafe(repoDir: any, expectedBranches?: string[]): {
39
+ repoDir: any;
40
+ branchName: string;
41
+ detached: boolean;
42
+ dirty: boolean;
43
+ headSha: string | null;
44
+ targetBranch: string;
45
+ targetSha: string | null;
46
+ repairable: boolean;
47
+ repaired: boolean;
48
+ blocker: null;
49
+ } | {
50
+ repoDir: any;
51
+ branchName: null;
52
+ detached: boolean;
53
+ dirty: boolean;
54
+ headSha: string;
55
+ targetBranch: string;
56
+ targetSha: string;
57
+ repairable: boolean;
58
+ repaired: boolean;
59
+ blocker: null;
60
+ } | {
61
+ repoDir: any;
62
+ branchName: null;
63
+ detached: boolean;
64
+ dirty: boolean;
65
+ headSha: string | null;
66
+ targetBranch: null;
67
+ targetSha: null;
68
+ repairable: boolean;
69
+ repaired: boolean;
70
+ blocker: string;
71
+ };
4
72
  export declare function gitWorkflowRoot(cwd?: any): string;
5
73
  export declare function assertCleanWorktree(cwd?: any): string;
6
74
  export declare function assertCleanWorktrees(repoDirs: any): any;
@@ -40,6 +40,75 @@ function resolveGeneratedPackageMetadataConflicts(repoDir) {
40
40
  function headCommit(repoDir, ref = "HEAD") {
41
41
  return runGit(["rev-parse", ref], { cwd: repoDir, capture: true }).trim();
42
42
  }
43
+ function maybeHeadCommit(repoDir, ref = "HEAD") {
44
+ try {
45
+ return headCommit(repoDir, ref);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function inspectDetachedHeadRepair(repoDir, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH]) {
51
+ const branchName = currentBranch(repoDir) || null;
52
+ const headSha = maybeHeadCommit(repoDir);
53
+ const dirty = gitStatusPorcelain(repoDir).length > 0;
54
+ if (branchName) {
55
+ return {
56
+ repoDir,
57
+ branchName,
58
+ detached: false,
59
+ dirty,
60
+ headSha,
61
+ targetBranch: branchName,
62
+ targetSha: headSha,
63
+ repairable: false,
64
+ repaired: false,
65
+ blocker: null
66
+ };
67
+ }
68
+ for (const branch of expectedBranches) {
69
+ const branchSha = branchExists(repoDir, branch) ? maybeHeadCommit(repoDir, branch) : null;
70
+ if (headSha && branchSha && headSha === branchSha) {
71
+ return {
72
+ repoDir,
73
+ branchName: null,
74
+ detached: true,
75
+ dirty,
76
+ headSha,
77
+ targetBranch: branch,
78
+ targetSha: branchSha,
79
+ repairable: true,
80
+ repaired: false,
81
+ blocker: null
82
+ };
83
+ }
84
+ }
85
+ const expected = expectedBranches.join(" or ");
86
+ return {
87
+ repoDir,
88
+ branchName: null,
89
+ detached: true,
90
+ dirty,
91
+ headSha,
92
+ targetBranch: null,
93
+ targetSha: null,
94
+ repairable: false,
95
+ repaired: false,
96
+ blocker: `Detached HEAD ${headSha ?? "(unknown)"} does not match ${expected}; review manually before continuing.`
97
+ };
98
+ }
99
+ function reattachDetachedHeadIfSafe(repoDir, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH]) {
100
+ const inspection = inspectDetachedHeadRepair(repoDir, expectedBranches);
101
+ if (!inspection.detached || !inspection.repairable || !inspection.targetBranch) {
102
+ return inspection;
103
+ }
104
+ runGit(["switch", inspection.targetBranch], { cwd: repoDir });
105
+ return {
106
+ ...inspection,
107
+ branchName: inspection.targetBranch,
108
+ detached: false,
109
+ repaired: true
110
+ };
111
+ }
43
112
  function gitWorkflowRoot(cwd = workspaceRoot()) {
44
113
  return repoRoot(cwd);
45
114
  }
@@ -394,6 +463,7 @@ export {
394
463
  fetchOrigin,
395
464
  gitWorkflowRoot,
396
465
  headCommit,
466
+ inspectDetachedHeadRepair,
397
467
  isTaskBranch,
398
468
  listTaskBranches,
399
469
  mergeBranchIntoTarget,
@@ -402,6 +472,7 @@ export {
402
472
  prepareReleaseBranches,
403
473
  pushBranch,
404
474
  pushHeadToBranch,
475
+ reattachDetachedHeadIfSafe,
405
476
  remoteBranchExists,
406
477
  squashMergeBranchIntoStaging,
407
478
  syncBranchWithOrigin,
@@ -119,5 +119,7 @@ export declare function formatGitHubActionsGateFailure(gate: GitHubActionsWorkfl
119
119
  export declare function waitForGitHubActionsGate(gate: GitHubActionsWorkflowGate, options?: {
120
120
  timeoutSeconds?: number;
121
121
  pollSeconds?: number;
122
+ operation?: string;
123
+ onProgress?: (message: string, stream?: 'stdout' | 'stderr') => void;
122
124
  }): Promise<Record<string, unknown>>;
123
125
  export {};
@@ -421,6 +421,31 @@ Failed jobs: ${failedJobs.join(", ")}` : "";
421
421
  Inspect with: gh run view ${runId} --repo ${repository} --log-failed` : "";
422
422
  return `${gate.name} ${gate.workflow} completed with conclusion ${String(result.conclusion ?? "unknown")} in ${repository}.${url}${jobLine}${command}`;
423
423
  }
424
+ function formatElapsed(seconds) {
425
+ const safe = Math.max(0, Math.round(seconds));
426
+ if (safe < 60) return `${safe}s`;
427
+ const minutes = Math.floor(safe / 60);
428
+ const remainder = safe % 60;
429
+ return remainder === 0 ? `${minutes}m` : `${minutes}m${remainder}s`;
430
+ }
431
+ function shortSha(value) {
432
+ return value ? value.slice(0, 12) : "(unknown)";
433
+ }
434
+ function formatGitHubActionsGateProgress(gate, event, operation) {
435
+ const prefix = `[${operation}][gate][${gate.name}] ${event.workflow}`;
436
+ if (event.type === "waiting") {
437
+ return `${prefix} on ${event.branch ?? gate.branch}: waiting for run for ${shortSha(event.headSha ?? gate.headSha)} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
438
+ }
439
+ if (event.type === "completed") {
440
+ const conclusion = event.conclusion === "success" ? "successfully" : `with conclusion ${event.conclusion ?? "unknown"}`;
441
+ const url2 = event.url ? `: ${event.url}` : "";
442
+ return `${prefix} completed ${conclusion} in ${formatElapsed(event.elapsedSeconds)}${url2}`;
443
+ }
444
+ const status = event.status ?? "waiting";
445
+ const url = event.url ? `: ${event.url}` : "";
446
+ const run = event.runId ? ` run ${event.runId}` : "";
447
+ return `${prefix}${run} ${status}${url} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
448
+ }
424
449
  async function waitForGitHubActionsGate(gate, options = {}) {
425
450
  const { waitForGitHubWorkflowCompletion } = await import("./github-automation.js");
426
451
  return await waitForGitHubWorkflowCompletion(gate.repoPath, {
@@ -429,7 +454,10 @@ async function waitForGitHubActionsGate(gate, options = {}) {
429
454
  headSha: gate.headSha,
430
455
  branch: gate.branch,
431
456
  timeoutSeconds: options.timeoutSeconds,
432
- pollSeconds: options.pollSeconds
457
+ pollSeconds: options.pollSeconds,
458
+ onProgress: (event) => {
459
+ options.onProgress?.(formatGitHubActionsGateProgress(gate, event, options.operation ?? "workflow"));
460
+ }
433
461
  });
434
462
  }
435
463
  export {
@@ -34,6 +34,18 @@ export interface GitHubWorkflowJobSummary {
34
34
  conclusion: string | null;
35
35
  url: string | null;
36
36
  }
37
+ export type GitHubWorkflowProgressEvent = {
38
+ type: 'waiting' | 'running' | 'completed';
39
+ repository: string;
40
+ workflow: string;
41
+ branch: string | null;
42
+ headSha: string | null;
43
+ elapsedSeconds: number;
44
+ runId: number | null;
45
+ url: string | null;
46
+ status: string | null;
47
+ conclusion: string | null;
48
+ };
37
49
  export declare function resolveGitHubApiToken(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): string;
38
50
  export declare function parseGitHubRepositorySlug(value: string): {
39
51
  owner: string;
@@ -125,13 +137,14 @@ export declare function upsertGitHubRepositoryVariableWithGhCli(repository: stri
125
137
  export declare function waitForGitHubWorkflowRunCompletion(repository: string | {
126
138
  owner: string;
127
139
  name: string;
128
- }, { client, workflow, headSha, branch, timeoutSeconds, pollSeconds, }?: {
140
+ }, { client, workflow, headSha, branch, timeoutSeconds, pollSeconds, onProgress, }?: {
129
141
  client?: GitHubApiClient;
130
142
  workflow?: string;
131
143
  headSha?: string | null;
132
144
  branch?: string | null;
133
145
  timeoutSeconds?: number;
134
146
  pollSeconds?: number;
147
+ onProgress?: (event: GitHubWorkflowProgressEvent) => void;
135
148
  }): Promise<{
136
149
  status: string;
137
150
  repository: string;
@@ -541,10 +541,28 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
541
541
  headSha,
542
542
  branch,
543
543
  timeoutSeconds = 600,
544
- pollSeconds = 5
544
+ pollSeconds = 5,
545
+ onProgress
545
546
  } = {}) {
546
547
  const { owner, name } = typeof repository === "string" ? parseGitHubRepositorySlug(repository) : repository;
547
548
  const startedAt = Date.now();
549
+ let lastProgress = null;
550
+ const emitProgress = (type, run = null) => {
551
+ const event = {
552
+ type,
553
+ repository: `${owner}/${name}`,
554
+ workflow,
555
+ branch: run?.headBranch ?? branch ?? null,
556
+ headSha: run?.headSha ?? headSha ?? null,
557
+ elapsedSeconds: Math.max(0, Math.round((Date.now() - startedAt) / 1e3)),
558
+ runId: run?.id ?? null,
559
+ url: run?.url ?? null,
560
+ status: run?.status ?? null,
561
+ conclusion: run?.conclusion ?? null
562
+ };
563
+ lastProgress = event;
564
+ onProgress?.(event);
565
+ };
548
566
  while (Date.now() - startedAt < timeoutSeconds * 1e3) {
549
567
  try {
550
568
  const listed = await client.rest.actions.listWorkflowRuns({
@@ -555,10 +573,14 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
555
573
  });
556
574
  const match = listed.data.workflow_runs.map((run) => normalizeWorkflowRun(run)).find((run) => (!headSha || run.headSha === headSha) && (!branch || run.headBranch === branch));
557
575
  if (!match?.id) {
576
+ emitProgress("waiting");
558
577
  await sleep(pollSeconds * 1e3);
559
578
  continue;
560
579
  }
561
580
  for (; ; ) {
581
+ if (Date.now() - startedAt >= timeoutSeconds * 1e3) {
582
+ break;
583
+ }
562
584
  const current = await client.rest.actions.getWorkflowRun({
563
585
  owner,
564
586
  repo: name,
@@ -566,6 +588,7 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
566
588
  });
567
589
  const normalized = normalizeWorkflowRun(current.data);
568
590
  if (normalized.status === "completed") {
591
+ emitProgress("completed", normalized);
569
592
  const jobs = await client.rest.actions.listJobsForWorkflowRun({
570
593
  owner,
571
594
  repo: name,
@@ -586,13 +609,15 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
586
609
  failedJobs: normalizedJobs.filter((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped")
587
610
  };
588
611
  }
612
+ emitProgress("running", normalized);
589
613
  await sleep(pollSeconds * 1e3);
590
614
  }
591
615
  } catch (error) {
592
616
  throw normalizeGitHubApiError(error, `Unable to monitor GitHub workflow ${workflow} in ${owner}/${name}`);
593
617
  }
594
618
  }
595
- throw new Error(`Timed out waiting for GitHub workflow ${workflow} in ${owner}/${name}.`);
619
+ const lastState = lastProgress ? ` Last known state: run ${lastProgress.runId ?? "(not created)"} ${lastProgress.status ?? "waiting"}${lastProgress.conclusion ? `/${lastProgress.conclusion}` : ""}${lastProgress.url ? ` ${lastProgress.url}` : ""}.` : "";
620
+ throw new Error(`Timed out waiting for GitHub workflow ${workflow} in ${owner}/${name}.${lastState}`);
596
621
  }
597
622
  async function ensureGitHubBranchFromBase(repository, branch, {
598
623
  baseBranch = "main",
@@ -271,7 +271,7 @@ export declare function ensureGitHubDeployAutomation(tenantRoot: any, { dryRun }
271
271
  mode?: undefined;
272
272
  };
273
273
  }>;
274
- export declare function waitForGitHubWorkflowCompletion(tenantRoot: any, { repository, workflow, headSha, branch, timeoutSeconds, pollSeconds, }?: {
274
+ export declare function waitForGitHubWorkflowCompletion(tenantRoot: any, { repository, workflow, headSha, branch, timeoutSeconds, pollSeconds, onProgress, }?: {
275
275
  workflow?: string | undefined;
276
276
  timeoutSeconds?: number | undefined;
277
277
  pollSeconds?: number | undefined;
@@ -502,7 +502,8 @@ async function waitForGitHubWorkflowCompletion(tenantRoot, {
502
502
  headSha,
503
503
  branch,
504
504
  timeoutSeconds = 600,
505
- pollSeconds = 5
505
+ pollSeconds = 5,
506
+ onProgress
506
507
  } = {}) {
507
508
  if (isGitHubAutomationStubbed()) {
508
509
  return {
@@ -521,7 +522,8 @@ async function waitForGitHubWorkflowCompletion(tenantRoot, {
521
522
  headSha,
522
523
  branch,
523
524
  timeoutSeconds,
524
- pollSeconds
525
+ pollSeconds,
526
+ onProgress
525
527
  });
526
528
  }
527
529
  export {
@@ -61,6 +61,7 @@ import {
61
61
  PRODUCTION_BRANCH,
62
62
  pushBranch,
63
63
  pushHeadToBranch,
64
+ reattachDetachedHeadIfSafe,
64
65
  remoteBranchExists,
65
66
  STAGING_BRANCH,
66
67
  squashMergeBranchIntoStaging,
@@ -377,7 +378,10 @@ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
377
378
  continue;
378
379
  }
379
380
  }
380
- const result = await waitForGitHubActionsGate(gate);
381
+ const result = await waitForGitHubActionsGate(gate, {
382
+ operation,
383
+ onProgress: options.onProgress
384
+ });
381
385
  const normalized = {
382
386
  name: gate.name,
383
387
  ...result,
@@ -1731,6 +1735,27 @@ function syncAllCheckedOutPackageRepos(root, branchName) {
1731
1735
  syncBranchWithOrigin(pkg.dir, branchName);
1732
1736
  }
1733
1737
  }
1738
+ function reattachRepairablePackageRepos(root, expectedBranches = [STAGING_BRANCH, PRODUCTION_BRANCH], options = {}) {
1739
+ const reports = checkedOutWorkspacePackageRepos(root).map((pkg) => {
1740
+ const report = reattachDetachedHeadIfSafe(pkg.dir, expectedBranches);
1741
+ if (report.repaired && report.targetBranch && report.headSha) {
1742
+ options.onProgress?.(`[workflow][repair] Reattached ${pkg.name} to ${report.targetBranch} at ${report.headSha.slice(0, 12)}.`);
1743
+ }
1744
+ return {
1745
+ name: pkg.name,
1746
+ path: pkg.dir,
1747
+ ...report
1748
+ };
1749
+ });
1750
+ const blockers = reports.filter((report) => report.detached && !report.repairable).map((report) => `${report.name}: ${report.blocker ?? "detached HEAD requires manual review."}`);
1751
+ if (blockers.length > 0 && options.throwOnBlocker) {
1752
+ workflowError(options.operation ?? "release", "validation_failed", `Detached package heads require manual recovery:
1753
+ ${blockers.join("\n")}`, {
1754
+ details: { blockers, reports }
1755
+ });
1756
+ }
1757
+ return { reports, blockers };
1758
+ }
1734
1759
  function collectReleasePackageSelection(root) {
1735
1760
  const publishable = sortWorkspacePackages(
1736
1761
  publishableWorkspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
@@ -2143,11 +2168,16 @@ async function workflowSwitch(helpers, input) {
2143
2168
  return await withContextEnv(helpers.context.env, async () => {
2144
2169
  const tenantRoot = resolveProjectRootOrThrow("switch", helpers.cwd());
2145
2170
  const root = workspaceRoot(tenantRoot);
2146
- const session = resolveTreeseedWorkflowSession(root);
2147
2171
  const branchName = String(input.branch ?? input.branchName ?? "").trim();
2148
2172
  if (!branchName) {
2149
2173
  workflowError("switch", "validation_failed", "Treeseed switch requires a branch name.");
2150
2174
  }
2175
+ reattachRepairablePackageRepos(root, [branchName, STAGING_BRANCH, PRODUCTION_BRANCH], {
2176
+ operation: "switch",
2177
+ onProgress: (line, stream) => helpers.write(line, stream),
2178
+ throwOnBlocker: true
2179
+ });
2180
+ const session = resolveTreeseedWorkflowSession(root);
2151
2181
  const preview = input.preview === true;
2152
2182
  const executionMode = normalizeExecutionMode(input);
2153
2183
  if (executionMode !== "plan" && shouldDispatchSwitchToManagedWorktree(root, input, helpers.context.env)) {
@@ -2410,6 +2440,12 @@ async function workflowSave(helpers, input) {
2410
2440
  return await withContextEnv(helpers.context.env, async () => {
2411
2441
  const tenantRoot = resolveProjectRootOrThrow("save", helpers.cwd());
2412
2442
  const root = workspaceRoot(tenantRoot);
2443
+ const rootBranch = currentBranch(repoRoot(root)) || null;
2444
+ reattachRepairablePackageRepos(root, [rootBranch, STAGING_BRANCH, PRODUCTION_BRANCH].filter((branch2) => Boolean(branch2)), {
2445
+ operation: "save",
2446
+ onProgress: (line, stream) => helpers.write(line, stream),
2447
+ throwOnBlocker: true
2448
+ });
2413
2449
  const session = resolveTreeseedWorkflowSession(root);
2414
2450
  const gitRoot = session.gitRoot;
2415
2451
  const branch = session.branchName;
@@ -3275,6 +3311,11 @@ async function workflowRelease(helpers, input) {
3275
3311
  try {
3276
3312
  return await withContextEnv(helpers.context.env, async () => {
3277
3313
  const root = resolveProjectRootOrThrow("release", helpers.cwd());
3314
+ reattachRepairablePackageRepos(root, [STAGING_BRANCH, PRODUCTION_BRANCH], {
3315
+ operation: "release",
3316
+ onProgress: (line, stream) => helpers.write(line, stream),
3317
+ throwOnBlocker: true
3318
+ });
3278
3319
  const session = resolveTreeseedWorkflowSession(root);
3279
3320
  const gitRoot = session.gitRoot;
3280
3321
  const mode = session.mode;
@@ -3439,7 +3480,11 @@ async function workflowRelease(helpers, input) {
3439
3480
  branch: PRODUCTION_BRANCH,
3440
3481
  headSha: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? "")
3441
3482
  }
3442
- ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
3483
+ ].filter((gate) => gate.headSha), ciMode, {
3484
+ root,
3485
+ runId: workflowRun.runId,
3486
+ onProgress: (line, stream) => helpers.write(line, stream)
3487
+ }).then((workflowGates) => ({ workflowGates })));
3443
3488
  const releaseBackMerge2 = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, false));
3444
3489
  const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3445
3490
  const payload2 = {
@@ -3556,7 +3601,11 @@ async function workflowRelease(helpers, input) {
3556
3601
  headSha: mergeResult.commitSha,
3557
3602
  branch: PRODUCTION_BRANCH
3558
3603
  }
3559
- ], ciMode, { root, runId: workflowRun.runId });
3604
+ ], ciMode, {
3605
+ root,
3606
+ runId: workflowRun.runId,
3607
+ onProgress: (line, stream) => helpers.write(line, stream)
3608
+ });
3560
3609
  const publish = workflowGates.find((gate) => gate.workflow === "publish.yml") ?? workflowGates[0] ?? null;
3561
3610
  assertReleaseGitHubWorkflowSucceeded(pkg.name, publish);
3562
3611
  const backMerge = backMergeProductionIntoStaging(pkg.dir, pkg.name);
@@ -3667,7 +3716,11 @@ async function workflowRelease(helpers, input) {
3667
3716
  branch: PRODUCTION_BRANCH,
3668
3717
  headSha: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? "")
3669
3718
  }
3670
- ].filter((gate) => gate.headSha), ciMode, { root, runId: workflowRun.runId }).then((workflowGates) => ({ workflowGates })));
3719
+ ].filter((gate) => gate.headSha), ciMode, {
3720
+ root,
3721
+ runId: workflowRun.runId,
3722
+ onProgress: (line, stream) => helpers.write(line, stream)
3723
+ }).then((workflowGates) => ({ workflowGates })));
3671
3724
  const releaseBackMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, true));
3672
3725
  const devTagCleanupMode = effectiveInput.devTagCleanup ?? "safe-after-release";
3673
3726
  const devTagCleanup = devTagCleanupMode === "off" ? (skipJournalStep(root, workflowRun.runId, "cleanup-dev-tags", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "cleanup-dev-tags", () => {
@@ -3740,13 +3793,29 @@ async function workflowRelease(helpers, input) {
3740
3793
  });
3741
3794
  } catch (error) {
3742
3795
  ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3796
+ const latestJournal = readWorkflowRunJournal(root, workflowRun.runId);
3797
+ const lastCompleted = [...latestJournal?.steps ?? []].reverse().find((step) => step.status === "completed") ?? null;
3798
+ const nextPending = latestJournal?.steps.find((step) => step.status === "pending") ?? null;
3799
+ helpers.write(`[release][recovery] Last release phase: ${lastCompleted?.id ?? "not-started"}; next phase: ${nextPending?.id ?? "none"}.`, "stderr");
3800
+ try {
3801
+ const repair = reattachRepairablePackageRepos(root, [STAGING_BRANCH, PRODUCTION_BRANCH], {
3802
+ onProgress: (line, stream) => helpers.write(line, stream)
3803
+ });
3804
+ if (repair.blockers.length > 0) {
3805
+ helpers.write(`[release][recovery] Package repos need manual review before retrying:
3806
+ ${repair.blockers.join("\n")}`, "stderr");
3807
+ }
3808
+ } catch (repairError) {
3809
+ helpers.write(`[release][recovery] Package repo repair failed: ${repairError instanceof Error ? repairError.message : String(repairError)}`, "stderr");
3810
+ }
3811
+ helpers.write(`Safe recovery: npx trsd release --${level} --json, or inspect with npx trsd recover --json.`, "stderr");
3743
3812
  failWorkflowRun(root, workflowRun.runId, error, {
3744
3813
  resumable: true,
3745
3814
  runId: workflowRun.runId,
3746
3815
  command: "release",
3747
- message: `Resume the interrupted release on ${STAGING_BRANCH}.`,
3748
- recoverCommand: "treeseed recover",
3749
- resumeCommand: `treeseed resume ${workflowRun.runId}`
3816
+ message: `Resume the interrupted release on ${STAGING_BRANCH}. Last phase: ${lastCompleted?.id ?? "not-started"}; next phase: ${nextPending?.id ?? "none"}.`,
3817
+ recoverCommand: "npx trsd recover --json",
3818
+ resumeCommand: `npx trsd release --${level} --json`
3750
3819
  });
3751
3820
  throw error;
3752
3821
  }
@@ -93,8 +93,11 @@ export type TreeseedWorkflowState = {
93
93
  aligned: boolean;
94
94
  localBranch: boolean;
95
95
  remoteBranch: boolean;
96
+ detached: boolean;
97
+ detachedRepair: Record<string, unknown> | null;
96
98
  }>;
97
99
  blockers: string[];
100
+ warnings: string[];
98
101
  };
99
102
  preview: {
100
103
  enabled: boolean;
@@ -24,6 +24,7 @@ import { collectCliPreflight } from "./operations/services/workspace-preflight.j
24
24
  import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
25
25
  import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
26
26
  import { inspectWorkspaceDependencyMode } from "./operations/services/workspace-dependency-mode.js";
27
+ import { inspectDetachedHeadRepair, PRODUCTION_BRANCH, STAGING_BRANCH } from "./operations/services/git-workflow.js";
27
28
  import { classifyWorkflowRunJournals, inspectWorkflowLock } from "./workflow/runs.js";
28
29
  import { createTreeseedManagedToolEnv, resolveTreeseedToolCommand } from "./managed-dependencies.js";
29
30
  import {
@@ -306,6 +307,7 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
306
307
  const repoBranch = currentBranch(pkg.dir) || null;
307
308
  const dirty = gitStatusPorcelain(pkg.dir).length > 0;
308
309
  const expectedBranch = branchName;
310
+ const detachedRepair = repoBranch ? null : inspectDetachedHeadRepair(pkg.dir, [expectedBranch, STAGING_BRANCH, PRODUCTION_BRANCH].filter((branch) => Boolean(branch)));
309
311
  let localBranch = false;
310
312
  if (expectedBranch) {
311
313
  if (repoBranch === expectedBranch) {
@@ -327,14 +329,35 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
327
329
  dirty,
328
330
  aligned: expectedBranch ? repoBranch === expectedBranch : true,
329
331
  localBranch,
330
- remoteBranch
332
+ remoteBranch,
333
+ detached: repoBranch == null,
334
+ detachedRepair: detachedRepair ? {
335
+ repairable: detachedRepair.repairable,
336
+ targetBranch: detachedRepair.targetBranch,
337
+ headSha: detachedRepair.headSha,
338
+ targetSha: detachedRepair.targetSha,
339
+ dirty: detachedRepair.dirty,
340
+ blocker: detachedRepair.blocker
341
+ } : null
331
342
  };
332
343
  }) : [];
333
344
  const packageSyncBlockers = [];
345
+ const packageSyncWarnings = [];
334
346
  for (const repo of packageSyncRepos) {
335
347
  if (repo.dirty) {
336
348
  packageSyncBlockers.push(`${repo.name} has uncommitted changes.`);
337
349
  }
350
+ const detachedRepair = repo.detachedRepair;
351
+ if (repo.detached && detachedRepair?.repairable === true) {
352
+ const targetBranch = typeof detachedRepair.targetBranch === "string" ? detachedRepair.targetBranch : branchName;
353
+ const dirtyNote = detachedRepair.dirty === true ? " with local changes preserved" : "";
354
+ packageSyncWarnings.push(`${repo.name} is detached at ${targetBranch ?? "an expected branch"} HEAD${dirtyNote}; workflow commands can reattach it automatically.`);
355
+ continue;
356
+ }
357
+ if (repo.detached && detachedRepair?.repairable !== true) {
358
+ packageSyncBlockers.push(`${repo.name} is detached at a commit that does not match ${branchName ?? STAGING_BRANCH}/${PRODUCTION_BRANCH}; review manually.`);
359
+ continue;
360
+ }
338
361
  if (branchName && !repo.localBranch && !repo.remoteBranch) {
339
362
  packageSyncBlockers.push(`${repo.name} is missing branch ${branchName}.`);
340
363
  continue;
@@ -424,7 +447,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
424
447
  aligned: packageSyncRepos.every((repo) => repo.aligned),
425
448
  dirty: packageSyncRepos.some((repo) => repo.dirty),
426
449
  repos: packageSyncRepos,
427
- blockers: packageSyncBlockers
450
+ blockers: packageSyncBlockers,
451
+ warnings: packageSyncWarnings
428
452
  },
429
453
  preview: {
430
454
  enabled: false,
@@ -707,6 +731,13 @@ function recommendTreeseedNextSteps(state) {
707
731
  return recommendations.slice(0, 3);
708
732
  }
709
733
  if (state.branchRole === "staging") {
734
+ if (state.packageSync.mode === "recursive-workspace" && state.packageSync.warnings.length > 0 && state.branchName) {
735
+ recommendations.push({
736
+ operation: "release",
737
+ reason: "Reattach repairable package repos automatically before continuing the release.",
738
+ input: { bump: "patch" }
739
+ });
740
+ }
710
741
  if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
711
742
  recommendations.push({
712
743
  operation: "switch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.6.22",
3
+ "version": "0.6.24",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {