@treeseed/sdk 0.6.32 → 0.6.34

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.
@@ -998,6 +998,7 @@ export declare function runRemoteD1Migrations(tenantRoot: any, options?: {}): {
998
998
  };
999
999
  export declare function markDeploymentInitialized(tenantRoot: any, options?: {}): any;
1000
1000
  export declare function markManagedServicesInitialized(tenantRoot: any, options?: {}): any;
1001
+ export declare function recordHostedDeploymentState(tenantRoot: any, options?: {}): any;
1001
1002
  export declare function assertDeploymentInitialized(tenantRoot: any, options?: {}): any;
1002
1003
  export declare function finalizeDeploymentState(tenantRoot: any, options?: {}): any;
1003
1004
  export declare function printDeploySummary(summary: any): void;
@@ -2096,6 +2096,42 @@ function markManagedServicesInitialized(tenantRoot, options = {}) {
2096
2096
  writeDeployState(tenantRoot, state, { target });
2097
2097
  return state;
2098
2098
  }
2099
+ function recordHostedDeploymentState(tenantRoot, options = {}) {
2100
+ const target = normalizeTarget(options.scope ?? options.target ?? "prod");
2101
+ const deployConfig = loadTenantDeployConfig(tenantRoot);
2102
+ const state = loadDeployState(tenantRoot, deployConfig, { target });
2103
+ const timestamp = typeof options.timestamp === "string" && options.timestamp.trim() ? options.timestamp.trim() : (/* @__PURE__ */ new Date()).toISOString();
2104
+ const deployedUrl = typeof options.url === "string" && options.url.trim() ? options.url.trim() : state.lastDeployedUrl ?? resolveConfiguredSurfaceBaseUrl(deployConfig, target, "web");
2105
+ const commit = typeof options.commit === "string" && options.commit.trim() ? options.commit.trim() : null;
2106
+ state.lastDeployedUrl = deployedUrl;
2107
+ state.lastDeploymentTimestamp = timestamp;
2108
+ state.lastDeployedCommit = commit;
2109
+ state.readiness = {
2110
+ ...state.readiness ?? {},
2111
+ initialized: true,
2112
+ configured: true,
2113
+ provisioned: true,
2114
+ deployable: true,
2115
+ phase: "provisioned",
2116
+ initializedAt: state.readiness?.initializedAt ?? timestamp,
2117
+ lastValidatedAt: timestamp,
2118
+ blockers: [],
2119
+ warnings: state.readiness?.warnings ?? []
2120
+ };
2121
+ const nextHistoryEntry = {
2122
+ commit,
2123
+ timestamp,
2124
+ url: deployedUrl,
2125
+ target: deployTargetLabel(target),
2126
+ source: options.source ?? "hosted-github-workflow",
2127
+ workflow: options.workflow ?? null,
2128
+ runId: options.runId ?? null
2129
+ };
2130
+ const history = Array.isArray(state.deploymentHistory) ? state.deploymentHistory : [];
2131
+ state.deploymentHistory = [...history, nextHistoryEntry].slice(-20);
2132
+ writeDeployState(tenantRoot, state, { target });
2133
+ return state;
2134
+ }
2099
2135
  function assertDeploymentInitialized(tenantRoot, options = {}) {
2100
2136
  const target = normalizeTarget(options.scope ?? options.target ?? "prod");
2101
2137
  const deployConfig = loadTenantDeployConfig(tenantRoot);
@@ -2225,6 +2261,7 @@ export {
2225
2261
  queueId,
2226
2262
  queueName,
2227
2263
  reconcileCloudflareWebCacheRules,
2264
+ recordHostedDeploymentState,
2228
2265
  resolveCloudflareZoneIdForHost,
2229
2266
  resolveConfiguredCloudflareAccountId,
2230
2267
  resolveConfiguredSurfaceBaseUrl,
@@ -119,15 +119,32 @@ export declare function mergeCurrentBranchIntoStaging(cwd: any, featureBranch: a
119
119
  committed: boolean;
120
120
  commitSha: string;
121
121
  pushed: boolean;
122
+ generatedMetadataReconciliation: {
123
+ commitSha: null;
124
+ resolved: boolean;
125
+ repoDir: any;
126
+ targetBranch: string;
127
+ reconciledFiles: string[];
128
+ allConflictsWereGeneratedMetadata: boolean;
129
+ } | null;
122
130
  };
123
- export declare function squashMergeBranchIntoStaging(cwd: any, featureBranch: any, message: any, { pushTarget }?: {
131
+ export declare function squashMergeBranchIntoStaging(cwd: any, featureBranch: any, message: any, { pushTarget, reportGeneratedMetadataReconciliation }?: {
124
132
  pushTarget?: boolean | undefined;
133
+ reportGeneratedMetadataReconciliation?: boolean | undefined;
125
134
  }): {
126
135
  repoDir: string;
127
136
  targetBranch: string;
128
137
  committed: boolean;
129
138
  commitSha: string;
130
139
  pushed: boolean;
140
+ generatedMetadataReconciliation: {
141
+ commitSha: null;
142
+ resolved: boolean;
143
+ repoDir: any;
144
+ targetBranch: string;
145
+ reconciledFiles: string[];
146
+ allConflictsWereGeneratedMetadata: boolean;
147
+ } | null;
131
148
  };
132
149
  export declare function currentManagedBranch(cwd?: any): string;
133
150
  export declare function isTaskBranch(branchName: any): boolean;
@@ -162,8 +179,9 @@ export declare function mergeStagingIntoMain(cwd?: any): {
162
179
  commitSha: string;
163
180
  pushed: boolean;
164
181
  };
165
- export declare function mergeBranchIntoTarget(cwd?: any, { sourceBranch, targetBranch, message, pushTarget }?: {
182
+ export declare function mergeBranchIntoTarget(cwd?: any, { sourceBranch, targetBranch, message, pushTarget, quietMerge }?: {
166
183
  pushTarget?: boolean | undefined;
184
+ quietMerge?: boolean | undefined;
167
185
  }): {
168
186
  repoDir: string;
169
187
  targetBranch: any;
@@ -28,14 +28,34 @@ function conflictedFiles(repoDir) {
28
28
  }
29
29
  function resolveGeneratedPackageMetadataConflicts(repoDir) {
30
30
  const files = conflictedFiles(repoDir);
31
- if (files.length === 0) return false;
31
+ if (files.length === 0) {
32
+ return {
33
+ resolved: false,
34
+ repoDir,
35
+ targetBranch: STAGING_BRANCH,
36
+ reconciledFiles: [],
37
+ allConflictsWereGeneratedMetadata: false
38
+ };
39
+ }
32
40
  const generatedMetadataFiles = /* @__PURE__ */ new Set(["package.json", "package-lock.json"]);
33
41
  if (files.some((file) => !generatedMetadataFiles.has(file))) {
34
- return false;
42
+ return {
43
+ resolved: false,
44
+ repoDir,
45
+ targetBranch: STAGING_BRANCH,
46
+ reconciledFiles: files,
47
+ allConflictsWereGeneratedMetadata: false
48
+ };
35
49
  }
36
50
  runGit(["checkout", "--theirs", "--", ...files], { cwd: repoDir });
37
51
  runGit(["add", "--", ...files], { cwd: repoDir });
38
- return true;
52
+ return {
53
+ resolved: true,
54
+ repoDir,
55
+ targetBranch: STAGING_BRANCH,
56
+ reconciledFiles: files,
57
+ allConflictsWereGeneratedMetadata: true
58
+ };
39
59
  }
40
60
  function headCommit(repoDir, ref = "HEAD") {
41
61
  return runGit(["rev-parse", ref], { cwd: repoDir, capture: true }).trim();
@@ -301,22 +321,35 @@ function deleteRemoteBranch(repoDir, branchName) {
301
321
  function mergeCurrentBranchIntoStaging(cwd, featureBranch) {
302
322
  return squashMergeBranchIntoStaging(cwd, featureBranch, `stage: ${featureBranch}`);
303
323
  }
304
- function squashMergeBranchIntoStaging(cwd, featureBranch, message, { pushTarget = true } = {}) {
324
+ function squashMergeBranchIntoStaging(cwd, featureBranch, message, { pushTarget = true, reportGeneratedMetadataReconciliation = true } = {}) {
305
325
  const repoDir = assertCleanWorktree(cwd);
306
326
  fetchOrigin(repoDir);
307
327
  syncBranchWithOrigin(repoDir, STAGING_BRANCH);
328
+ let generatedMetadataReconciliation = null;
308
329
  try {
309
- runGit(["merge", "--squash", featureBranch], { cwd: repoDir });
330
+ runGit(["merge", "--squash", featureBranch], { cwd: repoDir, capture: true });
310
331
  } catch (error) {
311
- if (!resolveGeneratedPackageMetadataConflicts(repoDir)) {
332
+ const reconciliation = resolveGeneratedPackageMetadataConflicts(repoDir);
333
+ if (!reconciliation.resolved) {
312
334
  throw error;
313
335
  }
336
+ if (reportGeneratedMetadataReconciliation) {
337
+ console.log(`Resolving generated package metadata reconciliation for ${reconciliation.reconciledFiles.join(", ")}.`);
338
+ }
339
+ generatedMetadataReconciliation = {
340
+ ...reconciliation,
341
+ commitSha: null
342
+ };
314
343
  }
315
344
  let committed = false;
316
345
  if (repoHasStagedChanges(repoDir)) {
317
346
  runGit(["commit", "-m", message], { cwd: repoDir });
318
347
  committed = true;
319
348
  }
349
+ const commitSha = headCommit(repoDir);
350
+ if (generatedMetadataReconciliation) {
351
+ generatedMetadataReconciliation.commitSha = commitSha;
352
+ }
320
353
  if (pushTarget) {
321
354
  pushBranch(repoDir, STAGING_BRANCH);
322
355
  }
@@ -324,8 +357,9 @@ function squashMergeBranchIntoStaging(cwd, featureBranch, message, { pushTarget
324
357
  repoDir,
325
358
  targetBranch: STAGING_BRANCH,
326
359
  committed,
327
- commitSha: headCommit(repoDir),
328
- pushed: pushTarget
360
+ commitSha,
361
+ pushed: pushTarget,
362
+ generatedMetadataReconciliation
329
363
  };
330
364
  }
331
365
  function currentManagedBranch(cwd = workspaceRoot()) {
@@ -425,13 +459,13 @@ function mergeStagingIntoMain(cwd = workspaceRoot()) {
425
459
  pushTarget: true
426
460
  });
427
461
  }
428
- function mergeBranchIntoTarget(cwd = workspaceRoot(), { sourceBranch, targetBranch, message, pushTarget = true } = {}) {
462
+ function mergeBranchIntoTarget(cwd = workspaceRoot(), { sourceBranch, targetBranch, message, pushTarget = true, quietMerge = false } = {}) {
429
463
  const repoDir = prepareReleaseBranches(cwd);
430
464
  checkoutBranch(repoDir, targetBranch);
431
465
  if (remoteBranchExists(repoDir, targetBranch)) {
432
466
  runGit(["merge", "--ff-only", `origin/${targetBranch}`], { cwd: repoDir });
433
467
  }
434
- runGit(["merge", "--no-ff", sourceBranch, "-m", message], { cwd: repoDir });
468
+ runGit(["merge", "--no-ff", sourceBranch, "-m", message], { cwd: repoDir, capture: quietMerge });
435
469
  pushBranch(repoDir, STAGING_BRANCH);
436
470
  if (pushTarget) {
437
471
  pushBranch(repoDir, targetBranch);
@@ -114,6 +114,8 @@ export declare function skippedGitHubActionsGate(gate: GitHubActionsWorkflowGate
114
114
  conclusion: null;
115
115
  runId: null;
116
116
  url: null;
117
+ createdAt: null;
118
+ updatedAt: null;
117
119
  };
118
120
  export declare function formatGitHubActionsGateFailure(gate: GitHubActionsWorkflowGate, result: Record<string, unknown>): string;
119
121
  export declare function waitForGitHubActionsGate(gate: GitHubActionsWorkflowGate, options?: {
@@ -406,7 +406,9 @@ function skippedGitHubActionsGate(gate, reason) {
406
406
  reason,
407
407
  conclusion: null,
408
408
  runId: null,
409
- url: null
409
+ url: null,
410
+ createdAt: null,
411
+ updatedAt: null
410
412
  };
411
413
  }
412
414
  function formatGitHubActionsGateFailure(gate, result) {
@@ -431,6 +433,21 @@ function formatElapsed(seconds) {
431
433
  function shortSha(value) {
432
434
  return value ? value.slice(0, 12) : "(unknown)";
433
435
  }
436
+ function activeJobSummary(event) {
437
+ const activeJobs = event.activeJobs ?? [];
438
+ if (activeJobs.length === 0) return "";
439
+ const summaries = activeJobs.slice(0, 2).map((job) => {
440
+ const activeStep = (job.steps ?? []).find((step) => step.status && step.status !== "completed");
441
+ return activeStep?.name ? `${job.name} > ${activeStep.name}` : job.name;
442
+ }).filter(Boolean);
443
+ return summaries.length > 0 ? `; active: ${summaries.join(", ")}` : "";
444
+ }
445
+ function failedJobSummary(event) {
446
+ const failedJobs = event.failedJobs ?? [];
447
+ if (failedJobs.length === 0) return "";
448
+ const names = failedJobs.slice(0, 3).map((job) => job.name).filter(Boolean);
449
+ return names.length > 0 ? `; failed: ${names.join(", ")}` : "";
450
+ }
434
451
  function formatGitHubActionsGateProgress(gate, event, operation) {
435
452
  const prefix = `[${operation}][gate][${gate.name}] ${event.workflow}`;
436
453
  if (event.type === "waiting") {
@@ -439,12 +456,12 @@ function formatGitHubActionsGateProgress(gate, event, operation) {
439
456
  if (event.type === "completed") {
440
457
  const conclusion = event.conclusion === "success" ? "successfully" : `with conclusion ${event.conclusion ?? "unknown"}`;
441
458
  const url2 = event.url ? `: ${event.url}` : "";
442
- return `${prefix} completed ${conclusion} in ${formatElapsed(event.elapsedSeconds)}${url2}`;
459
+ return `${prefix} completed ${conclusion}${failedJobSummary(event)} in ${formatElapsed(event.elapsedSeconds)}${url2}`;
443
460
  }
444
461
  const status = event.status ?? "waiting";
445
462
  const url = event.url ? `: ${event.url}` : "";
446
463
  const run = event.runId ? ` run ${event.runId}` : "";
447
- return `${prefix}${run} ${status}${url} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
464
+ return `${prefix}${run} ${status}${activeJobSummary(event)}${url} (${formatElapsed(event.elapsedSeconds)} elapsed)`;
448
465
  }
449
466
  async function waitForGitHubActionsGate(gate, options = {}) {
450
467
  const { waitForGitHubWorkflowCompletion } = await import("./github-automation.js");
@@ -26,6 +26,8 @@ export interface GitHubWorkflowRunSummary {
26
26
  url: string | null;
27
27
  headSha: string | null;
28
28
  headBranch: string | null;
29
+ createdAt: string | null;
30
+ updatedAt: string | null;
29
31
  }
30
32
  export interface GitHubWorkflowJobSummary {
31
33
  id: number;
@@ -33,6 +35,12 @@ export interface GitHubWorkflowJobSummary {
33
35
  status: string | null;
34
36
  conclusion: string | null;
35
37
  url: string | null;
38
+ steps?: GitHubWorkflowJobStepSummary[];
39
+ }
40
+ export interface GitHubWorkflowJobStepSummary {
41
+ name: string;
42
+ status: string | null;
43
+ conclusion: string | null;
36
44
  }
37
45
  export type GitHubWorkflowProgressEvent = {
38
46
  type: 'waiting' | 'running' | 'completed';
@@ -45,6 +53,10 @@ export type GitHubWorkflowProgressEvent = {
45
53
  url: string | null;
46
54
  status: string | null;
47
55
  conclusion: string | null;
56
+ jobs?: GitHubWorkflowJobSummary[];
57
+ activeJobs?: GitHubWorkflowJobSummary[];
58
+ completedJobs?: GitHubWorkflowJobSummary[];
59
+ failedJobs?: GitHubWorkflowJobSummary[];
48
60
  };
49
61
  export declare function resolveGitHubApiToken(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): string;
50
62
  export declare function parseGitHubRepositorySlug(value: string): {
@@ -152,6 +164,8 @@ export declare function waitForGitHubWorkflowRunCompletion(repository: string |
152
164
  runId: number;
153
165
  headSha: string | null;
154
166
  branch: string | null;
167
+ createdAt: string | null;
168
+ updatedAt: string | null;
155
169
  conclusion: string | null;
156
170
  url: string | null;
157
171
  jobs: GitHubWorkflowJobSummary[];
@@ -520,7 +520,9 @@ function normalizeWorkflowRun(run) {
520
520
  conclusion: typeof run.conclusion === "string" ? run.conclusion : null,
521
521
  url: typeof run.html_url === "string" ? run.html_url : null,
522
522
  headSha: typeof run.head_sha === "string" ? run.head_sha : null,
523
- headBranch: typeof run.head_branch === "string" ? run.head_branch : null
523
+ headBranch: typeof run.head_branch === "string" ? run.head_branch : null,
524
+ createdAt: typeof run.created_at === "string" ? run.created_at : null,
525
+ updatedAt: typeof run.updated_at === "string" ? run.updated_at : null
524
526
  };
525
527
  }
526
528
  function normalizeWorkflowJob(job) {
@@ -529,9 +531,27 @@ function normalizeWorkflowJob(job) {
529
531
  name: String(job.name ?? ""),
530
532
  status: typeof job.status === "string" ? job.status : null,
531
533
  conclusion: typeof job.conclusion === "string" ? job.conclusion : null,
532
- url: typeof job.html_url === "string" ? job.html_url : null
534
+ url: typeof job.html_url === "string" ? job.html_url : null,
535
+ steps: Array.isArray(job.steps) ? job.steps.map((step) => ({
536
+ name: String(step.name ?? ""),
537
+ status: typeof step.status === "string" ? step.status : null,
538
+ conclusion: typeof step.conclusion === "string" ? step.conclusion : null
539
+ })) : []
533
540
  };
534
541
  }
542
+ async function listWorkflowJobsForProgress(client, owner, repo, runId) {
543
+ try {
544
+ const jobs = await client.rest.actions.listJobsForWorkflowRun({
545
+ owner,
546
+ repo,
547
+ run_id: runId,
548
+ per_page: 100
549
+ });
550
+ return jobs.data.jobs.map((job) => normalizeWorkflowJob(job));
551
+ } catch {
552
+ return [];
553
+ }
554
+ }
535
555
  function sleep(ms) {
536
556
  return new Promise((resolve) => setTimeout(resolve, ms));
537
557
  }
@@ -547,7 +567,10 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
547
567
  const { owner, name } = typeof repository === "string" ? parseGitHubRepositorySlug(repository) : repository;
548
568
  const startedAt = Date.now();
549
569
  let lastProgress = null;
550
- const emitProgress = (type, run = null) => {
570
+ const emitProgress = (type, run = null, jobs = []) => {
571
+ const completedJobs = jobs.filter((job) => job.status === "completed");
572
+ const failedJobs = jobs.filter((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped");
573
+ const activeJobs = jobs.filter((job) => job.status && job.status !== "completed");
551
574
  const event = {
552
575
  type,
553
576
  repository: `${owner}/${name}`,
@@ -558,7 +581,11 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
558
581
  runId: run?.id ?? null,
559
582
  url: run?.url ?? null,
560
583
  status: run?.status ?? null,
561
- conclusion: run?.conclusion ?? null
584
+ conclusion: run?.conclusion ?? null,
585
+ jobs,
586
+ activeJobs,
587
+ completedJobs,
588
+ failedJobs
562
589
  };
563
590
  lastProgress = event;
564
591
  onProgress?.(event);
@@ -587,15 +614,10 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
587
614
  run_id: match.id
588
615
  });
589
616
  const normalized = normalizeWorkflowRun(current.data);
617
+ const progressJobs = await listWorkflowJobsForProgress(client, owner, name, match.id);
590
618
  if (normalized.status === "completed") {
591
- emitProgress("completed", normalized);
592
- const jobs = await client.rest.actions.listJobsForWorkflowRun({
593
- owner,
594
- repo: name,
595
- run_id: match.id,
596
- per_page: 100
597
- });
598
- const normalizedJobs = jobs.data.jobs.map((job) => normalizeWorkflowJob(job));
619
+ const normalizedJobs = progressJobs;
620
+ emitProgress("completed", normalized, normalizedJobs);
599
621
  return {
600
622
  status: "completed",
601
623
  repository: `${owner}/${name}`,
@@ -603,13 +625,15 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
603
625
  runId: normalized.id,
604
626
  headSha: normalized.headSha,
605
627
  branch: normalized.headBranch,
628
+ createdAt: normalized.createdAt,
629
+ updatedAt: normalized.updatedAt,
606
630
  conclusion: normalized.conclusion,
607
631
  url: normalized.url,
608
632
  jobs: normalizedJobs,
609
633
  failedJobs: normalizedJobs.filter((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped")
610
634
  };
611
635
  }
612
- emitProgress("running", normalized);
636
+ emitProgress("running", normalized, progressJobs);
613
637
  await sleep(pollSeconds * 1e3);
614
638
  }
615
639
  } catch (error) {
@@ -284,6 +284,8 @@ export declare function waitForGitHubWorkflowCompletion(tenantRoot: any, { repos
284
284
  runId: number;
285
285
  headSha: string | null;
286
286
  branch: string | null;
287
+ createdAt: string | null;
288
+ updatedAt: string | null;
287
289
  conclusion: string | null;
288
290
  url: string | null;
289
291
  jobs: import("./github-api.ts").GitHubWorkflowJobSummary[];
@@ -4,17 +4,31 @@ import { readFileSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
 
6
6
  const args = process.argv.slice(2);
7
+ const defaultAllowlisted = [
8
+ {
9
+ label: 'vite-browser-external-libsodium-url',
10
+ pattern: /Module "url" has been externalized for browser compatibility, imported by ".*libsodium-sumo.*"/u,
11
+ },
12
+ ];
7
13
  const allowlisted = [];
8
14
  const files = [];
15
+ let useDefaultPolicy = true;
9
16
 
10
17
  for (let index = 0; index < args.length; index += 1) {
11
18
  const arg = args[index];
19
+ if (arg === '--no-default-policy') {
20
+ useDefaultPolicy = false;
21
+ continue;
22
+ }
12
23
  if (arg === '--allow') {
13
24
  const pattern = args[index + 1];
14
25
  if (!pattern) {
15
26
  throw new Error('Missing value for --allow.');
16
27
  }
17
- allowlisted.push(new RegExp(pattern));
28
+ allowlisted.push({
29
+ label: `custom:${pattern}`,
30
+ pattern: new RegExp(pattern),
31
+ });
18
32
  index += 1;
19
33
  continue;
20
34
  }
@@ -22,17 +36,25 @@ for (let index = 0; index < args.length; index += 1) {
22
36
  }
23
37
 
24
38
  if (files.length === 0) {
25
- throw new Error('Usage: node check-build-warnings.mjs <log-file> [<log-file> ...] [--allow <regex>]');
39
+ throw new Error('Usage: node check-build-warnings.mjs <log-file> [<log-file> ...] [--allow <regex>] [--no-default-policy]');
26
40
  }
27
41
 
28
42
  const warningLines = [];
43
+ const allowedWarnings = new Map();
44
+ const effectiveAllowlisted = [
45
+ ...(useDefaultPolicy ? defaultAllowlisted : []),
46
+ ...allowlisted,
47
+ ];
29
48
  for (const file of files) {
30
49
  const contents = readFileSync(resolve(process.cwd(), file), 'utf8');
31
50
  for (const line of contents.split(/\r?\n/u)) {
32
51
  if (!line.includes('[WARN]')) {
33
52
  continue;
34
53
  }
35
- if (allowlisted.some((pattern) => pattern.test(line))) {
54
+ const allowed = effectiveAllowlisted.find((rule) => rule.pattern.test(line));
55
+ if (allowed) {
56
+ const current = allowedWarnings.get(allowed.label) ?? 0;
57
+ allowedWarnings.set(allowed.label, current + 1);
36
58
  continue;
37
59
  }
38
60
  warningLines.push(line);
@@ -47,4 +69,11 @@ if (warningLines.length > 0) {
47
69
  process.exit(1);
48
70
  }
49
71
 
72
+ const allowedTotal = [...allowedWarnings.values()].reduce((sum, count) => sum + count, 0);
73
+ if (allowedTotal > 0) {
74
+ console.log(`Allowed build warnings: ${allowedTotal}`);
75
+ for (const [label, count] of [...allowedWarnings.entries()].sort(([left], [right]) => left.localeCompare(right))) {
76
+ console.log(`- ${label}: ${count}`);
77
+ }
78
+ }
50
79
  console.log('No unexpected build warnings detected.');
@@ -415,6 +415,8 @@ export declare function workflowSave(helpers: WorkflowOperationHelpers, input: T
415
415
  conclusion: null;
416
416
  runId: null;
417
417
  url: null;
418
+ createdAt: null;
419
+ updatedAt: null;
418
420
  }[];
419
421
  releaseCandidate: ReleaseCandidateReport | null;
420
422
  } & {
@@ -553,6 +555,8 @@ export declare function workflowClose(helpers: WorkflowOperationHelpers, input:
553
555
  conclusion: null;
554
556
  runId: null;
555
557
  url: null;
558
+ createdAt: null;
559
+ updatedAt: null;
556
560
  }[];
557
561
  releaseCandidate: ReleaseCandidateReport | null;
558
562
  } & {
@@ -732,6 +736,8 @@ export declare function workflowStage(helpers: WorkflowOperationHelpers, input:
732
736
  conclusion: null;
733
737
  runId: null;
734
738
  url: null;
739
+ createdAt: null;
740
+ updatedAt: null;
735
741
  }[];
736
742
  releaseCandidate: ReleaseCandidateReport | null;
737
743
  } & {
@@ -798,6 +804,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
798
804
  worktreePath: string | null;
799
805
  primaryRoot: string | null;
800
806
  ciMode: "hosted" | "off";
807
+ fresh: boolean;
808
+ freshArchivedRuns: never[];
801
809
  mode: TreeseedWorkflowMode;
802
810
  mergeStrategy: string;
803
811
  level: string;
@@ -844,6 +852,11 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
844
852
  mode: "root-only";
845
853
  mergeStrategy: string;
846
854
  level: "patch" | "major" | "minor";
855
+ fresh: boolean;
856
+ freshArchivedRuns: {
857
+ runId: string;
858
+ reasons: string[];
859
+ }[];
847
860
  resumed: boolean;
848
861
  resumedRunId: string | null;
849
862
  autoResumed: boolean;
@@ -879,6 +892,7 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
879
892
  targetBranch: string;
880
893
  commitSha: string;
881
894
  };
895
+ hostedDeploymentState: Record<string, unknown>[];
882
896
  finalBranch: string;
883
897
  pushStatus: {
884
898
  stagingPushed: boolean;
@@ -898,6 +912,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
898
912
  conclusion: null;
899
913
  runId: null;
900
914
  url: null;
915
+ createdAt: null;
916
+ updatedAt: null;
901
917
  }[];
902
918
  } & {
903
919
  finalState?: WorkflowStatePayload;
@@ -909,6 +925,11 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
909
925
  mode: "recursive-workspace";
910
926
  mergeStrategy: string;
911
927
  level: "patch" | "major" | "minor";
928
+ fresh: boolean;
929
+ freshArchivedRuns: {
930
+ runId: string;
931
+ reasons: string[];
932
+ }[];
912
933
  resumed: boolean;
913
934
  resumedRunId: string | null;
914
935
  autoResumed: boolean;
@@ -956,6 +977,7 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
956
977
  targetBranch: string;
957
978
  commitSha: string;
958
979
  };
980
+ hostedDeploymentState: Record<string, unknown>[];
959
981
  finalBranch: string;
960
982
  pushStatus: {
961
983
  stagingPushed: boolean;
@@ -975,6 +997,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
975
997
  conclusion: null;
976
998
  runId: null;
977
999
  url: null;
1000
+ createdAt: null;
1001
+ updatedAt: null;
978
1002
  })[];
979
1003
  } & {
980
1004
  finalState?: WorkflowStatePayload;
@@ -1041,6 +1065,11 @@ export declare function workflowRecover(helpers: WorkflowOperationHelpers, input
1041
1065
  } | null;
1042
1066
  classification: import("./runs.ts").TreeseedWorkflowRunClassification;
1043
1067
  }[];
1068
+ markedObsoleteRun: {
1069
+ runId: string;
1070
+ command: TreeseedWorkflowRunCommand;
1071
+ reason: string;
1072
+ } | null;
1044
1073
  selectedRun: TreeseedWorkflowRunJournal | null;
1045
1074
  runCount: number;
1046
1075
  } & {
@@ -36,6 +36,7 @@ import {
36
36
  ensureGeneratedWranglerConfig,
37
37
  finalizeDeploymentState,
38
38
  loadDeployState,
39
+ recordHostedDeploymentState,
39
40
  runRemoteD1Migrations,
40
41
  validateDeployPrerequisites,
41
42
  validateDestroyPrerequisites
@@ -254,13 +255,30 @@ function resolveRootReleaseSubmoduleConflicts(root, selectedPackageNames) {
254
255
  const packagePaths = new Set(packages.map((pkg) => pkg.repoPath));
255
256
  const unresolved = unresolvedMergePaths(gitRoot);
256
257
  if (unresolved.length === 0 || unresolved.some((filePath) => !packagePaths.has(filePath))) {
257
- return false;
258
+ return {
259
+ resolved: false,
260
+ allUnresolvedPathsWerePackagePointers: unresolved.length > 0 && unresolved.every((filePath) => packagePaths.has(filePath)),
261
+ unresolvedPaths: unresolved,
262
+ entries: []
263
+ };
258
264
  }
265
+ const entries = [];
259
266
  for (const pkg of packages) {
260
267
  syncBranchWithOrigin(pkg.dir, PRODUCTION_BRANCH);
261
268
  run("git", ["add", pkg.repoPath], { cwd: gitRoot });
269
+ entries.push({
270
+ packageName: pkg.name,
271
+ path: pkg.repoPath,
272
+ targetBranch: PRODUCTION_BRANCH,
273
+ resolvedCommit: headCommit(pkg.dir)
274
+ });
262
275
  }
263
- return true;
276
+ return {
277
+ resolved: true,
278
+ allUnresolvedPathsWerePackagePointers: true,
279
+ unresolvedPaths: unresolved,
280
+ entries
281
+ };
264
282
  }
265
283
  function unlinkWorkflowWorkspaceLinks(root, helpers, mode = "auto") {
266
284
  if (!shouldManageWorkspaceLinks(mode, helpers.context.env)) {
@@ -401,6 +419,38 @@ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
401
419
  }
402
420
  return results;
403
421
  }
422
+ function recordHostedDeploymentStatesFromRootGates(root, rootRelease, workflowGates) {
423
+ const gates = Array.isArray(workflowGates) ? workflowGates.map((gate) => stringRecord(gate)).filter((gate) => Boolean(gate)) : [];
424
+ const releaseRecord = stringRecord(rootRelease) ?? {};
425
+ const reports = [];
426
+ for (const target of [
427
+ { scope: "staging", branch: STAGING_BRANCH, commit: releaseRecord.stagingCommit },
428
+ { scope: "prod", branch: PRODUCTION_BRANCH, commit: releaseRecord.releasedCommit }
429
+ ]) {
430
+ const gate = gates.find((candidate) => candidate.workflow === "deploy.yml" && candidate.branch === target.branch && candidate.status === "completed" && candidate.conclusion === "success");
431
+ const timestamp = typeof gate?.updatedAt === "string" && gate.updatedAt.trim() ? gate.updatedAt : null;
432
+ if (!gate || !timestamp) {
433
+ continue;
434
+ }
435
+ const state = recordHostedDeploymentState(root, {
436
+ scope: target.scope,
437
+ commit: typeof target.commit === "string" ? target.commit : null,
438
+ timestamp,
439
+ workflow: gate.workflow,
440
+ runId: gate.runId ?? null
441
+ });
442
+ reports.push({
443
+ scope: target.scope,
444
+ branch: target.branch,
445
+ commit: typeof target.commit === "string" ? target.commit : null,
446
+ timestamp: state.lastDeploymentTimestamp ?? timestamp,
447
+ url: state.lastDeployedUrl ?? null,
448
+ workflow: gate.workflow,
449
+ runId: gate.runId ?? null
450
+ });
451
+ }
452
+ return reports;
453
+ }
404
454
  function ensureTreeseedCommandReadiness(root) {
405
455
  if (getGitHubAutomationMode() === "stub") {
406
456
  return {
@@ -1060,6 +1110,123 @@ function releasePlanMatchesCurrentHeads(plan, rootRepo, packageReports) {
1060
1110
  function releaseRunHasCompletedMutation(journal) {
1061
1111
  return journal.steps.some((step) => step.status === "completed" && step.id !== "release-plan" && step.id !== "workspace-unlink");
1062
1112
  }
1113
+ function generatedReleaseMetadataFiles(repoDir) {
1114
+ return ["package.json", "package-lock.json", "npm-shrinkwrap.json"].filter((filePath) => {
1115
+ if (existsSync(resolve(repoDir, filePath))) return true;
1116
+ try {
1117
+ run("git", ["ls-files", "--error-unmatch", filePath], { cwd: repoDir, capture: true });
1118
+ return true;
1119
+ } catch {
1120
+ return false;
1121
+ }
1122
+ });
1123
+ }
1124
+ function collectReleaseCleanupSnapshot(root, selectedPackageNames) {
1125
+ return {
1126
+ repos: [
1127
+ {
1128
+ name: "@treeseed/market",
1129
+ path: repoRoot(root),
1130
+ branch: currentBranch(repoRoot(root)) || null,
1131
+ files: generatedReleaseMetadataFiles(repoRoot(root))
1132
+ },
1133
+ ...checkedOutWorkspacePackageRepos(root).filter((pkg) => selectedPackageNames.has(pkg.name)).map((pkg) => ({
1134
+ name: pkg.name,
1135
+ path: pkg.dir,
1136
+ branch: currentBranch(pkg.dir) || null,
1137
+ files: generatedReleaseMetadataFiles(pkg.dir)
1138
+ }))
1139
+ ]
1140
+ };
1141
+ }
1142
+ function restoreReleaseGeneratedMetadata(repo) {
1143
+ const restored = [];
1144
+ const skipped = [];
1145
+ for (const filePath of repo.files) {
1146
+ const status = run("git", ["status", "--porcelain", "--", filePath], { cwd: repo.path, capture: true });
1147
+ if (!status.trim()) {
1148
+ skipped.push(filePath);
1149
+ continue;
1150
+ }
1151
+ run("git", ["restore", "--staged", "--worktree", "--", filePath], { cwd: repo.path, capture: true });
1152
+ restored.push(filePath);
1153
+ }
1154
+ return { restored, skipped };
1155
+ }
1156
+ function cleanupFailedReleaseLocalState(root, helpers, snapshot, workspaceLinksMode) {
1157
+ const report = { restored: [], skipped: [], manualReview: [] };
1158
+ try {
1159
+ ensureWorkflowWorkspaceLinks(root, helpers, workspaceLinksMode ?? "auto");
1160
+ } catch (error) {
1161
+ report.manualReview.push({
1162
+ scope: "workspace-links",
1163
+ reason: error instanceof Error ? error.message : String(error)
1164
+ });
1165
+ }
1166
+ if (!snapshot) {
1167
+ report.skipped.push({ scope: "release-metadata", reason: "cleanup snapshot was not recorded before failure" });
1168
+ return report;
1169
+ }
1170
+ for (const repo of snapshot.repos) {
1171
+ try {
1172
+ const restored = restoreReleaseGeneratedMetadata(repo);
1173
+ if (repo.branch && currentBranch(repo.path) !== repo.branch) {
1174
+ checkoutBranch(repo.path, repo.branch);
1175
+ }
1176
+ if (restored.restored.length > 0) {
1177
+ report.restored.push({ repo: repo.name, path: repo.path, files: restored.restored });
1178
+ }
1179
+ if (restored.skipped.length > 0) {
1180
+ report.skipped.push({ repo: repo.name, path: repo.path, files: restored.skipped, reason: "unchanged" });
1181
+ }
1182
+ } catch (error) {
1183
+ report.manualReview.push({
1184
+ repo: repo.name,
1185
+ path: repo.path,
1186
+ branch: repo.branch,
1187
+ files: repo.files,
1188
+ reason: error instanceof Error ? error.message : String(error),
1189
+ nextCommand: repo.branch ? `git -C ${repo.path} restore --staged --worktree -- ${repo.files.join(" ")} && git -C ${repo.path} checkout ${repo.branch}` : null
1190
+ });
1191
+ }
1192
+ }
1193
+ return report;
1194
+ }
1195
+ function prepareFreshReleaseRun(root, branch, rootRepo, packageReports) {
1196
+ if (branch !== STAGING_BRANCH) return { archived: [], blockers: [] };
1197
+ const currentHeads = Object.fromEntries([
1198
+ [rootRepo.name, rootRepo.commitSha ?? null],
1199
+ ...packageReports.map((report) => [report.name, report.commitSha ?? null])
1200
+ ]);
1201
+ const archived = [];
1202
+ const blockers = [];
1203
+ for (const journal of listInterruptedWorkflowRuns(root).filter((entry) => entry.command === "release")) {
1204
+ const classification = classifyWorkflowRunJournal(journal, {
1205
+ currentBranch: branch,
1206
+ currentHeads
1207
+ });
1208
+ if (classification.state === "stale") {
1209
+ archiveWorkflowRun(root, journal.runId, {
1210
+ ...classification,
1211
+ reasons: ["fresh release superseded stale failed release", ...classification.reasons]
1212
+ });
1213
+ archived.push({ runId: journal.runId, reasons: classification.reasons });
1214
+ continue;
1215
+ }
1216
+ if (classification.state === "resumable" && releaseRunHasCompletedMutation(journal)) {
1217
+ blockers.push(`${journal.runId}: completed release mutations and is still safe to resume. Mark it obsolete with \`npx trsd recover --obsolete ${journal.runId} --reason "superseded by fresh release"\` before using --fresh.`);
1218
+ }
1219
+ }
1220
+ if (blockers.length > 0) {
1221
+ workflowError("release", "validation_failed", [
1222
+ "Treeseed release --fresh will not bypass a resumable partial release that already completed release mutations.",
1223
+ ...blockers
1224
+ ].join("\n"), {
1225
+ details: { archived, blockers }
1226
+ });
1227
+ }
1228
+ return { archived, blockers };
1229
+ }
1063
1230
  function findAutoResumableReleaseRun(root, branch, rootRepo, packageReports) {
1064
1231
  if (branch !== STAGING_BRANCH) return null;
1065
1232
  return listInterruptedWorkflowRuns(root).find((journal) => {
@@ -3323,8 +3490,10 @@ async function workflowRelease(helpers, input) {
3323
3490
  const rootRepo = createWorkspaceRootRepoReport(root);
3324
3491
  const packageReports = createWorkspacePackageReports(root);
3325
3492
  const explicitResumeRunId = helpers.context.workflow?.resumeRunId ?? null;
3326
- const autoResumeRun = executionMode === "execute" && !explicitResumeRunId ? findAutoResumableReleaseRun(root, session.branchName, rootRepo, packageReports) : null;
3327
- const planAutoResumeRun = executionMode === "plan" ? findAutoResumableReleaseRun(root, session.branchName, rootRepo, packageReports) : null;
3493
+ const freshRelease = input.fresh === true && !explicitResumeRunId;
3494
+ const freshPreparation = freshRelease && executionMode === "execute" ? prepareFreshReleaseRun(root, session.branchName, rootRepo, packageReports) : { archived: [], blockers: [] };
3495
+ const autoResumeRun = executionMode === "execute" && !explicitResumeRunId && !freshRelease ? findAutoResumableReleaseRun(root, session.branchName, rootRepo, packageReports) : null;
3496
+ const planAutoResumeRun = executionMode === "plan" && input.fresh !== true ? findAutoResumableReleaseRun(root, session.branchName, rootRepo, packageReports) : null;
3328
3497
  const effectiveInput = autoResumeRun ? autoResumeRun.input : input;
3329
3498
  const level = effectiveInput.bump ?? "patch";
3330
3499
  const ciMode = normalizeCiMode(effectiveInput.ciMode, "release");
@@ -3345,6 +3514,8 @@ async function workflowRelease(helpers, input) {
3345
3514
  return buildWorkflowResult("release", root, {
3346
3515
  ...plannedRelease,
3347
3516
  ciMode,
3517
+ fresh: input.fresh === true,
3518
+ freshArchivedRuns: [],
3348
3519
  ...worktreePayload(root, effectiveInput.worktreeMode),
3349
3520
  autoResumeCandidate: planAutoResumeRun ? {
3350
3521
  runId: planAutoResumeRun.runId,
@@ -3372,6 +3543,7 @@ async function workflowRelease(helpers, input) {
3372
3543
  gitDependencyProtocol: effectiveInput.gitDependencyProtocol ?? "preserve-origin",
3373
3544
  gitRemoteWriteMode: effectiveInput.gitRemoteWriteMode ?? "ssh-pushurl",
3374
3545
  ciMode,
3546
+ fresh: input.fresh === true,
3375
3547
  worktreeMode: effectiveInput.worktreeMode ?? "auto",
3376
3548
  workspaceLinks: effectiveInput.workspaceLinks ?? "auto"
3377
3549
  },
@@ -3404,6 +3576,7 @@ async function workflowRelease(helpers, input) {
3404
3576
  if (autoResumeRun) {
3405
3577
  helpers.write(`[workflow][resume] Resuming interrupted release ${autoResumeRun.runId} on ${STAGING_BRANCH}.`);
3406
3578
  }
3579
+ let releaseCleanupSnapshot = null;
3407
3580
  try {
3408
3581
  const releasePlan = await executeJournalStep(root, workflowRun.runId, "release-plan", () => plannedRelease);
3409
3582
  const effectivePackageSelection = releasePlanPackageSelection(releasePlan.packageSelection);
@@ -3485,12 +3658,15 @@ async function workflowRelease(helpers, input) {
3485
3658
  runId: workflowRun.runId,
3486
3659
  onProgress: (line, stream) => helpers.write(line, stream)
3487
3660
  }).then((workflowGates) => ({ workflowGates })));
3661
+ const hostedDeploymentState2 = recordHostedDeploymentStatesFromRootGates(root, rootRelease2, rootWorkflowGateResult2?.workflowGates);
3488
3662
  const releaseBackMerge2 = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, false));
3489
3663
  const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3490
3664
  const payload2 = {
3491
3665
  mode,
3492
3666
  mergeStrategy: "merge-commit",
3493
3667
  level,
3668
+ fresh: input.fresh === true,
3669
+ freshArchivedRuns: freshPreparation.archived,
3494
3670
  resumed: workflowRun.resumed,
3495
3671
  resumedRunId: workflowRun.resumed ? workflowRun.runId : null,
3496
3672
  autoResumed: autoResumeRun != null,
@@ -3506,6 +3682,7 @@ async function workflowRelease(helpers, input) {
3506
3682
  rootRepo,
3507
3683
  releaseCandidate,
3508
3684
  releaseBackMerge: releaseBackMerge2,
3685
+ hostedDeploymentState: hostedDeploymentState2,
3509
3686
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
3510
3687
  pushStatus: { stagingPushed: true, productionPushed: true, tagPushed: true },
3511
3688
  workspaceLinks: workspaceLinks2,
@@ -3527,6 +3704,7 @@ async function workflowRelease(helpers, input) {
3527
3704
  prepareReleaseBranches(pkg.dir);
3528
3705
  }
3529
3706
  }
3707
+ releaseCleanupSnapshot = collectReleaseCleanupSnapshot(root, effectiveSelectedPackageNames);
3530
3708
  const metadata = await executeJournalStep(root, workflowRun.runId, "prepare-release-metadata", () => {
3531
3709
  const releasedPackageDevTags2 = Object.fromEntries(
3532
3710
  checkedOutWorkspacePackageRepos(root).filter((pkg) => effectiveSelectedPackageNames.has(pkg.name)).map((pkg) => {
@@ -3641,17 +3819,22 @@ async function workflowRelease(helpers, input) {
3641
3819
  pushBranch(gitRoot, STAGING_BRANCH);
3642
3820
  const stagingCommit = headCommit(gitRoot);
3643
3821
  let released;
3822
+ let submoduleReconciliation = null;
3644
3823
  try {
3645
3824
  released = mergeBranchIntoTarget(root, {
3646
3825
  sourceBranch: STAGING_BRANCH,
3647
3826
  targetBranch: PRODUCTION_BRANCH,
3648
3827
  message: `release: ${STAGING_BRANCH} -> ${PRODUCTION_BRANCH}`,
3649
- pushTarget: false
3828
+ pushTarget: false,
3829
+ quietMerge: true
3650
3830
  });
3651
3831
  } catch (error) {
3652
- if (!resolveRootReleaseSubmoduleConflicts(root, effectiveSelectedPackageNames)) {
3832
+ const reconciliation = resolveRootReleaseSubmoduleConflicts(root, effectiveSelectedPackageNames);
3833
+ if (!reconciliation.resolved) {
3653
3834
  throw error;
3654
3835
  }
3836
+ helpers.write(`[release][reconcile] Resolving generated package pointer reconciliation for ${reconciliation.entries.map((entry) => String(entry.path)).join(", ")}.`);
3837
+ submoduleReconciliation = reconciliation;
3655
3838
  commitAllIfChanged(gitRoot, `release: ${STAGING_BRANCH} -> ${PRODUCTION_BRANCH}`);
3656
3839
  released = { commitSha: headCommit(gitRoot) };
3657
3840
  }
@@ -3671,7 +3854,8 @@ async function workflowRelease(helpers, input) {
3671
3854
  stagingCommit,
3672
3855
  releasedCommit,
3673
3856
  mergeCommit: released.commitSha,
3674
- tag
3857
+ tag,
3858
+ submoduleReconciliation
3675
3859
  };
3676
3860
  });
3677
3861
  rootRepo.committed = true;
@@ -3721,6 +3905,7 @@ async function workflowRelease(helpers, input) {
3721
3905
  runId: workflowRun.runId,
3722
3906
  onProgress: (line, stream) => helpers.write(line, stream)
3723
3907
  }).then((workflowGates) => ({ workflowGates })));
3908
+ const hostedDeploymentState = recordHostedDeploymentStatesFromRootGates(root, rootRelease, rootWorkflowGateResult?.workflowGates);
3724
3909
  const releaseBackMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, true));
3725
3910
  const devTagCleanupMode = effectiveInput.devTagCleanup ?? "safe-after-release";
3726
3911
  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", () => {
@@ -3752,6 +3937,8 @@ async function workflowRelease(helpers, input) {
3752
3937
  mode,
3753
3938
  mergeStrategy: "merge-commit",
3754
3939
  level,
3940
+ fresh: input.fresh === true,
3941
+ freshArchivedRuns: freshPreparation.archived,
3755
3942
  resumed: workflowRun.resumed,
3756
3943
  resumedRunId: workflowRun.resumed ? workflowRun.runId : null,
3757
3944
  autoResumed: autoResumeRun != null,
@@ -3770,6 +3957,7 @@ async function workflowRelease(helpers, input) {
3770
3957
  rootRepo,
3771
3958
  releaseCandidate,
3772
3959
  releaseBackMerge,
3960
+ hostedDeploymentState,
3773
3961
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
3774
3962
  pushStatus: {
3775
3963
  stagingPushed: true,
@@ -3792,7 +3980,7 @@ async function workflowRelease(helpers, input) {
3792
3980
  ])
3793
3981
  });
3794
3982
  } catch (error) {
3795
- ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3983
+ const localCleanup = cleanupFailedReleaseLocalState(root, helpers, releaseCleanupSnapshot, effectiveInput.workspaceLinks ?? "auto");
3796
3984
  const latestJournal = readWorkflowRunJournal(root, workflowRun.runId);
3797
3985
  const lastCompleted = [...latestJournal?.steps ?? []].reverse().find((step) => step.status === "completed") ?? null;
3798
3986
  const nextPending = latestJournal?.steps.find((step) => step.status === "pending") ?? null;
@@ -3808,14 +3996,22 @@ ${repair.blockers.join("\n")}`, "stderr");
3808
3996
  } catch (repairError) {
3809
3997
  helpers.write(`[release][recovery] Package repo repair failed: ${repairError instanceof Error ? repairError.message : String(repairError)}`, "stderr");
3810
3998
  }
3811
- helpers.write(`Safe recovery: npx trsd release --${level} --json, or inspect with npx trsd recover --json.`, "stderr");
3999
+ if (localCleanup.restored.length > 0) {
4000
+ helpers.write(`[release][recovery] Restored generated release metadata in ${localCleanup.restored.length} repo(s).`, "stderr");
4001
+ }
4002
+ if (localCleanup.manualReview.length > 0) {
4003
+ helpers.write(`[release][recovery] Local cleanup needs manual review:
4004
+ ${localCleanup.manualReview.map((entry) => `- ${String(entry.repo ?? entry.scope ?? "repo")}: ${String(entry.reason ?? "unknown")}`).join("\n")}`, "stderr");
4005
+ }
4006
+ helpers.write(`Safe recovery: npx trsd release --${level} --json, npx trsd release --${level} --fresh --json, or inspect with npx trsd recover --json.`, "stderr");
3812
4007
  failWorkflowRun(root, workflowRun.runId, error, {
3813
4008
  resumable: true,
3814
4009
  runId: workflowRun.runId,
3815
4010
  command: "release",
3816
4011
  message: `Resume the interrupted release on ${STAGING_BRANCH}. Last phase: ${lastCompleted?.id ?? "not-started"}; next phase: ${nextPending?.id ?? "none"}.`,
3817
4012
  recoverCommand: "npx trsd recover --json",
3818
- resumeCommand: `npx trsd release --${level} --json`
4013
+ resumeCommand: `npx trsd release --${level} --json`,
4014
+ localCleanup
3819
4015
  });
3820
4016
  throw error;
3821
4017
  }
@@ -3909,7 +4105,29 @@ async function workflowRecover(helpers, input = {}) {
3909
4105
  currentBranch: session.branchName,
3910
4106
  currentHeads
3911
4107
  });
3912
- const interruptedRuns = classifiedRuns.filter((entry) => entry.classification.state === "resumable").map(({ journal }) => ({
4108
+ const markedObsoleteRun = input.obsoleteRunId ? (() => {
4109
+ const entry = classifiedRuns.find((candidate) => candidate.journal.runId === input.obsoleteRunId);
4110
+ if (!entry) {
4111
+ workflowError("recover", "validation_failed", `Treeseed recover could not find workflow run ${input.obsoleteRunId}.`);
4112
+ }
4113
+ const reason = input.obsoleteReason?.trim() || "marked obsolete by operator";
4114
+ const classification = {
4115
+ state: "obsolete",
4116
+ reasons: [reason],
4117
+ classifiedAt: (/* @__PURE__ */ new Date()).toISOString()
4118
+ };
4119
+ archiveWorkflowRun(root, entry.journal.runId, classification);
4120
+ return {
4121
+ runId: entry.journal.runId,
4122
+ command: entry.journal.command,
4123
+ reason
4124
+ };
4125
+ })() : null;
4126
+ const effectiveClassifiedRuns = markedObsoleteRun ? classifyWorkflowRunJournals(root, {
4127
+ currentBranch: session.branchName,
4128
+ currentHeads
4129
+ }) : classifiedRuns;
4130
+ const interruptedRuns = effectiveClassifiedRuns.filter((entry) => entry.classification.state === "resumable").map(({ journal }) => ({
3913
4131
  runId: journal.runId,
3914
4132
  command: journal.command,
3915
4133
  status: journal.status,
@@ -3919,7 +4137,7 @@ async function workflowRecover(helpers, input = {}) {
3919
4137
  failure: journal.failure,
3920
4138
  resumeCommand: `treeseed resume ${journal.runId}`
3921
4139
  }));
3922
- const staleRuns = classifiedRuns.filter((entry) => entry.classification.state === "stale").map(({ journal, classification }) => ({
4140
+ const staleRuns = effectiveClassifiedRuns.filter((entry) => entry.classification.state === "stale").map(({ journal, classification }) => ({
3923
4141
  runId: journal.runId,
3924
4142
  command: journal.command,
3925
4143
  status: journal.status,
@@ -3929,7 +4147,7 @@ async function workflowRecover(helpers, input = {}) {
3929
4147
  failure: journal.failure,
3930
4148
  classification
3931
4149
  }));
3932
- const obsoleteRuns = classifiedRuns.filter((entry) => entry.classification.state === "obsolete").map(({ journal, classification }) => ({
4150
+ const obsoleteRuns = effectiveClassifiedRuns.filter((entry) => entry.classification.state === "obsolete").map(({ journal, classification }) => ({
3933
4151
  runId: journal.runId,
3934
4152
  command: journal.command,
3935
4153
  status: journal.status,
@@ -3952,6 +4170,7 @@ async function workflowRecover(helpers, input = {}) {
3952
4170
  staleRuns,
3953
4171
  obsoleteRuns,
3954
4172
  prunedRuns,
4173
+ markedObsoleteRun,
3955
4174
  selectedRun,
3956
4175
  runCount: journals.length
3957
4176
  },
@@ -247,6 +247,16 @@ function isReleaseGateOnlyCompletion(journal) {
247
247
  const pendingStep = journal.steps.find((step) => step.status === "pending");
248
248
  return pendingStep?.id === "release-root-gates" || pendingStep?.id === "release-back-merge" || pendingStep?.id === "cleanup-dev-tags";
249
249
  }
250
+ function releaseStepData(journal, stepId) {
251
+ return stringRecord(journal.steps.find((step) => step.id === stepId)?.data);
252
+ }
253
+ function expectedPackageHeadAfterReleaseGate(journal, packageName) {
254
+ const data = releaseStepData(journal, `release-${packageName}`);
255
+ const backMerge = stringRecord(data?.backMerge);
256
+ if (typeof backMerge?.commitSha === "string") return backMerge.commitSha;
257
+ if (typeof data?.commitSha === "string") return data.commitSha;
258
+ return null;
259
+ }
250
260
  function classifyWorkflowRunJournal(journal, options = {}) {
251
261
  const reasons = [];
252
262
  const now = options.now ?? nowIso();
@@ -283,6 +293,22 @@ function classifyWorkflowRunJournal(journal, options = {}) {
283
293
  reasons.push(`current branch ${options.currentBranch} does not match journal branch ${journal.session.branchName}`);
284
294
  }
285
295
  const releaseGateOnlyCompletion = isReleaseGateOnlyCompletion(journal);
296
+ if (journal.command === "release" && options.currentHeads && releaseGateOnlyCompletion) {
297
+ const rootRelease = releaseStepData(journal, "release-root");
298
+ const expectedRootHead = typeof rootRelease?.stagingCommit === "string" ? rootRelease.stagingCommit : null;
299
+ const rootHead = options.currentHeads["@treeseed/market"];
300
+ if (rootHead && expectedRootHead && rootHead !== expectedRootHead) {
301
+ reasons.push(`market staging head changed from ${expectedRootHead} to ${rootHead}`);
302
+ }
303
+ const releasePlan = releaseStepData(journal, "release-plan");
304
+ for (const name of selectedReleasePackageNames(releasePlan)) {
305
+ const currentHead = options.currentHeads[name];
306
+ const expectedHead = expectedPackageHeadAfterReleaseGate(journal, name);
307
+ if (currentHead && expectedHead && currentHead !== expectedHead) {
308
+ reasons.push(`${name} staging head changed from ${expectedHead} to ${currentHead}`);
309
+ }
310
+ }
311
+ }
286
312
  if (journal.command === "release" && options.currentHeads && !releaseGateOnlyCompletion) {
287
313
  const releasePlan = stringRecord(journal.steps.find((step) => step.id === "release-plan")?.data);
288
314
  if (releasePlan) {
@@ -189,6 +189,7 @@ export type TreeseedWorkflowState = {
189
189
  releaseHistory: {
190
190
  stagingAheadMain: number | null;
191
191
  stagingBehindMain: number | null;
192
+ unreleasedStagingCommits: number | null;
192
193
  backMerged: boolean | null;
193
194
  detail: string;
194
195
  };
@@ -261,6 +261,7 @@ function safeReleaseHistory(repoDir) {
261
261
  return {
262
262
  stagingAheadMain: null,
263
263
  stagingBehindMain: null,
264
+ unreleasedStagingCommits: null,
264
265
  backMerged: null,
265
266
  detail: "Repository root is unavailable."
266
267
  };
@@ -273,16 +274,20 @@ function safeReleaseHistory(repoDir) {
273
274
  if (!Number.isFinite(stagingAheadMain) || !Number.isFinite(stagingBehindMain)) {
274
275
  throw new Error("invalid rev-list output");
275
276
  }
277
+ const stagingOnlySubjects = run("git", ["log", "--format=%s", "main..staging"], { cwd: repoDir, capture: true }).split("\n").map((line) => line.trim()).filter(Boolean);
278
+ const unreleasedStagingCommits = stagingOnlySubjects.filter((subject) => subject !== "release: sync package staging heads" && subject !== "release: back-merge main into staging" && !subject.startsWith("release: back-merge main into staging ")).length;
276
279
  return {
277
280
  stagingAheadMain,
278
281
  stagingBehindMain,
282
+ unreleasedStagingCommits,
279
283
  backMerged: stagingBehindMain === 0,
280
- detail: stagingBehindMain === 0 ? "Staging contains current main release history." : `Staging is missing ${stagingBehindMain} main commit${stagingBehindMain === 1 ? "" : "s"}.`
284
+ detail: stagingBehindMain === 0 && unreleasedStagingCommits === 0 ? stagingAheadMain > 0 ? "Staging contains current main release history and is only ahead by release sync commits." : "Staging contains current main release history." : stagingBehindMain === 0 ? `Staging has ${unreleasedStagingCommits} unreleased commit${unreleasedStagingCommits === 1 ? "" : "s"} and contains current main release history.` : `Staging is missing ${stagingBehindMain} main commit${stagingBehindMain === 1 ? "" : "s"}.`
281
285
  };
282
286
  } catch {
283
287
  return {
284
288
  stagingAheadMain: null,
285
289
  stagingBehindMain: null,
290
+ unreleasedStagingCommits: null,
286
291
  backMerged: null,
287
292
  detail: "Could not compare staging and main release history."
288
293
  };
@@ -414,6 +419,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
414
419
  if (interruptedRuns.length > 0) {
415
420
  workflowBlockers.push(`Interrupted workflow runs detected: ${interruptedRuns.map((run2) => run2.runId).join(", ")}.`);
416
421
  }
422
+ const releaseHistory = safeReleaseHistory(root);
423
+ const releaseReady = branchRole === "staging" && !dirtyWorktree && (releaseHistory.unreleasedStagingCommits ?? 0) > 0;
417
424
  const state = {
418
425
  cwd: effectiveCwd,
419
426
  workspaceRoot,
@@ -527,8 +534,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
527
534
  idleRemainingMs: keyStatus.idleRemainingMs,
528
535
  startupPassphraseConfigured: Boolean(process.env.TREESEED_KEY_PASSPHRASE?.trim())
529
536
  },
530
- releaseReady: branchRole === "staging" && !dirtyWorktree,
531
- releaseHistory: safeReleaseHistory(root),
537
+ releaseReady,
538
+ releaseHistory,
532
539
  readiness: {
533
540
  local: { ready: false, blockers: [], warnings: [] },
534
541
  staging: { ready: false, blockers: [], warnings: [] },
@@ -747,8 +754,13 @@ function recommendTreeseedNextSteps(state) {
747
754
  }
748
755
  if (!state.persistentEnvironments.staging.initialized) {
749
756
  recommendations.push({ operation: "config", reason: "Initialize the staging environment before releasing.", input: { environment: ["staging"] } });
757
+ } else if ((state.releaseHistory.unreleasedStagingCommits ?? 0) > 0) {
758
+ recommendations.push({ operation: "release", reason: "Promote unreleased staging commits into production.", input: { bump: "patch" } });
759
+ if (state.managedServices.api.enabled) {
760
+ recommendations.push({ operation: "auth:login", reason: "Keep the local runtime authenticated to the remote API used by managed services." });
761
+ }
750
762
  } else {
751
- recommendations.push({ operation: "release", reason: "Promote staging into main when the integration branch is ready for production.", input: { bump: "patch" } });
763
+ recommendations.push({ operation: "status", reason: "Inspect staging and production state; no unreleased staging commits are pending." });
752
764
  if (state.managedServices.api.enabled) {
753
765
  recommendations.push({ operation: "auth:login", reason: "Keep the local runtime authenticated to the remote API used by managed services." });
754
766
  }
@@ -25,6 +25,7 @@ export type TreeseedWorkflowRecovery = {
25
25
  recoverCommand?: string | null;
26
26
  resumeCommand?: string | null;
27
27
  lock?: Record<string, unknown> | null;
28
+ localCleanup?: Record<string, unknown> | null;
28
29
  };
29
30
  export type TreeseedWorkflowExecutionMode = 'execute' | 'plan';
30
31
  export type TreeseedWorkflowWorktreeMode = 'auto' | 'on' | 'off';
@@ -214,6 +215,7 @@ export type TreeseedReleaseInput = {
214
215
  ciMode?: TreeseedWorkflowCiMode;
215
216
  worktreeMode?: TreeseedWorkflowWorktreeMode;
216
217
  workspaceLinks?: 'auto' | 'off';
218
+ fresh?: boolean;
217
219
  plan?: boolean;
218
220
  dryRun?: boolean;
219
221
  };
@@ -223,6 +225,8 @@ export type TreeseedResumeInput = {
223
225
  export type TreeseedRecoverInput = {
224
226
  runId?: string;
225
227
  pruneStale?: boolean;
228
+ obsoleteRunId?: string;
229
+ obsoleteReason?: string;
226
230
  };
227
231
  export type TreeseedDestroyInput = {
228
232
  target?: 'local' | 'staging' | 'prod';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -184,11 +184,9 @@ jobs:
184
184
  set -euo pipefail
185
185
  npm run verify:local 2>&1 | tee verify.log
186
186
  if test -f ./packages/sdk/scripts/check-build-warnings.mjs; then
187
- node ./packages/sdk/scripts/check-build-warnings.mjs verify.log \
188
- --allow 'Module "url" has been externalized for browser compatibility, imported by ".*libsodium-sumo.*"'
187
+ node ./packages/sdk/scripts/check-build-warnings.mjs verify.log
189
188
  elif test -f ./node_modules/@treeseed/sdk/dist/scripts/check-build-warnings.js; then
190
- node ./node_modules/@treeseed/sdk/dist/scripts/check-build-warnings.js verify.log \
191
- --allow 'Module "url" has been externalized for browser compatibility, imported by ".*libsodium-sumo.*"'
189
+ node ./node_modules/@treeseed/sdk/dist/scripts/check-build-warnings.js verify.log
192
190
  else
193
191
  echo "Unable to resolve @treeseed/sdk warning scanner entrypoint."
194
192
  exit 1