@git.zone/cli 5.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 (29) hide show
  1. package/assets/templates/ci_default/.gitea/workflows/default_tags.yaml +0 -21
  2. package/assets/templates/ci_default_gitlab/.gitlab-ci.yml +0 -13
  3. package/assets/templates/ci_default_private/.gitea/workflows/default_tags.yaml +0 -21
  4. package/assets/templates/ci_default_private_gitlab/.gitlab-ci.yml +0 -13
  5. package/dist_ts/00_commitinfo_data.js +1 -1
  6. package/dist_ts/helpers.climode.js +31 -2
  7. package/dist_ts/helpers.workflow.d.ts +10 -0
  8. package/dist_ts/helpers.workflow.js +80 -8
  9. package/dist_ts/mod_release/classes.releasejournal.d.ts +78 -0
  10. package/dist_ts/mod_release/classes.releasejournal.js +511 -0
  11. package/dist_ts/mod_release/helpers.npmartifact.d.ts +32 -0
  12. package/dist_ts/mod_release/helpers.npmartifact.js +358 -0
  13. package/dist_ts/mod_release/helpers.releasebranch.d.ts +1 -0
  14. package/dist_ts/mod_release/helpers.releasebranch.js +54 -6
  15. package/dist_ts/mod_release/helpers.releasepublication.d.ts +24 -0
  16. package/dist_ts/mod_release/helpers.releasepublication.js +293 -0
  17. package/dist_ts/mod_release/index.d.ts +1 -11
  18. package/dist_ts/mod_release/index.js +410 -288
  19. package/package.json +1 -1
  20. package/readme.hints.md +47 -1
  21. package/readme.md +61 -26
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/helpers.climode.ts +34 -1
  24. package/ts/helpers.workflow.ts +114 -7
  25. package/ts/mod_release/classes.releasejournal.ts +740 -0
  26. package/ts/mod_release/helpers.npmartifact.ts +553 -0
  27. package/ts/mod_release/helpers.releasebranch.ts +67 -5
  28. package/ts/mod_release/helpers.releasepublication.ts +641 -0
  29. package/ts/mod_release/index.ts +576 -411
@@ -9,15 +9,12 @@ 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";
21
18
  import {
22
19
  inspectReleaseBranch,
23
20
  integrateReleaseBranch,
@@ -28,16 +25,38 @@ import {
28
25
  resolveReleaseMergeFlag,
29
26
  type IReleaseBranchContext,
30
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";
31
47
 
32
- type TTargetStatus = "success" | "already-published" | "skipped" | "failed";
48
+ export { buildReleaseGitPushArgs } from "./helpers.releasepublication.js";
33
49
 
34
- interface ITargetResult {
35
- target: string;
36
- status: TTargetStatus;
37
- message?: string;
38
- }
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
+ };
39
58
 
40
- export const run = async (argvArg: any) => {
59
+ const runInternal = async (argvArg: any): Promise<void> => {
41
60
  const mode = await getCliMode(argvArg);
42
61
  const subcommand = argvArg._?.[1];
43
62
 
@@ -46,6 +65,18 @@ export const run = async (argvArg: any) => {
46
65
  return;
47
66
  }
48
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
+
49
80
  if (mode.json) {
50
81
  printJson({
51
82
  ok: false,
@@ -59,44 +90,37 @@ export const run = async (argvArg: any) => {
59
90
  executor: "bash",
60
91
  sourceFilePaths: [],
61
92
  });
62
- let mergeRequested: boolean;
63
- try {
64
- mergeRequested = resolveReleaseMergeFlag(argvArg);
65
- } catch (error) {
66
- logger.log("error", error instanceof Error ? error.message : String(error));
67
- process.exit(1);
68
- return;
69
- }
93
+ const mergeRequested = resolveReleaseMergeFlag(argvArg);
70
94
  const workflow = await resolveReleaseWorkflow(argvArg);
71
- let branchContext: IReleaseBranchContext;
72
- try {
73
- branchContext = await inspectReleaseBranch({
74
- smartshell: smartshellInstance,
75
- cwd: paths.cwd,
76
- gitRemote: workflow.gitRemote,
77
- gitTargetActive: workflow.targets.includes("git"),
78
- pushBranch: workflow.pushBranch,
79
- pushTags: workflow.pushTags,
80
- mergeRequested,
81
- planMode: workflow.confirmation === "plan",
82
- });
83
- } catch (error) {
84
- logger.log("error", error instanceof Error ? error.message : String(error));
85
- process.exit(1);
86
- return;
87
- }
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
+ });
88
106
  printReleasePlan(workflow, branchContext);
89
107
  if (workflow.confirmation === "plan") {
90
108
  return;
91
109
  }
92
110
 
111
+ await assertFreshReleaseCapabilities(
112
+ smartshellInstance,
113
+ workflow,
114
+ branchContext.sourceCwd,
115
+ branchContext.pushUrl,
116
+ );
117
+
93
118
  const pending = await readPendingChangelog(
94
119
  plugins.path.join(branchContext.sourceCwd, workflow.changelogFile),
95
120
  workflow.changelogPendingSection,
96
121
  );
97
122
  if (pending.isEmpty && !argvArg["allow-empty"] && !argvArg.allowEmpty) {
98
- logger.log("error", "No pending changelog entries. Nothing to release.");
99
- process.exit(1);
123
+ throw new Error("No pending changelog entries. Nothing to release.");
100
124
  }
101
125
 
102
126
  const versionType = resolveVersionType(argvArg, pending.block);
@@ -130,20 +154,10 @@ export const run = async (argvArg: any) => {
130
154
  }
131
155
 
132
156
  let newVersion = plannedVersion;
133
- const gitResults: ITargetResult[] = [];
134
- const npmResults: ITargetResult[] = [];
135
- const dockerResults: ITargetResult[] = [];
136
-
137
- try {
138
- branchContext = await prepareReleaseIntegration(
139
- smartshellInstance,
140
- branchContext,
141
- );
142
- } catch (error) {
143
- logger.log("error", error instanceof Error ? error.message : String(error));
144
- process.exit(1);
145
- return;
146
- }
157
+ branchContext = await prepareReleaseIntegration(
158
+ smartshellInstance,
159
+ branchContext,
160
+ );
147
161
  if (workflow.runTests) {
148
162
  await runCommandStep(
149
163
  smartshellInstance,
@@ -157,33 +171,15 @@ export const run = async (argvArg: any) => {
157
171
  let releasePushUrl = branchContext.pushUrl;
158
172
  let expectedRemoteMainOid = branchContext.remoteMainOid;
159
173
  if (branchContext.sourceBranch === "main") {
160
- try {
161
- await revalidatePreparedReleaseBranch(smartshellInstance, branchContext);
162
- } catch (error) {
163
- logger.log(
164
- "error",
165
- error instanceof Error ? error.message : String(error),
166
- );
167
- process.exit(1);
168
- return;
169
- }
174
+ await revalidatePreparedReleaseBranch(smartshellInstance, branchContext);
170
175
  } else {
171
- try {
172
- const integration = await integrateReleaseBranch(
173
- smartshellInstance,
174
- branchContext,
175
- );
176
- releaseCwd = integration.releaseCwd;
177
- releasePushUrl = integration.pushUrl;
178
- expectedRemoteMainOid = integration.expectedRemoteMainOid;
179
- } catch (error) {
180
- logger.log(
181
- "error",
182
- error instanceof Error ? error.message : String(error),
183
- );
184
- process.exit(1);
185
- return;
186
- }
176
+ const integration = await integrateReleaseBranch(
177
+ smartshellInstance,
178
+ branchContext,
179
+ );
180
+ releaseCwd = integration.releaseCwd;
181
+ releasePushUrl = integration.pushUrl;
182
+ expectedRemoteMainOid = integration.expectedRemoteMainOid;
187
183
  }
188
184
 
189
185
  newVersion = await runVersionStep(projectType, versionType, releaseCwd);
@@ -220,81 +216,447 @@ export const run = async (argvArg: any) => {
220
216
  );
221
217
  }
222
218
 
223
- const ensurePublicationState = async (): Promise<boolean> => {
224
- try {
225
- await verifyReleaseRefs(
226
- smartshellInstance,
227
- releaseCwd,
228
- newVersion,
229
- releaseRefs,
230
- );
231
- await revalidateReleasePublicationState(
232
- smartshellInstance,
233
- branchContext,
234
- releaseRefs.mainOid,
235
- expectedRemoteMainOid,
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
+ );
268
+ }
269
+ if (workflowArg.targets.includes("git") && !pushUrlArg) {
270
+ throw new Error("The Git release target requires one resolved push destination.");
271
+ }
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,
236
365
  );
237
- return true;
238
- } catch (error) {
239
- logger.log(
240
- "error",
241
- error instanceof Error ? error.message : String(error),
366
+ await verifyCleanTree(
367
+ optionsArg.smartshell,
368
+ optionsArg.releaseCwd,
369
+ "npm pack lifecycle scripts changed the release worktree. Aborting release.",
242
370
  );
243
- process.exit(1);
244
- return false;
371
+ await optionsArg.ensurePublicationState();
245
372
  }
246
- };
247
373
 
248
- if (!(await ensurePublicationState())) {
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 });
249
460
  return;
250
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
+ };
251
470
 
252
- if (workflow.targets.includes("git")) {
253
- gitResults.push(
254
- ...(await runGitTarget(
255
- smartshellInstance,
256
- workflow,
257
- releaseCwd,
258
- newVersion,
259
- releaseRefs,
260
- releasePushUrl,
261
- expectedRemoteMainOid,
262
- )),
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.",
263
500
  );
264
- if (gitResults.some((result) => result.status === "failed")) {
265
- printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
266
- process.exit(1);
267
- return;
268
- }
269
- expectedRemoteMainOid = releaseRefs.mainOid;
270
501
  }
271
- if (workflow.targets.includes("npm")) {
272
- if (!(await ensurePublicationState())) {
273
- return;
274
- }
275
- npmResults.push(
276
- ...(await runNpmTarget(smartshellInstance, workflow, releaseCwd)),
502
+ if (argvArg._?.length !== 3) {
503
+ throw new Error(
504
+ "Usage: gitzone release resume <version> [-y] [--recover-attempt=<id>]",
277
505
  );
278
506
  }
279
- if (workflow.targets.includes("docker")) {
280
- if (!(await ensurePublicationState())) {
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);
545
+ }
546
+
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.");
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.");
281
588
  return;
282
589
  }
283
- dockerResults.push(
284
- ...(await runDockerTarget(smartshellInstance, workflow, releaseCwd)),
285
- );
286
590
  }
287
591
 
288
- printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
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);
613
+ };
614
+
615
+ const assertResumeConfiguration = async (
616
+ workflowArg: IResolvedReleaseResumeConfiguration,
617
+ journalArg: IReleaseJournal,
618
+ cwdArg: string,
619
+ ): Promise<void> => {
289
620
  if (
290
- [...gitResults, ...npmResults, ...dockerResults].some(
291
- (result) => result.status === "failed",
292
- )
621
+ journalArg.git.state !== "skipped" &&
622
+ workflowArg.gitRemote !== journalArg.git.remote
293
623
  ) {
294
- process.exit(1);
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);
295
642
  }
296
643
  };
297
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
+
298
660
  function resolveVersionType(
299
661
  argvArg: any,
300
662
  pendingBlock: string,
@@ -314,8 +676,7 @@ async function runCommandStep(
314
676
  console.log(`\n${label}`);
315
677
  const result = await smartshellInstance.exec(command, { cwd: cwdArg });
316
678
  if (result.exitCode !== 0) {
317
- logger.log("error", `${label} failed. Aborting release.`);
318
- process.exit(1);
679
+ throw new Error(`${label} failed. Aborting release.`);
319
680
  }
320
681
  logger.log("success", `${label} passed.`);
321
682
  }
@@ -328,12 +689,15 @@ async function verifyCleanTree(
328
689
  const statusResult = await smartshellInstance.execSpawn(
329
690
  "git",
330
691
  ["status", "--porcelain"],
331
- { cwd: cwdArg, env: releaseGitEnv },
692
+ {
693
+ cwd: cwdArg,
694
+ env: releaseGitEnv,
695
+ timeout: 60_000,
696
+ timeoutKillGraceMs: 5_000,
697
+ },
332
698
  );
333
699
  if (statusResult.stdout.trim() !== "") {
334
- logger.log("error", errorMessage);
335
- console.log(statusResult.stdout);
336
- process.exit(1);
700
+ throw new Error(errorMessage);
337
701
  }
338
702
  }
339
703
 
@@ -385,6 +749,8 @@ async function runReleaseCommitStep(
385
749
  const addResult = await smartshellInstance.execSpawn("git", ["add", "-A"], {
386
750
  cwd: cwdArg,
387
751
  env: releaseGitEnv,
752
+ timeout: 60_000,
753
+ timeoutKillGraceMs: 5_000,
388
754
  });
389
755
  if (addResult.exitCode !== 0) {
390
756
  throw new Error("Staging release metadata failed.");
@@ -392,7 +758,12 @@ async function runReleaseCommitStep(
392
758
  const result = await smartshellInstance.execSpawn(
393
759
  "git",
394
760
  ["commit", "-m", `v${newVersion}`],
395
- { cwd: cwdArg, env: releaseGitEnv },
761
+ {
762
+ cwd: cwdArg,
763
+ env: releaseGitEnv,
764
+ timeout: 60_000,
765
+ timeoutKillGraceMs: 5_000,
766
+ },
396
767
  );
397
768
  if (result.exitCode !== 0) {
398
769
  throw new Error("Release commit failed.");
@@ -400,7 +771,13 @@ async function runReleaseCommitStep(
400
771
  const branchResult = await smartshellInstance.execSpawn(
401
772
  "git",
402
773
  ["symbolic-ref", "--quiet", "HEAD"],
403
- { cwd: cwdArg, env: releaseGitEnv, silent: true },
774
+ {
775
+ cwd: cwdArg,
776
+ env: releaseGitEnv,
777
+ silent: true,
778
+ timeout: 60_000,
779
+ timeoutKillGraceMs: 5_000,
780
+ },
404
781
  );
405
782
  if (
406
783
  branchResult.exitCode !== 0 ||
@@ -440,7 +817,12 @@ async function runTagStep(
440
817
  `v${newVersion}`,
441
818
  releaseCommitOidArg,
442
819
  ],
443
- { cwd: cwdArg, env: releaseGitEnv },
820
+ {
821
+ cwd: cwdArg,
822
+ env: releaseGitEnv,
823
+ timeout: 60_000,
824
+ timeoutKillGraceMs: 5_000,
825
+ },
444
826
  );
445
827
  if (result.exitCode !== 0) {
446
828
  throw new Error("Release tag failed.");
@@ -460,7 +842,13 @@ const resolveGitObjectOid = async (
460
842
  const result = await smartshellInstanceArg.execSpawn(
461
843
  "git",
462
844
  ["rev-parse", "--verify", revisionArg],
463
- { cwd: cwdArg, env: releaseGitEnv, silent: true },
845
+ {
846
+ cwd: cwdArg,
847
+ env: releaseGitEnv,
848
+ silent: true,
849
+ timeout: 60_000,
850
+ timeoutKillGraceMs: 5_000,
851
+ },
464
852
  );
465
853
  const oid = result.stdout.trim();
466
854
  if (result.exitCode !== 0 || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) {
@@ -523,208 +911,6 @@ const verifyReleaseRefs = async (
523
911
  }
524
912
  };
525
913
 
526
- async function runGitTarget(
527
- smartshellInstance: plugins.smartshell.Smartshell,
528
- workflow: IResolvedReleaseWorkflow,
529
- cwdArg: string,
530
- newVersionArg: string,
531
- releaseRefsArg: IReleaseRefs,
532
- pushUrlArg?: string,
533
- expectedRemoteMainOidArg?: string,
534
- ): Promise<ITargetResult[]> {
535
- const destination = pushUrlArg || workflow.gitRemote;
536
- const targets: string[] = [];
537
- const refspecs: string[] = [];
538
- if (workflow.pushBranch) {
539
- targets.push(`${workflow.gitRemote}/main`);
540
- refspecs.push("refs/heads/main:refs/heads/main");
541
- }
542
- if (workflow.pushTags) {
543
- targets.push(`${workflow.gitRemote}/v${newVersionArg}`);
544
- refspecs.push(`refs/tags/v${newVersionArg}:refs/tags/v${newVersionArg}`);
545
- }
546
- if (refspecs.length === 0) {
547
- return [];
548
- }
549
-
550
- const args = buildReleaseGitPushArgs({
551
- destination,
552
- newVersion: newVersionArg,
553
- pushBranch: workflow.pushBranch,
554
- pushTags: workflow.pushTags,
555
- mainOid: releaseRefsArg.mainOid,
556
- tagOid: releaseRefsArg.tagOid,
557
- expectedRemoteMainOid: expectedRemoteMainOidArg,
558
- });
559
- const result = await smartshellInstance.execSpawn("git", args, {
560
- cwd: cwdArg,
561
- env: releaseGitEnv,
562
- });
563
- return targets.map((target) => ({
564
- target,
565
- status: result.exitCode === 0 ? "success" : "failed",
566
- message:
567
- result.exitCode === 0
568
- ? undefined
569
- : firstMeaningfulLine(result.combinedOutput),
570
- }));
571
- }
572
-
573
- export interface IBuildReleaseGitPushArgsOptions {
574
- destination: string;
575
- newVersion: string;
576
- pushBranch: boolean;
577
- pushTags: boolean;
578
- mainOid: string;
579
- tagOid: string;
580
- expectedRemoteMainOid?: string;
581
- }
582
-
583
- export function buildReleaseGitPushArgs(
584
- optionsArg: IBuildReleaseGitPushArgsOptions,
585
- ): string[] {
586
- const refspecs: string[] = [];
587
- if (optionsArg.pushBranch) {
588
- refspecs.push(`${optionsArg.mainOid}:refs/heads/main`);
589
- }
590
- if (optionsArg.pushTags) {
591
- refspecs.push(`${optionsArg.tagOid}:refs/tags/v${optionsArg.newVersion}`);
592
- }
593
- const args = ["push", "--no-follow-tags"];
594
- if (refspecs.length > 1) {
595
- args.push("--atomic");
596
- }
597
- if (optionsArg.pushBranch && optionsArg.expectedRemoteMainOid) {
598
- args.push(
599
- `--force-with-lease=refs/heads/main:${optionsArg.expectedRemoteMainOid}`,
600
- );
601
- }
602
- args.push(optionsArg.destination, ...refspecs);
603
- return args;
604
- }
605
-
606
- async function runNpmTarget(
607
- smartshellInstance: plugins.smartshell.Smartshell,
608
- workflow: IResolvedReleaseWorkflow,
609
- cwdArg: string,
610
- ): Promise<ITargetResult[]> {
611
- if (!workflow.npmEnabled) {
612
- return [{ target: "npm", status: "skipped", message: "disabled" }];
613
- }
614
- if (workflow.npmRegistries.length === 0) {
615
- return [
616
- { target: "npm", status: "failed", message: "no registries configured" },
617
- ];
618
- }
619
-
620
- const results: ITargetResult[] = [];
621
- for (const registry of workflow.npmRegistries) {
622
- const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
623
- const result = await smartshellInstance.exec(command, { cwd: cwdArg });
624
- const output = result.combinedOutput;
625
- if (result.exitCode === 0) {
626
- results.push({ target: registry, status: "success" });
627
- } else if (
628
- isAlreadyPublishedOutput(output) &&
629
- workflow.npmAlreadyPublished === "success"
630
- ) {
631
- results.push({ target: registry, status: "already-published" });
632
- } else {
633
- results.push({
634
- target: registry,
635
- status: "failed",
636
- message: firstMeaningfulLine(output),
637
- });
638
- }
639
- }
640
- return results;
641
- }
642
-
643
- async function runDockerTarget(
644
- smartshellInstance: plugins.smartshell.Smartshell,
645
- workflow: IResolvedReleaseWorkflow,
646
- cwdArg: string,
647
- ): Promise<ITargetResult[]> {
648
- if (!workflow.dockerEnabled) {
649
- return [{ target: "docker", status: "skipped", message: "disabled" }];
650
- }
651
-
652
- let packageJson: unknown;
653
- try {
654
- packageJson = JSON.parse(
655
- await plugins.fs.readFile(
656
- plugins.path.join(cwdArg, "package.json"),
657
- "utf8",
658
- ),
659
- );
660
- } catch {
661
- packageJson = undefined;
662
- }
663
- if (!getDeclaredTsdockerRange(packageJson)) {
664
- return [
665
- {
666
- target: "tsdocker",
667
- status: "failed",
668
- message:
669
- "Docker releases require @git.zone/tsdocker as a project dependency",
670
- },
671
- ];
672
- }
673
- if (!(await hasInstalledProjectTsdockerBinary(cwdArg))) {
674
- return [
675
- {
676
- target: "tsdocker",
677
- status: "failed",
678
- message:
679
- "Docker releases require the installed project-local tSDocker binary",
680
- },
681
- ];
682
- }
683
-
684
- const minimumTsdockerVersion = getTsdockerMinimumVersion(
685
- workflow.dockerBuildRegistries.length > 0,
686
- workflow.dockerTest,
687
- );
688
- if (minimumTsdockerVersion) {
689
- const versionResult = await smartshellInstance.exec(
690
- "pnpm exec tsdocker --version",
691
- { cwd: cwdArg },
692
- );
693
- if (
694
- versionResult.exitCode !== 0 ||
695
- !supportsTsdockerMinimumVersion(
696
- versionResult.combinedOutput,
697
- minimumTsdockerVersion,
698
- )
699
- ) {
700
- const minimumVersion = formatTsdockerMinimumVersion(
701
- minimumTsdockerVersion,
702
- );
703
- return [
704
- {
705
- target: "tsdocker",
706
- status: "failed",
707
- message: `Docker release options require project-local @git.zone/tsdocker >= ${minimumVersion}`,
708
- },
709
- ];
710
- }
711
- }
712
-
713
- const command = buildTsdockerPushCommand(workflow);
714
- const result = await smartshellInstance.exec(command, { cwd: cwdArg });
715
- const output = result.combinedOutput;
716
- return [
717
- {
718
- target:
719
- workflow.dockerPatterns.length > 0
720
- ? `tsdocker:${workflow.dockerPatterns.join(",")}`
721
- : "tsdocker",
722
- status: result.exitCode === 0 ? "success" : "failed",
723
- message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output),
724
- },
725
- ];
726
- }
727
-
728
914
  type TDockerCommandWorkflow = Pick<
729
915
  IResolvedReleaseWorkflow,
730
916
  | "dockerRegistry"
@@ -776,21 +962,6 @@ export function buildTsdockerPushCommand(
776
962
  return commandParts.join(" ");
777
963
  }
778
964
 
779
- function isAlreadyPublishedOutput(output: string): boolean {
780
- return /previously published versions|cannot publish over|already exists/i.test(
781
- output,
782
- );
783
- }
784
-
785
- function firstMeaningfulLine(output: string): string {
786
- return (
787
- output
788
- .split("\n")
789
- .map((line) => line.trim())
790
- .find((line) => line.length > 0) || "command failed"
791
- );
792
- }
793
-
794
965
  function shellQuote(value: string): string {
795
966
  return `'${value.replaceAll("'", "'\\''")}'`;
796
967
  }
@@ -858,49 +1029,11 @@ function formatDockerOptions(workflow: IResolvedReleaseWorkflow): string {
858
1029
  return options.length > 0 ? options.join(", ") : "default";
859
1030
  }
860
1031
 
861
- function printReleaseSummary(
862
- newVersion: string,
863
- gitResults: ITargetResult[],
864
- npmResults: ITargetResult[],
865
- dockerResults: ITargetResult[],
866
- ): void {
867
- console.log("");
868
- console.log(`Release v${newVersion}`);
869
- console.log("");
870
-
871
- if (gitResults.length > 0) {
872
- console.log("git:");
873
- for (const result of gitResults) {
874
- console.log(
875
- ` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`,
876
- );
877
- }
878
- }
879
-
880
- if (npmResults.length > 0) {
881
- console.log("npm:");
882
- for (const result of npmResults) {
883
- console.log(
884
- ` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`,
885
- );
886
- }
887
- }
888
-
889
- if (dockerResults.length > 0) {
890
- console.log("docker:");
891
- for (const result of dockerResults) {
892
- console.log(
893
- ` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`,
894
- );
895
- }
896
- }
897
- }
898
-
899
1032
  export function showHelp(mode?: ICliMode): void {
900
1033
  if (mode?.json) {
901
1034
  printJson({
902
1035
  command: "release",
903
- usage: "gitzone release [options]",
1036
+ usage: "gitzone release [inspect [version]|resume <version>] [options]",
904
1037
  description:
905
1038
  "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
906
1039
  flags: [
@@ -918,16 +1051,20 @@ export function showHelp(mode?: ICliMode): void {
918
1051
  flag: "--no-build",
919
1052
  description: "Disable the post-metadata release build",
920
1053
  },
921
- { flag: "-p, --push", description: "Enable the git release target" },
1054
+ { flag: "-p, --push", description: "Explicitly select the git target" },
922
1055
  {
923
1056
  flag: "--target <names>",
924
- description: "Release only selected targets: git,npm,docker",
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",
925
1064
  },
926
- { flag: "--npm", description: "Enable the npm release target" },
927
- { flag: "--docker", description: "Enable the tsdocker release target" },
928
1065
  {
929
1066
  flag: "--no-publish",
930
- description: "Run release core and git target only",
1067
+ description: "Remove npm and Docker without implicitly enabling Git",
931
1068
  },
932
1069
  {
933
1070
  flag: "--merge",
@@ -939,13 +1076,28 @@ export function showHelp(mode?: ICliMode): void {
939
1076
  description:
940
1077
  "Show the workflow without fetching or mutating refs, files, the index, or worktrees",
941
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
+ },
942
1092
  ],
943
1093
  });
944
1094
  return;
945
1095
  }
946
1096
 
947
1097
  console.log("");
948
- console.log("Usage: gitzone release [options]");
1098
+ console.log(
1099
+ "Usage: gitzone release [inspect [version]|resume <version>] [options]",
1100
+ );
949
1101
  console.log("");
950
1102
  console.log("Creates a versioned release from changelog Pending entries.");
951
1103
  console.log("");
@@ -956,13 +1108,17 @@ export function showHelp(mode?: ICliMode): void {
956
1108
  " -b, --build Build after creating local release metadata",
957
1109
  );
958
1110
  console.log(" --no-build Disable the post-metadata release build");
959
- console.log(" -p, --push Enable the git release target");
1111
+ console.log(" -p, --push Explicitly select the git target");
960
1112
  console.log(
961
- " --target <names> Release only selected targets: git,npm,docker",
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",
962
1121
  );
963
- console.log(" --npm Enable the npm release target");
964
- console.log(" --docker Enable the tsdocker release target");
965
- console.log(" --no-publish Run release core and git target only");
966
1122
  console.log(
967
1123
  " --merge Fast-forward and lease-push a cleanly rebased feature branch before release metadata",
968
1124
  );
@@ -970,5 +1126,14 @@ export function showHelp(mode?: ICliMode): void {
970
1126
  console.log(
971
1127
  " --plan Show workflow without fetching or mutating refs, files, index, or worktrees",
972
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
+ );
973
1138
  console.log("");
974
1139
  }