@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.
- package/dist/operations/services/git-workflow.d.ts +68 -0
- package/dist/operations/services/git-workflow.js +71 -0
- package/dist/operations/services/github-actions-verification.d.ts +2 -0
- package/dist/operations/services/github-actions-verification.js +29 -1
- package/dist/operations/services/github-api.d.ts +14 -1
- package/dist/operations/services/github-api.js +27 -2
- package/dist/operations/services/github-automation.d.ts +1 -1
- package/dist/operations/services/github-automation.js +4 -2
- package/dist/workflow/operations.js +77 -8
- package/dist/workflow-state.d.ts +3 -0
- package/dist/workflow-state.js +33 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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: "
|
|
3749
|
-
resumeCommand: `
|
|
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
|
}
|
package/dist/workflow-state.d.ts
CHANGED
|
@@ -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;
|
package/dist/workflow-state.js
CHANGED
|
@@ -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",
|