@treeseed/sdk 0.6.33 → 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;
@@ -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()) {
@@ -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) {
@@ -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;
@@ -162,6 +164,8 @@ export declare function waitForGitHubWorkflowRunCompletion(repository: string |
162
164
  runId: number;
163
165
  headSha: string | null;
164
166
  branch: string | null;
167
+ createdAt: string | null;
168
+ updatedAt: string | null;
165
169
  conclusion: string | null;
166
170
  url: string | null;
167
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) {
@@ -623,6 +625,8 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
623
625
  runId: normalized.id,
624
626
  headSha: normalized.headSha,
625
627
  branch: normalized.headBranch,
628
+ createdAt: normalized.createdAt,
629
+ updatedAt: normalized.updatedAt,
626
630
  conclusion: normalized.conclusion,
627
631
  url: normalized.url,
628
632
  jobs: normalizedJobs,
@@ -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[];
@@ -5,7 +5,10 @@ import { resolve } from 'node:path';
5
5
 
6
6
  const args = process.argv.slice(2);
7
7
  const defaultAllowlisted = [
8
- /Module "url" has been externalized for browser compatibility, imported by ".*libsodium-sumo.*"/u,
8
+ {
9
+ label: 'vite-browser-external-libsodium-url',
10
+ pattern: /Module "url" has been externalized for browser compatibility, imported by ".*libsodium-sumo.*"/u,
11
+ },
9
12
  ];
10
13
  const allowlisted = [];
11
14
  const files = [];
@@ -22,7 +25,10 @@ for (let index = 0; index < args.length; index += 1) {
22
25
  if (!pattern) {
23
26
  throw new Error('Missing value for --allow.');
24
27
  }
25
- allowlisted.push(new RegExp(pattern));
28
+ allowlisted.push({
29
+ label: `custom:${pattern}`,
30
+ pattern: new RegExp(pattern),
31
+ });
26
32
  index += 1;
27
33
  continue;
28
34
  }
@@ -34,6 +40,7 @@ if (files.length === 0) {
34
40
  }
35
41
 
36
42
  const warningLines = [];
43
+ const allowedWarnings = new Map();
37
44
  const effectiveAllowlisted = [
38
45
  ...(useDefaultPolicy ? defaultAllowlisted : []),
39
46
  ...allowlisted,
@@ -44,7 +51,10 @@ for (const file of files) {
44
51
  if (!line.includes('[WARN]')) {
45
52
  continue;
46
53
  }
47
- if (effectiveAllowlisted.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);
48
58
  continue;
49
59
  }
50
60
  warningLines.push(line);
@@ -59,4 +69,11 @@ if (warningLines.length > 0) {
59
69
  process.exit(1);
60
70
  }
61
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
+ }
62
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
  } & {
@@ -886,6 +892,7 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
886
892
  targetBranch: string;
887
893
  commitSha: string;
888
894
  };
895
+ hostedDeploymentState: Record<string, unknown>[];
889
896
  finalBranch: string;
890
897
  pushStatus: {
891
898
  stagingPushed: boolean;
@@ -905,6 +912,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
905
912
  conclusion: null;
906
913
  runId: null;
907
914
  url: null;
915
+ createdAt: null;
916
+ updatedAt: null;
908
917
  }[];
909
918
  } & {
910
919
  finalState?: WorkflowStatePayload;
@@ -968,6 +977,7 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
968
977
  targetBranch: string;
969
978
  commitSha: string;
970
979
  };
980
+ hostedDeploymentState: Record<string, unknown>[];
971
981
  finalBranch: string;
972
982
  pushStatus: {
973
983
  stagingPushed: boolean;
@@ -987,6 +997,8 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
987
997
  conclusion: null;
988
998
  runId: null;
989
999
  url: null;
1000
+ createdAt: null;
1001
+ updatedAt: null;
990
1002
  })[];
991
1003
  } & {
992
1004
  finalState?: WorkflowStatePayload;
@@ -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
@@ -418,6 +419,38 @@ async function waitForWorkflowGates(operation, gates, ciMode, options = {}) {
418
419
  }
419
420
  return results;
420
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
+ }
421
454
  function ensureTreeseedCommandReadiness(root) {
422
455
  if (getGitHubAutomationMode() === "stub") {
423
456
  return {
@@ -3625,6 +3658,7 @@ async function workflowRelease(helpers, input) {
3625
3658
  runId: workflowRun.runId,
3626
3659
  onProgress: (line, stream) => helpers.write(line, stream)
3627
3660
  }).then((workflowGates) => ({ workflowGates })));
3661
+ const hostedDeploymentState2 = recordHostedDeploymentStatesFromRootGates(root, rootRelease2, rootWorkflowGateResult2?.workflowGates);
3628
3662
  const releaseBackMerge2 = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, false));
3629
3663
  const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
3630
3664
  const payload2 = {
@@ -3648,6 +3682,7 @@ async function workflowRelease(helpers, input) {
3648
3682
  rootRepo,
3649
3683
  releaseCandidate,
3650
3684
  releaseBackMerge: releaseBackMerge2,
3685
+ hostedDeploymentState: hostedDeploymentState2,
3651
3686
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
3652
3687
  pushStatus: { stagingPushed: true, productionPushed: true, tagPushed: true },
3653
3688
  workspaceLinks: workspaceLinks2,
@@ -3870,6 +3905,7 @@ async function workflowRelease(helpers, input) {
3870
3905
  runId: workflowRun.runId,
3871
3906
  onProgress: (line, stream) => helpers.write(line, stream)
3872
3907
  }).then((workflowGates) => ({ workflowGates })));
3908
+ const hostedDeploymentState = recordHostedDeploymentStatesFromRootGates(root, rootRelease, rootWorkflowGateResult?.workflowGates);
3873
3909
  const releaseBackMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => backMergeRootProductionIntoStaging(root, true));
3874
3910
  const devTagCleanupMode = effectiveInput.devTagCleanup ?? "safe-after-release";
3875
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", () => {
@@ -3921,6 +3957,7 @@ async function workflowRelease(helpers, input) {
3921
3957
  rootRepo,
3922
3958
  releaseCandidate,
3923
3959
  releaseBackMerge,
3960
+ hostedDeploymentState,
3924
3961
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
3925
3962
  pushStatus: {
3926
3963
  stagingPushed: true,
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.6.33",
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": {