@git.zone/cli 6.7.2 → 6.8.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.
@@ -0,0 +1,308 @@
1
+ import * as plugins from "./mod.plugins.js";
2
+ import type { IResolvedReleaseWorkflow } from "../helpers.workflow.js";
3
+ import { readPendingChangelog } from "../helpers.changelog.js";
4
+ import { InterProcessLock } from "../mod_services/classes.interprocesslock.js";
5
+ import { ReleaseJournalStore } from "./classes.releasejournal.js";
6
+ import {
7
+ releaseGitEnv,
8
+ type IReleaseBranchContext,
9
+ } from "./helpers.releasebranch.js";
10
+ import {
11
+ assertNpmVersionAbsent,
12
+ assertNpmPackageName,
13
+ hashReleaseDestination,
14
+ } from "./helpers.npmartifact.js";
15
+ import { canonicalReleaseJson } from "./helpers.tsdockerprotocol.js";
16
+
17
+ interface IRecoveryRefs {
18
+ mainOid: string;
19
+ tagOid: string;
20
+ }
21
+
22
+ export interface IReleasePreparationRecovery {
23
+ smartshell: plugins.smartshell.Smartshell;
24
+ workflow: IResolvedReleaseWorkflow;
25
+ branch: IReleaseBranchContext;
26
+ refs: IRecoveryRefs;
27
+ version: string;
28
+ store: ReleaseJournalStore;
29
+ }
30
+
31
+ const git = async (
32
+ options: IReleasePreparationRecovery,
33
+ args: string[],
34
+ cwd = options.branch.sourceCwd,
35
+ ) => {
36
+ const result = await options.smartshell.execSpawn("git", args, {
37
+ cwd,
38
+ env: releaseGitEnv,
39
+ silent: true,
40
+ timeout: 60_000,
41
+ timeoutKillGraceMs: 5_000,
42
+ });
43
+ if (result.exitCode !== 0)
44
+ throw new Error(`Release recovery Git validation failed: ${args[0]}.`);
45
+ return result.stdout.trim();
46
+ };
47
+
48
+ export const assertPackageReleaseRecovery = async (
49
+ options: IReleasePreparationRecovery,
50
+ ): Promise<string> => {
51
+ const { workflow, branch, version } = options;
52
+ if (
53
+ workflow.targets.length !== 2 ||
54
+ !workflow.targets.includes("git") ||
55
+ !workflow.targets.includes("npm") ||
56
+ workflow.npmPackageSource !== "root" ||
57
+ !workflow.pushBranch ||
58
+ !workflow.pushTags ||
59
+ branch.sourceBranch !== "main" ||
60
+ !branch.pushUrl ||
61
+ !branch.remoteMainOid
62
+ ) {
63
+ throw new Error(
64
+ "Release recover requires configured Git and root npm targets from clean main.",
65
+ );
66
+ }
67
+ const manifest = JSON.parse(
68
+ await plugins.fs.readFile(
69
+ plugins.path.join(branch.sourceCwd, "package.json"),
70
+ "utf8",
71
+ ),
72
+ );
73
+ if (manifest.version !== version || typeof manifest.name !== "string") {
74
+ throw new Error(
75
+ "Recovery package identity does not match the existing release version.",
76
+ );
77
+ }
78
+ assertNpmPackageName(manifest.name);
79
+ const pending = await readPendingChangelog(
80
+ plugins.path.join(branch.sourceCwd, workflow.changelogFile),
81
+ workflow.changelogPendingSection,
82
+ );
83
+ const changelog = await plugins.fs.readFile(
84
+ plugins.path.join(branch.sourceCwd, workflow.changelogFile),
85
+ "utf8",
86
+ );
87
+ const headings = changelog
88
+ .split(/\r?\n/)
89
+ .filter(
90
+ (line) =>
91
+ /^##\s/.test(line) && line !== `## ${workflow.changelogPendingSection}`,
92
+ );
93
+ if (
94
+ !pending.isEmpty ||
95
+ !new RegExp(
96
+ `^## \\d{4}-\\d{2}-\\d{2} - ${version.replace(/\./g, "\\.")}\\s*$`,
97
+ ).test(headings[0] ?? "")
98
+ ) {
99
+ throw new Error(
100
+ "Recovery requires the completed changelog section for this version and no Pending changes.",
101
+ );
102
+ }
103
+ if (
104
+ (await git(options, [
105
+ "show",
106
+ "-s",
107
+ "--format=%s",
108
+ options.refs.mainOid,
109
+ ])) !== `v${version}` ||
110
+ (
111
+ await git(options, [
112
+ "rev-list",
113
+ "--parents",
114
+ "-n",
115
+ "1",
116
+ options.refs.mainOid,
117
+ ])
118
+ ).split(" ").length !== 2
119
+ ) {
120
+ throw new Error("Recovery requires an existing non-merge release commit.");
121
+ }
122
+ if (
123
+ await git(options, [
124
+ "diff",
125
+ `${options.refs.mainOid}^`,
126
+ options.refs.mainOid,
127
+ "--",
128
+ ".smartconfig.json",
129
+ ])
130
+ ) {
131
+ throw new Error(
132
+ "Release configuration changed inside the release metadata commit.",
133
+ );
134
+ }
135
+ return manifest.name;
136
+ };
137
+
138
+ export const assertRecoveryUnpublished = async (
139
+ options: IReleasePreparationRecovery,
140
+ packageName: string,
141
+ ): Promise<void> => {
142
+ const output = await git(options, [
143
+ "ls-remote",
144
+ options.branch.pushUrl!,
145
+ `refs/tags/v${options.version}`,
146
+ `refs/tags/v${options.version}^{}`,
147
+ ]);
148
+ if (output)
149
+ throw new Error(
150
+ "Recovery cannot adopt an existing remote release tag. Use its original journal.",
151
+ );
152
+ for (const registry of options.workflow.npmRegistries)
153
+ await assertNpmVersionAbsent(registry, packageName, options.version);
154
+ };
155
+
156
+ const preparationBinding = (options: IReleasePreparationRecovery) => ({
157
+ kind: "gitzone-package-release-preparation",
158
+ schemaVersion: 1,
159
+ version: options.version,
160
+ ...options.refs,
161
+ destinationHash: hashReleaseDestination(options.branch.pushUrl!),
162
+ expectedRemoteMainOid: options.branch.remoteMainOid,
163
+ // Capture resolved configuration, including build/test commands, so retries
164
+ // cannot silently prepare a different release.
165
+ workflow: JSON.parse(
166
+ JSON.stringify({ ...options.workflow, confirmation: "auto" }),
167
+ ),
168
+ });
169
+
170
+ const bindPreparation = async (
171
+ path: string,
172
+ expected: string,
173
+ ): Promise<void> => {
174
+ try {
175
+ const stat = await plugins.fs.lstat(path);
176
+ if (
177
+ !stat.isFile() ||
178
+ stat.isSymbolicLink() ||
179
+ stat.size > 128 * 1024 ||
180
+ (await plugins.fs.readFile(path, "utf8")) !== expected
181
+ ) {
182
+ throw new Error(
183
+ "Release recovery identity or configuration differs from its original preparation.",
184
+ );
185
+ }
186
+ } catch (error) {
187
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
188
+ const handle = await plugins.fs.open(path, "wx", 0o600);
189
+ try {
190
+ await handle.writeFile(expected, "utf8");
191
+ await handle.sync();
192
+ } finally {
193
+ await handle.close();
194
+ }
195
+ const directory = await plugins.fs.open(plugins.path.dirname(path), "r");
196
+ try {
197
+ await directory.sync();
198
+ } finally {
199
+ await directory.close();
200
+ }
201
+ }
202
+ };
203
+
204
+ /** Build and seal one unpublished, already tagged package without changing refs. */
205
+ export const withReleasePreparationRecovery = async <T>(
206
+ options: IReleasePreparationRecovery,
207
+ ensureState: () => Promise<void>,
208
+ prepare: (
209
+ checkout: string,
210
+ ensureCheckout: () => Promise<void>,
211
+ ) => Promise<T>,
212
+ ): Promise<T> => {
213
+ if (!options.branch.planAncestryVerified) {
214
+ throw new Error(
215
+ "Release recovery requires verified remote ancestry before preparation.",
216
+ );
217
+ }
218
+ const directory = options.store.getReleaseDirectory(options.version);
219
+ return new InterProcessLock({
220
+ lockPath: `${directory}.prepare.lock`,
221
+ description: `Release v${options.version} preparation`,
222
+ }).runExclusive(async () => {
223
+ await ensureState();
224
+ try {
225
+ await plugins.fs.lstat(directory);
226
+ throw new Error(
227
+ "Release journal already exists. Use release resume; do not rebuild its artifact.",
228
+ );
229
+ } catch (error) {
230
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
231
+ }
232
+ await bindPreparation(
233
+ `${directory}.preparation.json`,
234
+ `${canonicalReleaseJson(preparationBinding(options))}\n`,
235
+ );
236
+ const root = await plugins.fs.mkdtemp(
237
+ plugins.path.join(plugins.os.tmpdir(), "gitzone-release-prepare-"),
238
+ );
239
+ const checkout = plugins.path.join(root, "source");
240
+ let added = false;
241
+ try {
242
+ await git(options, [
243
+ "worktree",
244
+ "add",
245
+ "--detach",
246
+ checkout,
247
+ options.refs.mainOid,
248
+ ]);
249
+ added = true;
250
+ const install = await options.smartshell.execSpawn(
251
+ "pnpm",
252
+ ["install", "--frozen-lockfile"],
253
+ {
254
+ cwd: checkout,
255
+ timeout: 10 * 60_000,
256
+ timeoutKillGraceMs: 5_000,
257
+ },
258
+ );
259
+ if (install.exitCode !== 0)
260
+ throw new Error("Release recovery dependency installation failed.");
261
+ if (options.workflow.runTests)
262
+ await run(options, checkout, options.workflow.testCommand, "tests");
263
+ if (options.workflow.runBuild)
264
+ await run(options, checkout, options.workflow.buildCommand, "build");
265
+ await assertCheckout(options, checkout);
266
+ await ensureState();
267
+ const result = await prepare(checkout, () =>
268
+ assertCheckout(options, checkout),
269
+ );
270
+ await assertCheckout(options, checkout);
271
+ return result;
272
+ } finally {
273
+ // This path is a uniquely owned disposable checkout, never the user's
274
+ // source tree or runtime data. Keep registration evidence if cleanup fails.
275
+ if (added)
276
+ await git(options, ["worktree", "remove", "--force", checkout]);
277
+ await plugins.fs.rm(root, { recursive: true, force: true });
278
+ }
279
+ });
280
+ };
281
+
282
+ const run = async (
283
+ options: IReleasePreparationRecovery,
284
+ cwd: string,
285
+ command: string,
286
+ phase: string,
287
+ ) => {
288
+ const result = await options.smartshell.exec(command, { cwd });
289
+ if (result.exitCode !== 0)
290
+ throw new Error(`Release recovery ${phase} failed.`);
291
+ };
292
+
293
+ const assertCheckout = async (
294
+ options: IReleasePreparationRecovery,
295
+ checkout: string,
296
+ ) => {
297
+ if (
298
+ (await git(options, ["rev-parse", "HEAD"], checkout)) !==
299
+ options.refs.mainOid ||
300
+ (await git(
301
+ options,
302
+ ["status", "--porcelain", "--untracked-files=all"],
303
+ checkout,
304
+ ))
305
+ ) {
306
+ throw new Error("Release recovery build changed its exact tracked source.");
307
+ }
308
+ };
@@ -51,6 +51,11 @@ import {
51
51
  verifyStoredNpmArtifact,
52
52
  } from "./helpers.npmartifact.js";
53
53
  import { executeReleasePublication } from "./helpers.releasepublication.js";
54
+ import {
55
+ assertPackageReleaseRecovery,
56
+ assertRecoveryUnpublished,
57
+ withReleasePreparationRecovery,
58
+ } from "./helpers.releasepreparation.js";
54
59
  import {
55
60
  TsdockerReleaseClient,
56
61
  canonicalReleaseJson,
@@ -87,6 +92,10 @@ const runInternal = async (argvArg: any): Promise<void> => {
87
92
  await runReleaseResume(argvArg, mode);
88
93
  return;
89
94
  }
95
+ if (subcommand === "recover") {
96
+ await runReleaseRecover(argvArg, mode);
97
+ return;
98
+ }
90
99
  if (subcommand !== undefined) {
91
100
  throw new Error(`Unknown release subcommand: ${subcommand}`);
92
101
  }
@@ -402,6 +411,8 @@ interface ICreateReleaseJournalOptions {
402
411
  smartshell: plugins.smartshell.Smartshell;
403
412
  workflow: IResolvedReleaseWorkflow;
404
413
  releaseCwd: string;
414
+ /** Exact detached checkout used only while recovering pre-journal packaging. */
415
+ npmSourceCwd?: string;
405
416
  releasePushUrl?: string;
406
417
  expectedRemoteMainOid?: string;
407
418
  version: string;
@@ -447,6 +458,7 @@ export const planReleasePackages = async (
447
458
  export const createReleaseJournal = async (
448
459
  optionsArg: ICreateReleaseJournalOptions,
449
460
  ): Promise<{ store: ReleaseJournalStore; journal: TReleaseJournal }> => {
461
+ const npmSourceCwd = optionsArg.npmSourceCwd ?? optionsArg.releaseCwd;
450
462
  const gitCommonDirectory = await resolveGitCommonDirectory(
451
463
  optionsArg.smartshell,
452
464
  optionsArg.releaseCwd,
@@ -474,7 +486,7 @@ export const createReleaseJournal = async (
474
486
  const packages: IReleasePackage[] = [];
475
487
  if (optionsArg.workflow.targets.includes("npm")) {
476
488
  if (optionsArg.workflow.npmPackageSource === "tspublish") {
477
- const plan = await planReleasePackages(optionsArg.releaseCwd);
489
+ const plan = await planReleasePackages(npmSourceCwd);
478
490
  if (plan.version !== optionsArg.version)
479
491
  throw new Error("tspublish plan version differs from the release.");
480
492
  const outputDirectory = plugins.path.join(
@@ -482,7 +494,7 @@ export const createReleaseJournal = async (
482
494
  "prepared",
483
495
  );
484
496
  const prepared = await new plugins.tspublish.TsPublish().prepare(
485
- optionsArg.releaseCwd,
497
+ npmSourceCwd,
486
498
  outputDirectory,
487
499
  );
488
500
  if (
@@ -509,14 +521,14 @@ export const createReleaseJournal = async (
509
521
  } else {
510
522
  artifact = await packNpmArtifact(
511
523
  optionsArg.smartshell,
512
- optionsArg.releaseCwd,
524
+ npmSourceCwd,
513
525
  temporaryDirectory,
514
526
  optionsArg.version,
515
527
  );
516
528
  }
517
529
  await verifyCleanTree(
518
530
  optionsArg.smartshell,
519
- optionsArg.releaseCwd,
531
+ npmSourceCwd,
520
532
  "npm pack lifecycle scripts changed the release worktree. Aborting release.",
521
533
  );
522
534
  await optionsArg.ensurePublicationState();
@@ -650,6 +662,153 @@ const runReleaseInspect = async (
650
662
  }
651
663
  };
652
664
 
665
+ const runReleaseRecover = async (
666
+ argvArg: any,
667
+ mode: ICliMode,
668
+ ): Promise<void> => {
669
+ const allowed = new Set([
670
+ "_",
671
+ "$0",
672
+ "y",
673
+ "yes",
674
+ "plan",
675
+ "h",
676
+ "help",
677
+ "plain",
678
+ "quiet",
679
+ "agent",
680
+ "interactive",
681
+ "check-updates",
682
+ "checkUpdates",
683
+ ]);
684
+ if (
685
+ argvArg._?.length !== 3 ||
686
+ Object.keys(argvArg).some((key) => !allowed.has(key)) ||
687
+ (argvArg.plan !== undefined && typeof argvArg.plan !== "boolean")
688
+ ) {
689
+ throw new Error(
690
+ "Usage: gitzone release recover <version> [-y|--plan]. Target, version, build and test overrides are not allowed.",
691
+ );
692
+ }
693
+ const version = normalizeReleaseVersion(argvArg._[2]);
694
+ const shell = new plugins.smartshell.Smartshell({
695
+ executor: "bash",
696
+ sourceFilePaths: [],
697
+ });
698
+ const store = new ReleaseJournalStore(
699
+ await resolveGitCommonDirectory(shell, paths.cwd),
700
+ );
701
+ const existing = await plugins.fs
702
+ .lstat(store.getReleaseDirectory(version))
703
+ .then(() => true)
704
+ .catch((error: NodeJS.ErrnoException) => {
705
+ if (error.code === "ENOENT") return false;
706
+ throw error;
707
+ });
708
+ if (existing) {
709
+ if (argvArg.plan) {
710
+ printReleaseJournalSummary(await store.read(version));
711
+ return;
712
+ }
713
+ await runReleaseResume(
714
+ { _: ["release", "resume", version], yes: mode.yes },
715
+ mode,
716
+ );
717
+ return;
718
+ }
719
+ const workflow = await resolveReleaseWorkflow({ yes: true });
720
+ assertReleaseConfiguration(workflow, false);
721
+ let branch = await inspectReleaseBranch({
722
+ smartshell: shell,
723
+ cwd: paths.cwd,
724
+ gitRemote: workflow.gitRemote,
725
+ gitTargetActive: true,
726
+ pushBranch: workflow.pushBranch,
727
+ pushTags: workflow.pushTags,
728
+ mergeRequested: false,
729
+ planMode: argvArg.plan === true,
730
+ });
731
+ const refs = await captureReleaseRefs(
732
+ shell,
733
+ paths.cwd,
734
+ version,
735
+ branch.sourceOid,
736
+ );
737
+ const options = { smartshell: shell, workflow, branch, refs, version, store };
738
+ const packageName = await assertPackageReleaseRecovery(options);
739
+ if (!branch.planAncestryVerified) {
740
+ console.log(
741
+ "Remote ancestry is deferred until execution fetches the missing commit.",
742
+ );
743
+ }
744
+ console.log(
745
+ `Recover existing ${packageName}@${version}\ncommit: ${refs.mainOid}\ntag object: ${refs.tagOid}\nGit destination: ${branch.gitRemote}\nnpm registries: ${workflow.npmRegistries.join(", ")}\nBuild and pack an isolated checkout, install its journal, then publish configured targets.`,
746
+ );
747
+ if (argvArg.plan) return;
748
+ await assertFreshReleaseCapabilities(
749
+ shell,
750
+ workflow,
751
+ paths.cwd,
752
+ branch.pushUrl,
753
+ );
754
+ if (!mode.yes) {
755
+ if (!mode.interactive)
756
+ throw new Error(
757
+ "Release recovery requires an interactive terminal or -y.",
758
+ );
759
+ if (
760
+ !(await plugins.smartinteract.SmartInteract.getCliConfirmation(
761
+ `Recover and publish existing v${version}?`,
762
+ false,
763
+ ))
764
+ )
765
+ return;
766
+ }
767
+ branch = await prepareReleaseIntegration(shell, branch);
768
+ options.branch = branch;
769
+ const ensureState = async (remote = branch.remoteMainOid) => {
770
+ await verifyReleaseRefs(shell, paths.cwd, version, refs);
771
+ await revalidateReleasePublicationState(
772
+ shell,
773
+ branch,
774
+ refs.mainOid,
775
+ remote,
776
+ );
777
+ };
778
+ const ensureUnpublished = async () => {
779
+ await ensureState();
780
+ await assertRecoveryUnpublished(options, packageName);
781
+ };
782
+ const context = await withReleasePreparationRecovery(
783
+ options,
784
+ ensureUnpublished,
785
+ async (checkout, ensureCheckout) =>
786
+ createReleaseJournal({
787
+ smartshell: shell,
788
+ workflow,
789
+ releaseCwd: paths.cwd,
790
+ npmSourceCwd: checkout,
791
+ releasePushUrl: branch.pushUrl,
792
+ expectedRemoteMainOid: branch.remoteMainOid,
793
+ version,
794
+ refs,
795
+ ensurePublicationState: async () => {
796
+ await ensureCheckout();
797
+ await ensureUnpublished();
798
+ },
799
+ }),
800
+ );
801
+ const result = await executeReleasePublication({
802
+ smartshell: shell,
803
+ cwd: paths.cwd,
804
+ store: context.store,
805
+ journal: context.journal,
806
+ pushUrl: branch.pushUrl,
807
+ ensurePublicationState: ensureState,
808
+ });
809
+ printReleaseJournalSummary(result);
810
+ };
811
+
653
812
  const assertResumeArguments = (argvArg: any): void => {
654
813
  const forbiddenKeys = [
655
814
  "allow-empty",
@@ -1284,7 +1443,8 @@ export function showHelp(mode?: ICliMode): void {
1284
1443
  if (mode?.json) {
1285
1444
  printJson({
1286
1445
  command: "release",
1287
- usage: "gitzone release [inspect [version]|resume <version>] [options]",
1446
+ usage:
1447
+ "gitzone release [inspect [version]|resume <version>|recover <version>] [options]",
1288
1448
  description:
1289
1449
  "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
1290
1450
  flags: [
@@ -1338,6 +1498,11 @@ export function showHelp(mode?: ICliMode): void {
1338
1498
  description:
1339
1499
  "Resume exact journaled publication without rebuilding or repacking npm artifacts",
1340
1500
  },
1501
+ {
1502
+ flag: "recover <version>",
1503
+ description:
1504
+ "Recover a local root npm release tag after build or pack failure before journal creation",
1505
+ },
1341
1506
  {
1342
1507
  flag: "--recover-attempt <id>",
1343
1508
  description:
@@ -1350,7 +1515,7 @@ export function showHelp(mode?: ICliMode): void {
1350
1515
 
1351
1516
  console.log("");
1352
1517
  console.log(
1353
- "Usage: gitzone release [inspect [version]|resume <version>] [options]",
1518
+ "Usage: gitzone release [inspect [version]|resume <version>|recover <version>] [options]",
1354
1519
  );
1355
1520
  console.log("");
1356
1521
  console.log("Creates a versioned release from changelog Pending entries.");
@@ -1386,6 +1551,9 @@ export function showHelp(mode?: ICliMode): void {
1386
1551
  console.log(
1387
1552
  " resume <version> Resume exact journaled publication without rebuilding or repacking npm artifacts",
1388
1553
  );
1554
+ console.log(
1555
+ " recover <version> Recover an unpublished root npm release from its existing local tag",
1556
+ );
1389
1557
  console.log(
1390
1558
  " --recover-attempt Recover one exact 32-character hexadecimal attempt ID after proving its publisher stopped",
1391
1559
  );