@git.zone/cli 4.0.0 → 6.0.0

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.
Files changed (35) hide show
  1. package/.smartconfig.json +0 -1
  2. package/assets/templates/ci_default/.gitea/workflows/default_tags.yaml +0 -21
  3. package/assets/templates/ci_default_gitlab/.gitlab-ci.yml +0 -13
  4. package/assets/templates/ci_default_private/.gitea/workflows/default_tags.yaml +0 -21
  5. package/assets/templates/ci_default_private_gitlab/.gitlab-ci.yml +0 -13
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/helpers.climode.js +31 -2
  8. package/dist_ts/helpers.workflow.d.ts +10 -2
  9. package/dist_ts/helpers.workflow.js +102 -19
  10. package/dist_ts/mod_commit/mod.helpers.d.ts +3 -3
  11. package/dist_ts/mod_commit/mod.helpers.js +10 -10
  12. package/dist_ts/mod_config/index.js +22 -23
  13. package/dist_ts/mod_release/classes.releasejournal.d.ts +78 -0
  14. package/dist_ts/mod_release/classes.releasejournal.js +511 -0
  15. package/dist_ts/mod_release/helpers.npmartifact.d.ts +32 -0
  16. package/dist_ts/mod_release/helpers.npmartifact.js +358 -0
  17. package/dist_ts/mod_release/helpers.releasebranch.d.ts +47 -0
  18. package/dist_ts/mod_release/helpers.releasebranch.js +627 -0
  19. package/dist_ts/mod_release/helpers.releasepublication.d.ts +24 -0
  20. package/dist_ts/mod_release/helpers.releasepublication.js +293 -0
  21. package/dist_ts/mod_release/index.d.ts +1 -1
  22. package/dist_ts/mod_release/index.js +539 -209
  23. package/package.json +1 -1
  24. package/readme.hints.md +47 -1
  25. package/readme.md +81 -38
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/helpers.climode.ts +34 -1
  28. package/ts/helpers.workflow.ts +155 -19
  29. package/ts/mod_commit/mod.helpers.ts +12 -8
  30. package/ts/mod_config/index.ts +21 -22
  31. package/ts/mod_release/classes.releasejournal.ts +740 -0
  32. package/ts/mod_release/helpers.npmartifact.ts +553 -0
  33. package/ts/mod_release/helpers.releasebranch.ts +1344 -0
  34. package/ts/mod_release/helpers.releasepublication.ts +641 -0
  35. package/ts/mod_release/index.ts +906 -262
@@ -9,25 +9,54 @@ import {
9
9
  readPendingChangelog,
10
10
  } from "../helpers.changelog.js";
11
11
  import {
12
- formatTsdockerMinimumVersion,
13
- getDeclaredTsdockerRange,
14
- getTsdockerMinimumVersion,
15
12
  resolveReleaseWorkflow,
16
- supportsTsdockerMinimumVersion,
13
+ resolveReleaseResumeConfiguration,
14
+ type IResolvedReleaseResumeConfiguration,
17
15
  type IResolvedReleaseWorkflow,
18
16
  } from "../helpers.workflow.js";
19
17
  import * as commitHelpers from "../mod_commit/mod.helpers.js";
20
- import { hasInstalledProjectTsdockerBinary } from "../helpers.tsdocker.js";
18
+ import {
19
+ inspectReleaseBranch,
20
+ integrateReleaseBranch,
21
+ prepareReleaseIntegration,
22
+ revalidatePreparedReleaseBranch,
23
+ revalidateReleasePublicationState,
24
+ releaseGitEnv,
25
+ resolveReleaseMergeFlag,
26
+ type IReleaseBranchContext,
27
+ } from "./helpers.releasebranch.js";
28
+ import {
29
+ ReleaseJournalStore,
30
+ createInitialTargetStatus,
31
+ finalizeJournalCompletion,
32
+ normalizeReleaseGitRemoteName,
33
+ normalizeReleaseVersion,
34
+ resolveGitCommonDirectory,
35
+ type IReleaseArtifact,
36
+ type IReleaseJournal,
37
+ } from "./classes.releasejournal.js";
38
+ import {
39
+ assertNoLegacyNpmPublisher,
40
+ assertPnpmReleaseCapability,
41
+ hashReleaseDestination,
42
+ normalizeNpmRegistryUrl,
43
+ packNpmArtifact,
44
+ verifyStoredNpmArtifact,
45
+ } from "./helpers.npmartifact.js";
46
+ import { executeReleasePublication } from "./helpers.releasepublication.js";
21
47
 
22
- type TTargetStatus = "success" | "already-published" | "skipped" | "failed";
48
+ export { buildReleaseGitPushArgs } from "./helpers.releasepublication.js";
23
49
 
24
- interface ITargetResult {
25
- target: string;
26
- status: TTargetStatus;
27
- message?: string;
28
- }
50
+ export const run = async (argvArg: any): Promise<void> => {
51
+ try {
52
+ await runInternal(argvArg);
53
+ } catch (error) {
54
+ logger.log("error", error instanceof Error ? error.message : String(error));
55
+ process.exitCode = 1;
56
+ }
57
+ };
29
58
 
30
- export const run = async (argvArg: any) => {
59
+ const runInternal = async (argvArg: any): Promise<void> => {
31
60
  const mode = await getCliMode(argvArg);
32
61
  const subcommand = argvArg._?.[1];
33
62
 
@@ -36,47 +65,88 @@ export const run = async (argvArg: any) => {
36
65
  return;
37
66
  }
38
67
 
68
+ if (subcommand === "inspect") {
69
+ await runReleaseInspect(argvArg, mode);
70
+ return;
71
+ }
72
+ if (subcommand === "resume") {
73
+ await runReleaseResume(argvArg, mode);
74
+ return;
75
+ }
76
+ if (subcommand !== undefined) {
77
+ throw new Error(`Unknown release subcommand: ${subcommand}`);
78
+ }
79
+
39
80
  if (mode.json) {
40
81
  printJson({
41
82
  ok: false,
42
- error: "JSON output is not supported for mutating release workflows yet. Use `gitzone release --plan` for a human-readable plan.",
83
+ error:
84
+ "JSON output is not supported for mutating release workflows yet. Use `gitzone release --plan` for a human-readable plan.",
43
85
  });
44
86
  return;
45
87
  }
46
88
 
89
+ const smartshellInstance = new plugins.smartshell.Smartshell({
90
+ executor: "bash",
91
+ sourceFilePaths: [],
92
+ });
93
+ const mergeRequested = resolveReleaseMergeFlag(argvArg);
47
94
  const workflow = await resolveReleaseWorkflow(argvArg);
48
- printReleasePlan(workflow);
95
+ assertReleaseConfiguration(workflow);
96
+ let branchContext: IReleaseBranchContext = await inspectReleaseBranch({
97
+ smartshell: smartshellInstance,
98
+ cwd: paths.cwd,
99
+ gitRemote: workflow.gitRemote,
100
+ gitTargetActive: workflow.targets.includes("git"),
101
+ pushBranch: workflow.pushBranch,
102
+ pushTags: workflow.pushTags,
103
+ mergeRequested,
104
+ planMode: workflow.confirmation === "plan",
105
+ });
106
+ printReleasePlan(workflow, branchContext);
49
107
  if (workflow.confirmation === "plan") {
50
108
  return;
51
109
  }
52
110
 
53
- const smartshellInstance = new plugins.smartshell.Smartshell({
54
- executor: "bash",
55
- sourceFilePaths: [],
56
- });
111
+ await assertFreshReleaseCapabilities(
112
+ smartshellInstance,
113
+ workflow,
114
+ branchContext.sourceCwd,
115
+ branchContext.pushUrl,
116
+ );
57
117
 
58
118
  const pending = await readPendingChangelog(
59
- plugins.path.join(paths.cwd, workflow.changelogFile),
119
+ plugins.path.join(branchContext.sourceCwd, workflow.changelogFile),
60
120
  workflow.changelogPendingSection,
61
121
  );
62
122
  if (pending.isEmpty && !argvArg["allow-empty"] && !argvArg.allowEmpty) {
63
- logger.log("error", "No pending changelog entries. Nothing to release.");
64
- process.exit(1);
123
+ throw new Error("No pending changelog entries. Nothing to release.");
65
124
  }
66
125
 
67
126
  const versionType = resolveVersionType(argvArg, pending.block);
68
- const projectType = await commitHelpers.detectProjectType();
69
- const currentVersion = await commitHelpers.readCurrentVersion(projectType);
70
- const plannedVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
127
+ const projectType = await commitHelpers.detectProjectType(
128
+ branchContext.sourceCwd,
129
+ );
130
+ const currentVersion = await commitHelpers.readCurrentVersion(
131
+ projectType,
132
+ branchContext.sourceCwd,
133
+ );
134
+ const plannedVersion = commitHelpers.calculateNewVersion(
135
+ currentVersion,
136
+ versionType,
137
+ );
71
138
 
72
139
  if (workflow.confirmation === "prompt") {
73
140
  if (!mode.interactive) {
74
- throw new Error("Release confirmation requires an interactive terminal. Use `-y` or set release.confirmation to `auto`.");
141
+ throw new Error(
142
+ "Release confirmation requires an interactive terminal. Use `-y` or set release.confirmation to `auto`.",
143
+ );
75
144
  }
76
- const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
77
- `Release v${plannedVersion} (${versionType}) now?`,
78
- true,
79
- );
145
+ const confirmed =
146
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
147
+ `Release v${plannedVersion} (${versionType}) now?`,
148
+ true,
149
+ );
80
150
  if (!confirmed) {
81
151
  logger.log("info", "Release cancelled.");
82
152
  return;
@@ -84,44 +154,513 @@ export const run = async (argvArg: any) => {
84
154
  }
85
155
 
86
156
  let newVersion = plannedVersion;
87
- const gitResults: ITargetResult[] = [];
88
- const npmResults: ITargetResult[] = [];
89
- const dockerResults: ITargetResult[] = [];
90
-
91
- if (workflow.requireCleanTree) {
92
- await verifyCleanTree(smartshellInstance, "Working tree is not clean. Commit or stash changes before releasing.");
93
- }
157
+ branchContext = await prepareReleaseIntegration(
158
+ smartshellInstance,
159
+ branchContext,
160
+ );
94
161
  if (workflow.runTests) {
95
- await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
162
+ await runCommandStep(
163
+ smartshellInstance,
164
+ branchContext.sourceCwd,
165
+ "Running tests",
166
+ workflow.testCommand,
167
+ );
96
168
  }
97
169
 
98
- newVersion = await runVersionStep(projectType, versionType);
99
- await runChangelogStep(workflow, newVersion);
100
- await runReleaseCommitStep(smartshellInstance, newVersion);
101
- await runTagStep(smartshellInstance, newVersion);
170
+ let releaseCwd = branchContext.sourceCwd;
171
+ let releasePushUrl = branchContext.pushUrl;
172
+ let expectedRemoteMainOid = branchContext.remoteMainOid;
173
+ if (branchContext.sourceBranch === "main") {
174
+ await revalidatePreparedReleaseBranch(smartshellInstance, branchContext);
175
+ } else {
176
+ const integration = await integrateReleaseBranch(
177
+ smartshellInstance,
178
+ branchContext,
179
+ );
180
+ releaseCwd = integration.releaseCwd;
181
+ releasePushUrl = integration.pushUrl;
182
+ expectedRemoteMainOid = integration.expectedRemoteMainOid;
183
+ }
184
+
185
+ newVersion = await runVersionStep(projectType, versionType, releaseCwd);
186
+ await runChangelogStep(workflow, newVersion, releaseCwd);
187
+ const releaseCommitOid = await runReleaseCommitStep(
188
+ smartshellInstance,
189
+ newVersion,
190
+ releaseCwd,
191
+ );
192
+ await runTagStep(
193
+ smartshellInstance,
194
+ newVersion,
195
+ releaseCommitOid,
196
+ releaseCwd,
197
+ );
198
+ const releaseRefs = await captureReleaseRefs(
199
+ smartshellInstance,
200
+ releaseCwd,
201
+ newVersion,
202
+ releaseCommitOid,
203
+ );
102
204
 
103
205
  if (workflow.runBuild) {
104
- await runCommandStep(smartshellInstance, "Running release build", workflow.buildCommand);
105
- await verifyCleanTree(smartshellInstance, "Build produced uncommitted changes. Aborting release.");
206
+ await runCommandStep(
207
+ smartshellInstance,
208
+ releaseCwd,
209
+ "Running release build",
210
+ workflow.buildCommand,
211
+ );
212
+ await verifyCleanTree(
213
+ smartshellInstance,
214
+ releaseCwd,
215
+ "Build produced uncommitted changes. Aborting release.",
216
+ );
106
217
  }
107
218
 
108
- if (workflow.targets.includes("git")) {
109
- gitResults.push(...(await runGitTarget(smartshellInstance, workflow)));
219
+ const ensurePublicationState = async (
220
+ expectedRemoteMainOidArg = expectedRemoteMainOid,
221
+ ): Promise<void> => {
222
+ await verifyReleaseRefs(
223
+ smartshellInstance,
224
+ releaseCwd,
225
+ newVersion,
226
+ releaseRefs,
227
+ );
228
+ await revalidateReleasePublicationState(
229
+ smartshellInstance,
230
+ branchContext,
231
+ releaseRefs.mainOid,
232
+ expectedRemoteMainOidArg,
233
+ );
234
+ };
235
+
236
+ await ensurePublicationState();
237
+ const journalContext = await createReleaseJournal({
238
+ smartshell: smartshellInstance,
239
+ workflow,
240
+ releaseCwd,
241
+ releasePushUrl,
242
+ expectedRemoteMainOid,
243
+ version: newVersion,
244
+ refs: releaseRefs,
245
+ ensurePublicationState,
246
+ });
247
+ const finalJournal = await executeReleasePublication({
248
+ smartshell: smartshellInstance,
249
+ cwd: releaseCwd,
250
+ store: journalContext.store,
251
+ journal: journalContext.journal,
252
+ pushUrl: releasePushUrl,
253
+ ensurePublicationState,
254
+ });
255
+ printReleaseJournalSummary(finalJournal);
256
+ };
257
+
258
+ const assertFreshReleaseCapabilities = async (
259
+ smartshellArg: plugins.smartshell.Smartshell,
260
+ workflowArg: IResolvedReleaseWorkflow,
261
+ cwdArg: string,
262
+ pushUrlArg?: string,
263
+ ): Promise<void> => {
264
+ if (workflowArg.targets.includes("docker")) {
265
+ throw new Error(
266
+ "Journaled Docker publication requires the planned structured tSDocker digest capability. Disable the Docker target until that upstream release is installed.",
267
+ );
110
268
  }
111
- if (workflow.targets.includes("npm")) {
112
- npmResults.push(...(await runNpmTarget(smartshellInstance, workflow)));
269
+ if (workflowArg.targets.includes("git") && !pushUrlArg) {
270
+ throw new Error("The Git release target requires one resolved push destination.");
113
271
  }
114
- if (workflow.targets.includes("docker")) {
115
- dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow)));
272
+ if (
273
+ workflowArg.targets.includes("git") ||
274
+ workflowArg.targets.includes("npm")
275
+ ) {
276
+ await assertNoLegacyNpmPublisher(cwdArg);
277
+ }
278
+ if (workflowArg.targets.includes("npm")) {
279
+ await assertPnpmReleaseCapability(smartshellArg, cwdArg);
280
+ }
281
+ };
282
+
283
+ const assertReleaseConfiguration = (
284
+ workflowArg: IResolvedReleaseWorkflow,
285
+ ): void => {
286
+ if (workflowArg.targets.includes("git")) {
287
+ normalizeReleaseGitRemoteName(workflowArg.gitRemote);
288
+ }
289
+ if (!workflowArg.targets.includes("npm")) {
290
+ return;
291
+ }
292
+ if (workflowArg.npmRegistries.length === 0) {
293
+ throw new Error("The npm release target requires at least one registry.");
294
+ }
295
+ if (workflowArg.npmAccessLevel !== "public") {
296
+ throw new Error(
297
+ "Journaled npm publication currently requires public access so every destination can be verified anonymously.",
298
+ );
299
+ }
300
+ if (
301
+ workflowArg.npmAlreadyPublished !== "success" &&
302
+ workflowArg.npmAlreadyPublished !== "error"
303
+ ) {
304
+ throw new Error(
305
+ "release.targets.npm.alreadyPublished must be success or error.",
306
+ );
307
+ }
308
+ const canonicalRegistries = workflowArg.npmRegistries.map(
309
+ normalizeNpmRegistryUrl,
310
+ );
311
+ if (
312
+ new Set(canonicalRegistries).size !== canonicalRegistries.length ||
313
+ canonicalRegistries.some(
314
+ (registryArg, indexArg) => registryArg !== workflowArg.npmRegistries[indexArg],
315
+ )
316
+ ) {
317
+ throw new Error(
318
+ "npm release registries must be unique canonical credential-free http(s) URLs.",
319
+ );
320
+ }
321
+ };
322
+
323
+ interface ICreateReleaseJournalOptions {
324
+ smartshell: plugins.smartshell.Smartshell;
325
+ workflow: IResolvedReleaseWorkflow;
326
+ releaseCwd: string;
327
+ releasePushUrl?: string;
328
+ expectedRemoteMainOid?: string;
329
+ version: string;
330
+ refs: IReleaseRefs;
331
+ ensurePublicationState: (expectedRemoteMainOidArg?: string) => Promise<void>;
332
+ }
333
+
334
+ const createReleaseJournal = async (
335
+ optionsArg: ICreateReleaseJournalOptions,
336
+ ): Promise<{ store: ReleaseJournalStore; journal: IReleaseJournal }> => {
337
+ const gitCommonDirectory = await resolveGitCommonDirectory(
338
+ optionsArg.smartshell,
339
+ optionsArg.releaseCwd,
340
+ );
341
+ const store = new ReleaseJournalStore(gitCommonDirectory);
342
+ const releaseDirectory = store.getReleaseDirectory(optionsArg.version);
343
+ const releaseDirectoryExists = await plugins.fs
344
+ .lstat(releaseDirectory)
345
+ .then(() => true)
346
+ .catch((error: NodeJS.ErrnoException) => {
347
+ if (error.code === "ENOENT") return false;
348
+ throw error;
349
+ });
350
+ if (releaseDirectoryExists) {
351
+ throw new Error(
352
+ `Release journal v${optionsArg.version} already exists. Inspect or resume it instead of regenerating artifacts.`,
353
+ );
354
+ }
355
+
356
+ let temporaryDirectory = await store.createTemporaryDirectory(optionsArg.version);
357
+ try {
358
+ let artifact: IReleaseArtifact | null = null;
359
+ if (optionsArg.workflow.targets.includes("npm")) {
360
+ artifact = await packNpmArtifact(
361
+ optionsArg.smartshell,
362
+ optionsArg.releaseCwd,
363
+ temporaryDirectory,
364
+ optionsArg.version,
365
+ );
366
+ await verifyCleanTree(
367
+ optionsArg.smartshell,
368
+ optionsArg.releaseCwd,
369
+ "npm pack lifecycle scripts changed the release worktree. Aborting release.",
370
+ );
371
+ await optionsArg.ensurePublicationState();
372
+ }
373
+
374
+ const gitEnabled = optionsArg.workflow.targets.includes("git");
375
+ if (
376
+ gitEnabled &&
377
+ (!optionsArg.releasePushUrl || !optionsArg.expectedRemoteMainOid)
378
+ ) {
379
+ throw new Error("Git release destination state is incomplete.");
380
+ }
381
+ const now = new Date().toISOString();
382
+ const gitStatus = createInitialTargetStatus(gitEnabled);
383
+ const journal = finalizeJournalCompletion({
384
+ kind: "gitzone-release-journal",
385
+ schemaVersion: 1,
386
+ revision: 1,
387
+ release: {
388
+ version: optionsArg.version,
389
+ tag: `v${optionsArg.version}`,
390
+ mainOid: optionsArg.refs.mainOid,
391
+ tagOid: optionsArg.refs.tagOid,
392
+ },
393
+ artifact,
394
+ git: {
395
+ state: gitStatus.state,
396
+ attempts: gitStatus.attempts,
397
+ attempt: gitStatus.attempt,
398
+ error: gitStatus.error,
399
+ remote: gitEnabled ? optionsArg.workflow.gitRemote : null,
400
+ destinationHash: gitEnabled
401
+ ? hashReleaseDestination(optionsArg.releasePushUrl as string)
402
+ : null,
403
+ expectedRemoteMainOid: gitEnabled
404
+ ? (optionsArg.expectedRemoteMainOid as string)
405
+ : null,
406
+ },
407
+ npm: {
408
+ access: "public",
409
+ tag: "latest",
410
+ alreadyPublished: optionsArg.workflow.targets.includes("npm")
411
+ ? optionsArg.workflow.npmAlreadyPublished
412
+ : "success",
413
+ registries: optionsArg.workflow.targets.includes("npm")
414
+ ? optionsArg.workflow.npmRegistries.map((registryArg) => {
415
+ const status = createInitialTargetStatus(true);
416
+ return {
417
+ state: status.state,
418
+ attempts: status.attempts,
419
+ attempt: status.attempt,
420
+ error: status.error,
421
+ registry: registryArg,
422
+ };
423
+ })
424
+ : [],
425
+ },
426
+ createdAt: now,
427
+ updatedAt: now,
428
+ completedAt: null,
429
+ }, now);
430
+ const installed = await store.installPrepared(temporaryDirectory, journal);
431
+ temporaryDirectory = "";
432
+ return { store, journal: installed };
433
+ } finally {
434
+ if (temporaryDirectory) {
435
+ await plugins.fs.rm(temporaryDirectory, { recursive: true, force: true });
436
+ }
437
+ }
438
+ };
439
+
440
+ const runReleaseInspect = async (
441
+ argvArg: any,
442
+ modeArg: ICliMode,
443
+ ): Promise<void> => {
444
+ if (argvArg._?.length > 3) {
445
+ throw new Error("Usage: gitzone release inspect [version] [--json]");
446
+ }
447
+ const smartshellInstance = new plugins.smartshell.Smartshell({
448
+ executor: "bash",
449
+ sourceFilePaths: [],
450
+ });
451
+ const store = new ReleaseJournalStore(
452
+ await resolveGitCommonDirectory(smartshellInstance, paths.cwd),
453
+ );
454
+ const requestedVersion = argvArg._?.[2];
455
+ const journals = requestedVersion
456
+ ? [await store.read(normalizeReleaseVersion(requestedVersion))]
457
+ : await store.list();
458
+ if (modeArg.json) {
459
+ printJson({ ok: true, journals });
460
+ return;
461
+ }
462
+ if (journals.length === 0) {
463
+ console.log("No release journals found.");
464
+ return;
465
+ }
466
+ for (const journal of journals) {
467
+ printReleaseJournalSummary(journal);
468
+ }
469
+ };
470
+
471
+ const assertResumeArguments = (argvArg: any): void => {
472
+ const forbiddenKeys = [
473
+ "allow-empty",
474
+ "allowEmpty",
475
+ "b",
476
+ "build",
477
+ "docker",
478
+ "git",
479
+ "major",
480
+ "merge",
481
+ "minor",
482
+ "npm",
483
+ "p",
484
+ "patch",
485
+ "plan",
486
+ "publish",
487
+ "push",
488
+ "t",
489
+ "target",
490
+ "targets",
491
+ "test",
492
+ ];
493
+ if (
494
+ forbiddenKeys.some((keyArg) =>
495
+ Object.prototype.hasOwnProperty.call(argvArg, keyArg),
496
+ )
497
+ ) {
498
+ throw new Error(
499
+ "release resume rejects fresh-release target, integration, build, test, and version overrides.",
500
+ );
501
+ }
502
+ if (argvArg._?.length !== 3) {
503
+ throw new Error(
504
+ "Usage: gitzone release resume <version> [-y] [--recover-attempt=<id>]",
505
+ );
506
+ }
507
+ };
508
+
509
+ const runReleaseResume = async (
510
+ argvArg: any,
511
+ modeArg: ICliMode,
512
+ ): Promise<void> => {
513
+ assertResumeArguments(argvArg);
514
+ if (modeArg.json) {
515
+ throw new Error("JSON output is read-only. Use release inspect --json.");
516
+ }
517
+ const recoveryAttemptId =
518
+ argvArg["recover-attempt"] || argvArg.recoverAttempt;
519
+ if (
520
+ recoveryAttemptId !== undefined &&
521
+ (typeof recoveryAttemptId !== "string" ||
522
+ !/^[0-9a-f]{32}$/.test(recoveryAttemptId))
523
+ ) {
524
+ throw new Error("--recover-attempt requires one exact 32-character owner ID.");
525
+ }
526
+ const version = normalizeReleaseVersion(argvArg._[2]);
527
+ const smartshellInstance = new plugins.smartshell.Smartshell({
528
+ executor: "bash",
529
+ sourceFilePaths: [],
530
+ });
531
+ const store = new ReleaseJournalStore(
532
+ await resolveGitCommonDirectory(smartshellInstance, paths.cwd),
533
+ );
534
+ const journal = await store.read(version);
535
+ const gitEnabled = journal.git.state !== "skipped";
536
+ const resumeConfiguration = await resolveReleaseResumeConfiguration({
537
+ git: gitEnabled,
538
+ npm: journal.npm.registries.length > 0,
539
+ });
540
+ await assertResumeConfiguration(resumeConfiguration, journal, paths.cwd);
541
+ if (journal.artifact) {
542
+ await assertPnpmReleaseCapability(smartshellInstance, paths.cwd);
543
+ await assertNoLegacyNpmPublisher(paths.cwd);
544
+ await verifyStoredNpmArtifact(store.getArtifactPath(version), journal.artifact);
116
545
  }
117
546
 
118
- printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
119
- if ([...gitResults, ...npmResults, ...dockerResults].some((result) => result.status === "failed")) {
120
- process.exit(1);
547
+ const branchContext = await inspectReleaseBranch({
548
+ smartshell: smartshellInstance,
549
+ cwd: paths.cwd,
550
+ gitRemote: journal.git.remote || resumeConfiguration.gitRemote,
551
+ gitTargetActive: gitEnabled,
552
+ pushBranch: true,
553
+ pushTags: true,
554
+ mergeRequested: false,
555
+ planMode: false,
556
+ });
557
+ if (branchContext.sourceOid !== journal.release.mainOid) {
558
+ throw new Error("Resume must run from the journaled release commit on main.");
559
+ }
560
+ const refs = await captureReleaseRefs(
561
+ smartshellInstance,
562
+ paths.cwd,
563
+ version,
564
+ journal.release.mainOid,
565
+ );
566
+ if (refs.tagOid !== journal.release.tagOid) {
567
+ throw new Error("The local release tag no longer matches the journal.");
568
+ }
569
+ const pushUrl = gitEnabled ? branchContext.pushUrl : undefined;
570
+ if (
571
+ gitEnabled &&
572
+ (!pushUrl || hashReleaseDestination(pushUrl) !== journal.git.destinationHash)
573
+ ) {
574
+ throw new Error("The Git push destination changed after journal creation.");
121
575
  }
576
+
577
+ if (!modeArg.yes) {
578
+ if (!modeArg.interactive) {
579
+ throw new Error("Release resume requires an interactive terminal or -y.");
580
+ }
581
+ const confirmed =
582
+ await plugins.smartinteract.SmartInteract.getCliConfirmation(
583
+ `Resume release v${version} now?`,
584
+ true,
585
+ );
586
+ if (!confirmed) {
587
+ logger.log("info", "Release resume cancelled.");
588
+ return;
589
+ }
590
+ }
591
+
592
+ const ensurePublicationState = async (
593
+ expectedRemoteMainOidArg?: string,
594
+ ): Promise<void> => {
595
+ await verifyReleaseRefs(smartshellInstance, paths.cwd, version, refs);
596
+ await revalidateReleasePublicationState(
597
+ smartshellInstance,
598
+ branchContext,
599
+ journal.release.mainOid,
600
+ expectedRemoteMainOidArg,
601
+ );
602
+ };
603
+ const finalJournal = await executeReleasePublication({
604
+ smartshell: smartshellInstance,
605
+ cwd: paths.cwd,
606
+ store,
607
+ journal,
608
+ pushUrl,
609
+ recoveryAttemptId,
610
+ ensurePublicationState,
611
+ });
612
+ printReleaseJournalSummary(finalJournal);
122
613
  };
123
614
 
124
- function resolveVersionType(argvArg: any, pendingBlock: string): commitHelpers.VersionType {
615
+ const assertResumeConfiguration = async (
616
+ workflowArg: IResolvedReleaseResumeConfiguration,
617
+ journalArg: IReleaseJournal,
618
+ cwdArg: string,
619
+ ): Promise<void> => {
620
+ if (
621
+ journalArg.git.state !== "skipped" &&
622
+ workflowArg.gitRemote !== journalArg.git.remote
623
+ ) {
624
+ throw new Error("Current Git release remote does not match the journal.");
625
+ }
626
+ const currentRegistries = workflowArg.npmRegistries.map(
627
+ normalizeNpmRegistryUrl,
628
+ );
629
+ if (
630
+ journalArg.npm.registries.length > 0 &&
631
+ (JSON.stringify(currentRegistries) !==
632
+ JSON.stringify(
633
+ journalArg.npm.registries.map((registryArg) => registryArg.registry),
634
+ ) ||
635
+ workflowArg.npmAccessLevel !== journalArg.npm.access ||
636
+ workflowArg.npmAlreadyPublished !== journalArg.npm.alreadyPublished)
637
+ ) {
638
+ throw new Error("Current npm release configuration does not match the journal.");
639
+ }
640
+ if (journalArg.git.state !== "skipped" || journalArg.artifact) {
641
+ await assertNoLegacyNpmPublisher(cwdArg);
642
+ }
643
+ };
644
+
645
+ const printReleaseJournalSummary = (journalArg: IReleaseJournal): void => {
646
+ console.log("");
647
+ console.log(`Release v${journalArg.release.version}`);
648
+ console.log(`journal revision: ${journalArg.revision}`);
649
+ console.log(
650
+ `artifact: ${journalArg.artifact ? `${journalArg.artifact.integrity} (${journalArg.artifact.sha256})` : "none"}`,
651
+ );
652
+ console.log(`git: ${journalArg.git.state}`);
653
+ for (const registry of journalArg.npm.registries) {
654
+ console.log(`npm ${registry.registry}: ${registry.state}`);
655
+ }
656
+ console.log(`completed: ${journalArg.completedAt || "no"}`);
657
+ console.log("");
658
+ };
659
+
660
+ function resolveVersionType(
661
+ argvArg: any,
662
+ pendingBlock: string,
663
+ ): commitHelpers.VersionType {
125
664
  if (argvArg.major) return "major";
126
665
  if (argvArg.minor) return "minor";
127
666
  if (argvArg.patch) return "patch";
@@ -130,51 +669,71 @@ function resolveVersionType(argvArg: any, pendingBlock: string): commitHelpers.V
130
669
 
131
670
  async function runCommandStep(
132
671
  smartshellInstance: plugins.smartshell.Smartshell,
672
+ cwdArg: string,
133
673
  label: string,
134
674
  command: string,
135
675
  ): Promise<void> {
136
676
  console.log(`\n${label}`);
137
- const result = await smartshellInstance.exec(command);
677
+ const result = await smartshellInstance.exec(command, { cwd: cwdArg });
138
678
  if (result.exitCode !== 0) {
139
- logger.log("error", `${label} failed. Aborting release.`);
140
- process.exit(1);
679
+ throw new Error(`${label} failed. Aborting release.`);
141
680
  }
142
681
  logger.log("success", `${label} passed.`);
143
682
  }
144
683
 
145
684
  async function verifyCleanTree(
146
685
  smartshellInstance: plugins.smartshell.Smartshell,
686
+ cwdArg: string,
147
687
  errorMessage: string,
148
688
  ): Promise<void> {
149
- const statusResult = await smartshellInstance.exec("git status --porcelain");
689
+ const statusResult = await smartshellInstance.execSpawn(
690
+ "git",
691
+ ["status", "--porcelain"],
692
+ {
693
+ cwd: cwdArg,
694
+ env: releaseGitEnv,
695
+ timeout: 60_000,
696
+ timeoutKillGraceMs: 5_000,
697
+ },
698
+ );
150
699
  if (statusResult.stdout.trim() !== "") {
151
- logger.log("error", errorMessage);
152
- console.log(statusResult.stdout);
153
- process.exit(1);
700
+ throw new Error(errorMessage);
154
701
  }
155
702
  }
156
703
 
157
704
  async function runVersionStep(
158
705
  projectType: commitHelpers.ProjectType,
159
706
  versionType: commitHelpers.VersionType,
707
+ cwdArg: string,
160
708
  ): Promise<string> {
161
- const currentVersion = await commitHelpers.readCurrentVersion(projectType);
162
- const newVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
709
+ const currentVersion = await commitHelpers.readCurrentVersion(
710
+ projectType,
711
+ cwdArg,
712
+ );
713
+ const newVersion = commitHelpers.calculateNewVersion(
714
+ currentVersion,
715
+ versionType,
716
+ );
163
717
  logger.log("info", `Bumping version: ${currentVersion} -> ${newVersion}`);
164
718
 
165
- const commitInfo = new plugins.commitinfo.CommitInfo(paths.cwd, versionType);
719
+ const commitInfo = new plugins.commitinfo.CommitInfo(cwdArg, versionType);
166
720
  await commitInfo.writeIntoPotentialDirs();
167
- await commitHelpers.updateProjectVersionFiles(projectType, newVersion);
721
+ await commitHelpers.updateProjectVersionFiles(
722
+ projectType,
723
+ newVersion,
724
+ cwdArg,
725
+ );
168
726
  return newVersion;
169
727
  }
170
728
 
171
729
  async function runChangelogStep(
172
730
  workflow: IResolvedReleaseWorkflow,
173
731
  newVersion: string,
732
+ cwdArg: string,
174
733
  ): Promise<void> {
175
734
  const dateString = new Date().toISOString().slice(0, 10);
176
735
  await movePendingToVersion(
177
- plugins.path.join(paths.cwd, workflow.changelogFile),
736
+ plugins.path.join(cwdArg, workflow.changelogFile),
178
737
  workflow.changelogPendingSection,
179
738
  workflow.changelogVersionHeading,
180
739
  newVersion,
@@ -185,149 +744,172 @@ async function runChangelogStep(
185
744
  async function runReleaseCommitStep(
186
745
  smartshellInstance: plugins.smartshell.Smartshell,
187
746
  newVersion: string,
188
- ): Promise<void> {
189
- await smartshellInstance.exec("git add -A");
190
- const result = await smartshellInstance.exec(`git commit -m ${shellQuote(`v${newVersion}`)}`);
747
+ cwdArg: string,
748
+ ): Promise<string> {
749
+ const addResult = await smartshellInstance.execSpawn("git", ["add", "-A"], {
750
+ cwd: cwdArg,
751
+ env: releaseGitEnv,
752
+ timeout: 60_000,
753
+ timeoutKillGraceMs: 5_000,
754
+ });
755
+ if (addResult.exitCode !== 0) {
756
+ throw new Error("Staging release metadata failed.");
757
+ }
758
+ const result = await smartshellInstance.execSpawn(
759
+ "git",
760
+ ["commit", "-m", `v${newVersion}`],
761
+ {
762
+ cwd: cwdArg,
763
+ env: releaseGitEnv,
764
+ timeout: 60_000,
765
+ timeoutKillGraceMs: 5_000,
766
+ },
767
+ );
191
768
  if (result.exitCode !== 0) {
192
- logger.log("error", "Release commit failed.");
193
- process.exit(1);
769
+ throw new Error("Release commit failed.");
770
+ }
771
+ const branchResult = await smartshellInstance.execSpawn(
772
+ "git",
773
+ ["symbolic-ref", "--quiet", "HEAD"],
774
+ {
775
+ cwd: cwdArg,
776
+ env: releaseGitEnv,
777
+ silent: true,
778
+ timeout: 60_000,
779
+ timeoutKillGraceMs: 5_000,
780
+ },
781
+ );
782
+ if (
783
+ branchResult.exitCode !== 0 ||
784
+ branchResult.stdout.trim() !== "refs/heads/main"
785
+ ) {
786
+ throw new Error("The release commit was not created on main.");
787
+ }
788
+ const headOid = await resolveGitObjectOid(
789
+ smartshellInstance,
790
+ cwdArg,
791
+ "HEAD^{commit}",
792
+ );
793
+ const mainOid = await resolveGitObjectOid(
794
+ smartshellInstance,
795
+ cwdArg,
796
+ "refs/heads/main^{commit}",
797
+ );
798
+ if (headOid !== mainOid) {
799
+ throw new Error("HEAD and main diverged after creating the release commit.");
194
800
  }
801
+ return mainOid;
195
802
  }
196
803
 
197
804
  async function runTagStep(
198
805
  smartshellInstance: plugins.smartshell.Smartshell,
199
806
  newVersion: string,
807
+ releaseCommitOidArg: string,
808
+ cwdArg: string,
200
809
  ): Promise<void> {
201
- const result = await smartshellInstance.exec(`git tag v${newVersion} -m ${shellQuote(`v${newVersion}`)}`);
810
+ const result = await smartshellInstance.execSpawn(
811
+ "git",
812
+ [
813
+ "tag",
814
+ "-a",
815
+ `v${newVersion}`,
816
+ "-m",
817
+ `v${newVersion}`,
818
+ releaseCommitOidArg,
819
+ ],
820
+ {
821
+ cwd: cwdArg,
822
+ env: releaseGitEnv,
823
+ timeout: 60_000,
824
+ timeoutKillGraceMs: 5_000,
825
+ },
826
+ );
202
827
  if (result.exitCode !== 0) {
203
- logger.log("error", "Release tag failed.");
204
- process.exit(1);
828
+ throw new Error("Release tag failed.");
205
829
  }
206
830
  }
207
831
 
208
- async function runGitTarget(
209
- smartshellInstance: plugins.smartshell.Smartshell,
210
- workflow: IResolvedReleaseWorkflow,
211
- ): Promise<ITargetResult[]> {
212
- const currentBranchResult = await smartshellInstance.exec("git branch --show-current");
213
- const currentBranch = currentBranchResult.stdout.trim() || "master";
214
- const commands: Array<{ target: string; command: string }> = [];
215
- if (workflow.pushBranch) {
216
- commands.push({
217
- target: `${workflow.gitRemote}/${currentBranch}`,
218
- command: `git push ${workflow.gitRemote} ${currentBranch}`,
219
- });
220
- }
221
- if (workflow.pushTags) {
222
- commands.push({
223
- target: `${workflow.gitRemote}/tags`,
224
- command: `git push ${workflow.gitRemote} --tags`,
225
- });
226
- }
227
-
228
- const results: ITargetResult[] = [];
229
- for (const { target, command } of commands) {
230
- const result = await smartshellInstance.exec(command);
231
- results.push({
232
- target,
233
- status: result.exitCode === 0 ? "success" : "failed",
234
- message: result.exitCode === 0 ? undefined : "push failed",
235
- });
236
- }
237
- return results;
832
+ interface IReleaseRefs {
833
+ mainOid: string;
834
+ tagOid: string;
238
835
  }
239
836
 
240
- async function runNpmTarget(
241
- smartshellInstance: plugins.smartshell.Smartshell,
242
- workflow: IResolvedReleaseWorkflow,
243
- ): Promise<ITargetResult[]> {
244
- if (!workflow.npmEnabled) {
245
- return [{ target: "npm", status: "skipped", message: "disabled" }];
246
- }
247
- if (workflow.npmRegistries.length === 0) {
248
- return [{ target: "npm", status: "failed", message: "no registries configured" }];
249
- }
250
-
251
- const results: ITargetResult[] = [];
252
- for (const registry of workflow.npmRegistries) {
253
- const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
254
- const result = await smartshellInstance.exec(command);
255
- const output = result.combinedOutput;
256
- if (result.exitCode === 0) {
257
- results.push({ target: registry, status: "success" });
258
- } else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") {
259
- results.push({ target: registry, status: "already-published" });
260
- } else {
261
- results.push({ target: registry, status: "failed", message: firstMeaningfulLine(output) });
262
- }
837
+ const resolveGitObjectOid = async (
838
+ smartshellInstanceArg: plugins.smartshell.Smartshell,
839
+ cwdArg: string,
840
+ revisionArg: string,
841
+ ): Promise<string> => {
842
+ const result = await smartshellInstanceArg.execSpawn(
843
+ "git",
844
+ ["rev-parse", "--verify", revisionArg],
845
+ {
846
+ cwd: cwdArg,
847
+ env: releaseGitEnv,
848
+ silent: true,
849
+ timeout: 60_000,
850
+ timeoutKillGraceMs: 5_000,
851
+ },
852
+ );
853
+ const oid = result.stdout.trim();
854
+ if (result.exitCode !== 0 || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) {
855
+ throw new Error(`Unable to resolve release Git object ${revisionArg}.`);
263
856
  }
264
- return results;
265
- }
857
+ return oid;
858
+ };
266
859
 
267
- async function runDockerTarget(
268
- smartshellInstance: plugins.smartshell.Smartshell,
269
- workflow: IResolvedReleaseWorkflow,
270
- ): Promise<ITargetResult[]> {
271
- if (!workflow.dockerEnabled) {
272
- return [{ target: "docker", status: "skipped", message: "disabled" }];
860
+ const captureReleaseRefs = async (
861
+ smartshellInstanceArg: plugins.smartshell.Smartshell,
862
+ cwdArg: string,
863
+ newVersionArg: string,
864
+ expectedMainOidArg?: string,
865
+ ): Promise<IReleaseRefs> => {
866
+ const mainOid = await resolveGitObjectOid(
867
+ smartshellInstanceArg,
868
+ cwdArg,
869
+ "refs/heads/main^{commit}",
870
+ );
871
+ const tagOid = await resolveGitObjectOid(
872
+ smartshellInstanceArg,
873
+ cwdArg,
874
+ `refs/tags/v${newVersionArg}^{tag}`,
875
+ );
876
+ const tagCommitOid = await resolveGitObjectOid(
877
+ smartshellInstanceArg,
878
+ cwdArg,
879
+ `refs/tags/v${newVersionArg}^{commit}`,
880
+ );
881
+ if (
882
+ (expectedMainOidArg && mainOid !== expectedMainOidArg) ||
883
+ tagCommitOid !== mainOid
884
+ ) {
885
+ throw new Error(
886
+ "The release tag is not bound to the captured main release commit.",
887
+ );
273
888
  }
889
+ return { mainOid, tagOid };
890
+ };
274
891
 
275
- let packageJson: unknown;
276
- try {
277
- packageJson = JSON.parse(
278
- await plugins.fs.readFile(plugins.path.join(paths.cwd, "package.json"), "utf8"),
892
+ const verifyReleaseRefs = async (
893
+ smartshellInstanceArg: plugins.smartshell.Smartshell,
894
+ cwdArg: string,
895
+ newVersionArg: string,
896
+ expectedRefsArg: IReleaseRefs,
897
+ ): Promise<void> => {
898
+ const currentRefs = await captureReleaseRefs(
899
+ smartshellInstanceArg,
900
+ cwdArg,
901
+ newVersionArg,
902
+ expectedRefsArg.mainOid,
903
+ );
904
+ if (
905
+ currentRefs.mainOid !== expectedRefsArg.mainOid ||
906
+ currentRefs.tagOid !== expectedRefsArg.tagOid
907
+ ) {
908
+ throw new Error(
909
+ "Local release refs changed before publication. Inspect main and the release tag before retrying.",
279
910
  );
280
- } catch {
281
- packageJson = undefined;
282
- }
283
- if (!getDeclaredTsdockerRange(packageJson)) {
284
- return [{
285
- target: "tsdocker",
286
- status: "failed",
287
- message: "Docker releases require @git.zone/tsdocker as a project dependency",
288
- }];
289
- }
290
- if (!(await hasInstalledProjectTsdockerBinary(paths.cwd))) {
291
- return [{
292
- target: "tsdocker",
293
- status: "failed",
294
- message: "Docker releases require the installed project-local tSDocker binary",
295
- }];
296
- }
297
-
298
- const minimumTsdockerVersion = getTsdockerMinimumVersion(
299
- workflow.dockerBuildRegistries.length > 0,
300
- workflow.dockerTest,
301
- );
302
- if (minimumTsdockerVersion) {
303
- const versionResult = await smartshellInstance.exec("pnpm exec tsdocker --version");
304
- if (
305
- versionResult.exitCode !== 0
306
- || !supportsTsdockerMinimumVersion(
307
- versionResult.combinedOutput,
308
- minimumTsdockerVersion,
309
- )
310
- ) {
311
- const minimumVersion = formatTsdockerMinimumVersion(minimumTsdockerVersion);
312
- return [{
313
- target: "tsdocker",
314
- status: "failed",
315
- message: `Docker release options require project-local @git.zone/tsdocker >= ${minimumVersion}`,
316
- }];
317
- }
318
911
  }
319
-
320
- const command = buildTsdockerPushCommand(workflow);
321
- const result = await smartshellInstance.exec(command);
322
- const output = result.combinedOutput;
323
- return [{
324
- target: workflow.dockerPatterns.length > 0
325
- ? `tsdocker:${workflow.dockerPatterns.join(",")}`
326
- : "tsdocker",
327
- status: result.exitCode === 0 ? "success" : "failed",
328
- message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output),
329
- }];
330
- }
912
+ };
331
913
 
332
914
  type TDockerCommandWorkflow = Pick<
333
915
  IResolvedReleaseWorkflow,
@@ -341,7 +923,9 @@ type TDockerCommandWorkflow = Pick<
341
923
  | "dockerPatterns"
342
924
  >;
343
925
 
344
- export function buildTsdockerPushCommand(workflow: TDockerCommandWorkflow): string {
926
+ export function buildTsdockerPushCommand(
927
+ workflow: TDockerCommandWorkflow,
928
+ ): string {
345
929
  const commandParts = ["pnpm", "exec", "tsdocker", "push"];
346
930
  for (const pattern of workflow.dockerPatterns) {
347
931
  commandParts.push(shellQuote(pattern));
@@ -350,7 +934,9 @@ export function buildTsdockerPushCommand(workflow: TDockerCommandWorkflow): stri
350
934
  commandParts.push(`--registry=${shellQuote(workflow.dockerRegistry)}`);
351
935
  }
352
936
  if (workflow.dockerBuildRegistries.length > 0) {
353
- commandParts.push(`--build-registries=${shellQuote(workflow.dockerBuildRegistries.join(","))}`);
937
+ commandParts.push(
938
+ `--build-registries=${shellQuote(workflow.dockerBuildRegistries.join(","))}`,
939
+ );
354
940
  }
355
941
  if (workflow.dockerTest) {
356
942
  commandParts.push("--test");
@@ -363,7 +949,11 @@ export function buildTsdockerPushCommand(workflow: TDockerCommandWorkflow): stri
363
949
  }
364
950
  if (workflow.dockerParallel === true) {
365
951
  commandParts.push("--parallel");
366
- } else if (typeof workflow.dockerParallel === "number" && Number.isFinite(workflow.dockerParallel) && workflow.dockerParallel > 0) {
952
+ } else if (
953
+ typeof workflow.dockerParallel === "number" &&
954
+ Number.isFinite(workflow.dockerParallel) &&
955
+ workflow.dockerParallel > 0
956
+ ) {
367
957
  commandParts.push(`--parallel=${Math.floor(workflow.dockerParallel)}`);
368
958
  }
369
959
  if (workflow.dockerContext) {
@@ -372,37 +962,50 @@ export function buildTsdockerPushCommand(workflow: TDockerCommandWorkflow): stri
372
962
  return commandParts.join(" ");
373
963
  }
374
964
 
375
- function isAlreadyPublishedOutput(output: string): boolean {
376
- return /previously published versions|cannot publish over|already exists/i.test(output);
377
- }
378
-
379
- function firstMeaningfulLine(output: string): string {
380
- return output
381
- .split("\n")
382
- .map((line) => line.trim())
383
- .find((line) => line.length > 0) || "command failed";
384
- }
385
-
386
965
  function shellQuote(value: string): string {
387
966
  return `'${value.replaceAll("'", "'\\''")}'`;
388
967
  }
389
968
 
390
- function printReleasePlan(workflow: IResolvedReleaseWorkflow): void {
969
+ function printReleasePlan(
970
+ workflow: IResolvedReleaseWorkflow,
971
+ branchContextArg: IReleaseBranchContext,
972
+ ): void {
391
973
  console.log("");
392
974
  console.log("gitzone release - resolved workflow");
393
975
  console.log(`confirmation: ${workflow.confirmation}`);
394
976
  console.log(`plan: ${workflow.plan.join(" -> ")}`);
395
- console.log(`targets: ${workflow.targets.length > 0 ? workflow.targets.join(", ") : "none"}`);
396
- console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogPendingSection}`);
977
+ console.log(`source branch: ${branchContextArg.sourceBranch}`);
978
+ if (branchContextArg.sourceBranch !== "main") {
979
+ console.log("release branch: main (fast-forward integration requested)");
980
+ console.log(
981
+ `remote main ancestry: ${branchContextArg.planAncestryVerified ? "verified" : "deferred until execution"}`,
982
+ );
983
+ }
984
+ console.log(
985
+ `targets: ${workflow.targets.length > 0 ? workflow.targets.join(", ") : "none"}`,
986
+ );
987
+ console.log(
988
+ `changelog: ${workflow.changelogFile}#${workflow.changelogPendingSection}`,
989
+ );
397
990
  if (workflow.targets.includes("npm")) {
398
- console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`);
991
+ console.log(
992
+ `npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`,
993
+ );
399
994
  }
400
995
  if (workflow.targets.includes("docker")) {
401
996
  console.log(`docker engine: ${workflow.dockerEngine}`);
402
- console.log(`docker registry: ${workflow.dockerRegistry || "all configured registries"}`);
403
- console.log(`docker build authentication: ${workflow.dockerBuildRegistries.length > 0 ? workflow.dockerBuildRegistries.join(", ") : "all configured registries"}`);
404
- console.log(`docker image tests: ${workflow.dockerTest ? "required before destination publication" : "not requested"}`);
405
- console.log(`docker patterns: ${workflow.dockerPatterns.length > 0 ? workflow.dockerPatterns.join(", ") : "all Dockerfiles"}`);
997
+ console.log(
998
+ `docker registry: ${workflow.dockerRegistry || "all configured registries"}`,
999
+ );
1000
+ console.log(
1001
+ `docker build authentication: ${workflow.dockerBuildRegistries.length > 0 ? workflow.dockerBuildRegistries.join(", ") : "all configured registries"}`,
1002
+ );
1003
+ console.log(
1004
+ `docker image tests: ${workflow.dockerTest ? "required before destination publication" : "not requested"}`,
1005
+ );
1006
+ console.log(
1007
+ `docker patterns: ${workflow.dockerPatterns.length > 0 ? workflow.dockerPatterns.join(", ") : "all Dockerfiles"}`,
1008
+ );
406
1009
  console.log(`docker options: ${formatDockerOptions(workflow)}`);
407
1010
  }
408
1011
  console.log("");
@@ -410,86 +1013,127 @@ function printReleasePlan(workflow: IResolvedReleaseWorkflow): void {
410
1013
 
411
1014
  function formatDockerOptions(workflow: IResolvedReleaseWorkflow): string {
412
1015
  const options: string[] = [];
413
- if (workflow.dockerRegistry) options.push(`registry=${workflow.dockerRegistry}`);
1016
+ if (workflow.dockerRegistry)
1017
+ options.push(`registry=${workflow.dockerRegistry}`);
414
1018
  if (workflow.dockerBuildRegistries.length > 0) {
415
1019
  options.push(`buildRegistries=${workflow.dockerBuildRegistries.join(",")}`);
416
1020
  }
417
1021
  if (workflow.dockerTest) options.push("test");
418
1022
  if (workflow.dockerCached) options.push("cached");
419
- if (workflow.dockerParallel) options.push(`parallel=${workflow.dockerParallel === true ? "true" : workflow.dockerParallel}`);
1023
+ if (workflow.dockerParallel)
1024
+ options.push(
1025
+ `parallel=${workflow.dockerParallel === true ? "true" : workflow.dockerParallel}`,
1026
+ );
420
1027
  if (workflow.dockerNoBuild) options.push("no-build");
421
1028
  if (workflow.dockerContext) options.push(`context=${workflow.dockerContext}`);
422
1029
  return options.length > 0 ? options.join(", ") : "default";
423
1030
  }
424
1031
 
425
- function printReleaseSummary(
426
- newVersion: string,
427
- gitResults: ITargetResult[],
428
- npmResults: ITargetResult[],
429
- dockerResults: ITargetResult[],
430
- ): void {
431
- console.log("");
432
- console.log(`Release v${newVersion}`);
433
- console.log("");
434
-
435
- if (gitResults.length > 0) {
436
- console.log("git:");
437
- for (const result of gitResults) {
438
- console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
439
- }
440
- }
441
-
442
- if (npmResults.length > 0) {
443
- console.log("npm:");
444
- for (const result of npmResults) {
445
- console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
446
- }
447
- }
448
-
449
- if (dockerResults.length > 0) {
450
- console.log("docker:");
451
- for (const result of dockerResults) {
452
- console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
453
- }
454
- }
455
- }
456
-
457
1032
  export function showHelp(mode?: ICliMode): void {
458
1033
  if (mode?.json) {
459
1034
  printJson({
460
1035
  command: "release",
461
- usage: "gitzone release [options]",
462
- description: "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
1036
+ usage: "gitzone release [inspect [version]|resume <version>] [options]",
1037
+ description:
1038
+ "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
463
1039
  flags: [
464
- { flag: "-y, --yes", description: "Run without interactive confirmation" },
1040
+ {
1041
+ flag: "-y, --yes",
1042
+ description: "Run without interactive confirmation",
1043
+ },
465
1044
  { flag: "-t, --test", description: "Enable release preflight tests" },
466
- { flag: "-b, --build", description: "Enable release preflight build" },
467
- { flag: "-p, --push", description: "Enable the git release target" },
468
- { flag: "--target <names>", description: "Release only selected targets: git,npm,docker" },
469
- { flag: "--npm", description: "Enable the npm release target" },
470
- { flag: "--docker", description: "Enable the tsdocker release target" },
471
- { flag: "--no-publish", description: "Run release core and git target only" },
472
- { flag: "--plan", description: "Show resolved workflow without mutating files" },
1045
+ {
1046
+ flag: "-b, --build",
1047
+ description:
1048
+ "Enable the build after local release metadata is created",
1049
+ },
1050
+ {
1051
+ flag: "--no-build",
1052
+ description: "Disable the post-metadata release build",
1053
+ },
1054
+ { flag: "-p, --push", description: "Explicitly select the git target" },
1055
+ {
1056
+ flag: "--target <names>",
1057
+ description:
1058
+ "Replace configured targets with a non-empty subset of git,npm,docker",
1059
+ },
1060
+ { flag: "--npm", description: "Explicitly select the npm target" },
1061
+ {
1062
+ flag: "--docker",
1063
+ description: "Select Docker and fail closed under journal schema v1",
1064
+ },
1065
+ {
1066
+ flag: "--no-publish",
1067
+ description: "Remove npm and Docker without implicitly enabling Git",
1068
+ },
1069
+ {
1070
+ flag: "--merge",
1071
+ description:
1072
+ "Fast-forward and lease-push a cleanly rebased feature branch before release metadata",
1073
+ },
1074
+ {
1075
+ flag: "--plan",
1076
+ description:
1077
+ "Show the workflow without fetching or mutating refs, files, the index, or worktrees",
1078
+ },
1079
+ {
1080
+ flag: "inspect [version]",
1081
+ description: "Read one or all durable release journals without mutation",
1082
+ },
1083
+ {
1084
+ flag: "resume <version>",
1085
+ description: "Resume exact Git/npm publication without rebuilding or repacking",
1086
+ },
1087
+ {
1088
+ flag: "--recover-attempt <id>",
1089
+ description:
1090
+ "Recover one exact 32-character hexadecimal attempt ID after proving it stopped",
1091
+ },
473
1092
  ],
474
1093
  });
475
1094
  return;
476
1095
  }
477
1096
 
478
1097
  console.log("");
479
- console.log("Usage: gitzone release [options]");
1098
+ console.log(
1099
+ "Usage: gitzone release [inspect [version]|resume <version>] [options]",
1100
+ );
480
1101
  console.log("");
481
1102
  console.log("Creates a versioned release from changelog Pending entries.");
482
1103
  console.log("");
483
1104
  console.log("Flags:");
484
1105
  console.log(" -y, --yes Run without interactive confirmation");
485
1106
  console.log(" -t, --test Enable release preflight tests");
486
- console.log(" -b, --build Enable release preflight build");
487
- console.log(" -p, --push Enable the git release target");
488
- console.log(" --target <names> Release only selected targets: git,npm,docker");
489
- console.log(" --npm Enable the npm release target");
490
- console.log(" --docker Enable the tsdocker release target");
491
- console.log(" --no-publish Run release core and git target only");
1107
+ console.log(
1108
+ " -b, --build Build after creating local release metadata",
1109
+ );
1110
+ console.log(" --no-build Disable the post-metadata release build");
1111
+ console.log(" -p, --push Explicitly select the git target");
1112
+ console.log(
1113
+ " --target <names> Replace configured targets with a non-empty subset of git,npm,docker",
1114
+ );
1115
+ console.log(" --npm Explicitly select the npm target");
1116
+ console.log(
1117
+ " --docker Select Docker (fails closed under journal schema v1)",
1118
+ );
1119
+ console.log(
1120
+ " --no-publish Remove npm and Docker without implicitly enabling Git",
1121
+ );
1122
+ console.log(
1123
+ " --merge Fast-forward and lease-push a cleanly rebased feature branch before release metadata",
1124
+ );
492
1125
  console.log(" --major|--minor|--patch Override inferred semver level");
493
- console.log(" --plan Show resolved workflow without mutating files");
1126
+ console.log(
1127
+ " --plan Show workflow without fetching or mutating refs, files, index, or worktrees",
1128
+ );
1129
+ console.log(
1130
+ " inspect [version] Read one or all durable release journals without mutation",
1131
+ );
1132
+ console.log(
1133
+ " resume <version> Resume exact Git/npm publication without rebuilding or repacking",
1134
+ );
1135
+ console.log(
1136
+ " --recover-attempt Recover one exact 32-character hexadecimal attempt ID after proving its publisher stopped",
1137
+ );
494
1138
  console.log("");
495
1139
  }