@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
@@ -0,0 +1,740 @@
1
+ import * as plugins from "./mod.plugins.js";
2
+ import { InterProcessLock } from "../mod_services/classes.interprocesslock.js";
3
+
4
+ export const releaseJournalSchemaVersion = 1 as const;
5
+ export const releaseArtifactFileName = "package.tgz" as const;
6
+ export const releaseJournalFileName = "journal.json" as const;
7
+
8
+ export type TReleaseTargetState =
9
+ | "pending"
10
+ | "publishing"
11
+ | "verified"
12
+ | "failed"
13
+ | "conflict"
14
+ | "skipped";
15
+
16
+ export type TReleaseErrorCode =
17
+ | "command-failed"
18
+ | "verification-inconclusive"
19
+ | "artifact-conflict"
20
+ | "already-published"
21
+ | "destination-conflict"
22
+ | "attempt-recovery-required";
23
+
24
+ export interface IReleaseAttempt {
25
+ id: string;
26
+ pid: number;
27
+ startedAt: string;
28
+ }
29
+
30
+ export interface IReleaseTargetStatus {
31
+ state: TReleaseTargetState;
32
+ attempts: number;
33
+ attempt: IReleaseAttempt | null;
34
+ error: TReleaseErrorCode | null;
35
+ }
36
+
37
+ export interface IReleaseArtifact {
38
+ kind: "npm-tarball";
39
+ file: typeof releaseArtifactFileName;
40
+ packageName: string;
41
+ version: string;
42
+ size: number;
43
+ sha1: string;
44
+ sha256: string;
45
+ integrity: string;
46
+ }
47
+
48
+ export interface IReleaseGitJournal extends IReleaseTargetStatus {
49
+ remote: string | null;
50
+ destinationHash: string | null;
51
+ expectedRemoteMainOid: string | null;
52
+ }
53
+
54
+ export interface IReleaseNpmRegistryJournal extends IReleaseTargetStatus {
55
+ registry: string;
56
+ }
57
+
58
+ export interface IReleaseJournal {
59
+ kind: "gitzone-release-journal";
60
+ schemaVersion: typeof releaseJournalSchemaVersion;
61
+ revision: number;
62
+ release: {
63
+ version: string;
64
+ tag: string;
65
+ mainOid: string;
66
+ tagOid: string;
67
+ };
68
+ artifact: IReleaseArtifact | null;
69
+ git: IReleaseGitJournal;
70
+ npm: {
71
+ access: "public";
72
+ tag: "latest";
73
+ alreadyPublished: "success" | "error";
74
+ registries: IReleaseNpmRegistryJournal[];
75
+ };
76
+ createdAt: string;
77
+ updatedAt: string;
78
+ completedAt: string | null;
79
+ }
80
+
81
+ const releaseVersionRegex = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
82
+ const gitOidRegex = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
83
+ const attemptIdRegex = /^[0-9a-f]{32}$/;
84
+ const sha1Regex = /^[0-9a-f]{40}$/;
85
+ const sha256Regex = /^[0-9a-f]{64}$/;
86
+ const sha512IntegrityRegex = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
87
+ const packageNameRegex = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
88
+ const gitRemoteNameRegex = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
89
+ const targetStates: TReleaseTargetState[] = [
90
+ "pending",
91
+ "publishing",
92
+ "verified",
93
+ "failed",
94
+ "conflict",
95
+ "skipped",
96
+ ];
97
+ const errorCodes: TReleaseErrorCode[] = [
98
+ "command-failed",
99
+ "verification-inconclusive",
100
+ "artifact-conflict",
101
+ "already-published",
102
+ "destination-conflict",
103
+ "attempt-recovery-required",
104
+ ];
105
+
106
+ const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
107
+ typeof valueArg === "object" && valueArg !== null && !Array.isArray(valueArg);
108
+
109
+ const isCanonicalRegistryUrl = (valueArg: string): boolean => {
110
+ try {
111
+ const url = new URL(valueArg);
112
+ if (
113
+ (url.protocol !== "https:" && url.protocol !== "http:") ||
114
+ url.username ||
115
+ url.password ||
116
+ url.search ||
117
+ url.hash
118
+ ) {
119
+ return false;
120
+ }
121
+ const pathname = url.pathname.replace(/\/+$/, "");
122
+ return valueArg === `${url.origin}${pathname}`;
123
+ } catch {
124
+ return false;
125
+ }
126
+ };
127
+
128
+ const assertExactKeys = (
129
+ valueArg: Record<string, unknown>,
130
+ keysArg: readonly string[],
131
+ contextArg: string,
132
+ ): void => {
133
+ const actualKeys = Object.keys(valueArg);
134
+ if (
135
+ actualKeys.length !== keysArg.length ||
136
+ actualKeys.some((keyArg, indexArg) => keyArg !== keysArg[indexArg])
137
+ ) {
138
+ throw new Error(`${contextArg} must use the exact canonical key set and order.`);
139
+ }
140
+ };
141
+
142
+ const assertIsoTimestamp = (valueArg: unknown, contextArg: string): string => {
143
+ if (
144
+ typeof valueArg !== "string" ||
145
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(valueArg) ||
146
+ new Date(valueArg).toISOString() !== valueArg
147
+ ) {
148
+ throw new Error(`${contextArg} must be a canonical UTC timestamp.`);
149
+ }
150
+ return valueArg;
151
+ };
152
+
153
+ export const normalizeReleaseVersion = (valueArg: string): string => {
154
+ const version = valueArg.startsWith("v") ? valueArg.slice(1) : valueArg;
155
+ if (!releaseVersionRegex.test(version)) {
156
+ throw new Error("Release journal versions must use canonical x.y.z semver.");
157
+ }
158
+ return version;
159
+ };
160
+
161
+ export const normalizeReleaseGitRemoteName = (valueArg: string): string => {
162
+ if (
163
+ typeof valueArg !== "string" ||
164
+ !gitRemoteNameRegex.test(valueArg) ||
165
+ valueArg.includes("..") ||
166
+ valueArg.endsWith("/")
167
+ ) {
168
+ throw new Error("Release journals require a canonical credential-free Git remote name.");
169
+ }
170
+ return valueArg;
171
+ };
172
+
173
+ const assertTargetStatus = (
174
+ valueArg: Record<string, unknown>,
175
+ contextArg: string,
176
+ ): IReleaseTargetStatus => {
177
+ if (!targetStates.includes(valueArg.state as TReleaseTargetState)) {
178
+ throw new Error(`${contextArg}.state is invalid.`);
179
+ }
180
+ if (
181
+ !Number.isSafeInteger(valueArg.attempts) ||
182
+ (valueArg.attempts as number) < 0
183
+ ) {
184
+ throw new Error(`${contextArg}.attempts must be a non-negative safe integer.`);
185
+ }
186
+ if (
187
+ valueArg.error !== null &&
188
+ !errorCodes.includes(valueArg.error as TReleaseErrorCode)
189
+ ) {
190
+ throw new Error(`${contextArg}.error is invalid.`);
191
+ }
192
+
193
+ if (valueArg.state === "publishing") {
194
+ if (!isPlainObject(valueArg.attempt)) {
195
+ throw new Error(`${contextArg}.attempt is required while publishing.`);
196
+ }
197
+ assertExactKeys(valueArg.attempt, ["id", "pid", "startedAt"], `${contextArg}.attempt`);
198
+ if (
199
+ typeof valueArg.attempt.id !== "string" ||
200
+ !attemptIdRegex.test(valueArg.attempt.id) ||
201
+ !Number.isSafeInteger(valueArg.attempt.pid) ||
202
+ (valueArg.attempt.pid as number) <= 0
203
+ ) {
204
+ throw new Error(`${contextArg}.attempt owner is invalid.`);
205
+ }
206
+ assertIsoTimestamp(valueArg.attempt.startedAt, `${contextArg}.attempt.startedAt`);
207
+ if ((valueArg.attempts as number) < 1 || valueArg.error !== null) {
208
+ throw new Error(`${contextArg} publishing state is inconsistent.`);
209
+ }
210
+ } else if (valueArg.attempt !== null) {
211
+ throw new Error(`${contextArg}.attempt must be null outside publishing state.`);
212
+ }
213
+
214
+ if (
215
+ (valueArg.state === "pending" ||
216
+ valueArg.state === "verified" ||
217
+ valueArg.state === "skipped") &&
218
+ valueArg.error !== null
219
+ ) {
220
+ throw new Error(`${contextArg}.error must be null for ${valueArg.state}.`);
221
+ }
222
+ if (
223
+ (valueArg.state === "failed" || valueArg.state === "conflict") &&
224
+ valueArg.error === null
225
+ ) {
226
+ throw new Error(`${contextArg}.error is required for ${valueArg.state}.`);
227
+ }
228
+ return valueArg as unknown as IReleaseTargetStatus;
229
+ };
230
+
231
+ const assertArtifact = (valueArg: unknown): IReleaseArtifact | null => {
232
+ if (valueArg === null) {
233
+ return null;
234
+ }
235
+ if (!isPlainObject(valueArg)) {
236
+ throw new Error("Release journal artifact must be an object or null.");
237
+ }
238
+ assertExactKeys(
239
+ valueArg,
240
+ ["kind", "file", "packageName", "version", "size", "sha1", "sha256", "integrity"],
241
+ "Release journal artifact",
242
+ );
243
+ if (
244
+ valueArg.kind !== "npm-tarball" ||
245
+ valueArg.file !== releaseArtifactFileName ||
246
+ typeof valueArg.packageName !== "string" ||
247
+ !packageNameRegex.test(valueArg.packageName) ||
248
+ typeof valueArg.version !== "string" ||
249
+ normalizeReleaseVersion(valueArg.version) !== valueArg.version ||
250
+ !Number.isSafeInteger(valueArg.size) ||
251
+ (valueArg.size as number) <= 0 ||
252
+ typeof valueArg.sha1 !== "string" ||
253
+ !sha1Regex.test(valueArg.sha1) ||
254
+ typeof valueArg.sha256 !== "string" ||
255
+ !sha256Regex.test(valueArg.sha256) ||
256
+ typeof valueArg.integrity !== "string" ||
257
+ !sha512IntegrityRegex.test(valueArg.integrity)
258
+ ) {
259
+ throw new Error("Release journal artifact identity is invalid.");
260
+ }
261
+ return valueArg as unknown as IReleaseArtifact;
262
+ };
263
+
264
+ const isComplete = (journalArg: IReleaseJournal): boolean =>
265
+ [
266
+ journalArg.git.state,
267
+ ...journalArg.npm.registries.map((registryArg) => registryArg.state),
268
+ ].every((stateArg) => stateArg === "verified" || stateArg === "skipped");
269
+
270
+ const immutableJournalIdentity = (journalArg: IReleaseJournal): string =>
271
+ JSON.stringify({
272
+ release: journalArg.release,
273
+ artifact: journalArg.artifact,
274
+ git: {
275
+ remote: journalArg.git.remote,
276
+ destinationHash: journalArg.git.destinationHash,
277
+ expectedRemoteMainOid: journalArg.git.expectedRemoteMainOid,
278
+ },
279
+ npm: {
280
+ access: journalArg.npm.access,
281
+ tag: journalArg.npm.tag,
282
+ alreadyPublished: journalArg.npm.alreadyPublished,
283
+ registries: journalArg.npm.registries.map((registryArg) =>
284
+ registryArg.registry
285
+ ),
286
+ },
287
+ });
288
+
289
+ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
290
+ if (!isPlainObject(valueArg)) {
291
+ throw new Error("Release journal root must be a JSON object.");
292
+ }
293
+ assertExactKeys(
294
+ valueArg,
295
+ [
296
+ "kind",
297
+ "schemaVersion",
298
+ "revision",
299
+ "release",
300
+ "artifact",
301
+ "git",
302
+ "npm",
303
+ "createdAt",
304
+ "updatedAt",
305
+ "completedAt",
306
+ ],
307
+ "Release journal",
308
+ );
309
+ if (
310
+ valueArg.kind !== "gitzone-release-journal" ||
311
+ valueArg.schemaVersion !== releaseJournalSchemaVersion ||
312
+ !Number.isSafeInteger(valueArg.revision) ||
313
+ (valueArg.revision as number) < 1
314
+ ) {
315
+ throw new Error("Release journal header is invalid or unsupported.");
316
+ }
317
+
318
+ if (!isPlainObject(valueArg.release)) {
319
+ throw new Error("Release journal release identity is missing.");
320
+ }
321
+ assertExactKeys(
322
+ valueArg.release,
323
+ ["version", "tag", "mainOid", "tagOid"],
324
+ "Release journal release",
325
+ );
326
+ if (
327
+ typeof valueArg.release.version !== "string" ||
328
+ normalizeReleaseVersion(valueArg.release.version) !== valueArg.release.version ||
329
+ valueArg.release.tag !== `v${valueArg.release.version}` ||
330
+ typeof valueArg.release.mainOid !== "string" ||
331
+ !gitOidRegex.test(valueArg.release.mainOid) ||
332
+ typeof valueArg.release.tagOid !== "string" ||
333
+ !gitOidRegex.test(valueArg.release.tagOid)
334
+ ) {
335
+ throw new Error("Release journal Git identity is invalid.");
336
+ }
337
+
338
+ const artifact = assertArtifact(valueArg.artifact);
339
+ if (artifact && artifact.version !== valueArg.release.version) {
340
+ throw new Error("Release journal artifact version does not match the release.");
341
+ }
342
+
343
+ if (!isPlainObject(valueArg.git)) {
344
+ throw new Error("Release journal Git target is missing.");
345
+ }
346
+ assertExactKeys(
347
+ valueArg.git,
348
+ [
349
+ "state",
350
+ "attempts",
351
+ "attempt",
352
+ "error",
353
+ "remote",
354
+ "destinationHash",
355
+ "expectedRemoteMainOid",
356
+ ],
357
+ "Release journal Git target",
358
+ );
359
+ assertTargetStatus(valueArg.git, "Release journal Git target");
360
+ if (valueArg.git.state === "skipped") {
361
+ if (
362
+ valueArg.git.remote !== null ||
363
+ valueArg.git.destinationHash !== null ||
364
+ valueArg.git.expectedRemoteMainOid !== null
365
+ ) {
366
+ throw new Error("Skipped Git journal targets must not retain destination data.");
367
+ }
368
+ } else if (
369
+ typeof valueArg.git.remote !== "string" ||
370
+ normalizeReleaseGitRemoteName(valueArg.git.remote) !== valueArg.git.remote ||
371
+ typeof valueArg.git.destinationHash !== "string" ||
372
+ !sha256Regex.test(valueArg.git.destinationHash) ||
373
+ typeof valueArg.git.expectedRemoteMainOid !== "string" ||
374
+ !gitOidRegex.test(valueArg.git.expectedRemoteMainOid)
375
+ ) {
376
+ throw new Error("Active Git journal target destination data is invalid.");
377
+ }
378
+
379
+ if (!isPlainObject(valueArg.npm)) {
380
+ throw new Error("Release journal npm target is missing.");
381
+ }
382
+ assertExactKeys(
383
+ valueArg.npm,
384
+ ["access", "tag", "alreadyPublished", "registries"],
385
+ "Release journal npm target",
386
+ );
387
+ if (
388
+ valueArg.npm.access !== "public" ||
389
+ valueArg.npm.tag !== "latest" ||
390
+ (valueArg.npm.alreadyPublished !== "success" &&
391
+ valueArg.npm.alreadyPublished !== "error") ||
392
+ !Array.isArray(valueArg.npm.registries)
393
+ ) {
394
+ throw new Error("Release journal npm settings are invalid.");
395
+ }
396
+ const registryUrls: string[] = [];
397
+ for (const [index, rawRegistry] of valueArg.npm.registries.entries()) {
398
+ if (!isPlainObject(rawRegistry)) {
399
+ throw new Error(`Release journal npm registry ${index} must be an object.`);
400
+ }
401
+ assertExactKeys(
402
+ rawRegistry,
403
+ ["state", "attempts", "attempt", "error", "registry"],
404
+ `Release journal npm registry ${index}`,
405
+ );
406
+ assertTargetStatus(rawRegistry, `Release journal npm registry ${index}`);
407
+ if (
408
+ typeof rawRegistry.registry !== "string" ||
409
+ !isCanonicalRegistryUrl(rawRegistry.registry) ||
410
+ rawRegistry.state === "skipped"
411
+ ) {
412
+ throw new Error(`Release journal npm registry ${index} URL is invalid.`);
413
+ }
414
+ registryUrls.push(rawRegistry.registry);
415
+ }
416
+ if (new Set(registryUrls).size !== registryUrls.length) {
417
+ throw new Error("Release journal npm registries must be unique.");
418
+ }
419
+ if ((valueArg.npm.registries.length > 0) !== Boolean(artifact)) {
420
+ throw new Error("Release journal npm targets and artifact must exist together.");
421
+ }
422
+
423
+ const createdAt = assertIsoTimestamp(valueArg.createdAt, "Release journal createdAt");
424
+ const updatedAt = assertIsoTimestamp(valueArg.updatedAt, "Release journal updatedAt");
425
+ if (updatedAt < createdAt) {
426
+ throw new Error("Release journal updatedAt predates createdAt.");
427
+ }
428
+ if (valueArg.completedAt !== null) {
429
+ const completedAt = assertIsoTimestamp(
430
+ valueArg.completedAt,
431
+ "Release journal completedAt",
432
+ );
433
+ if (completedAt < createdAt || !isComplete(valueArg as unknown as IReleaseJournal)) {
434
+ throw new Error("Release journal completion state is inconsistent.");
435
+ }
436
+ if (completedAt > updatedAt) {
437
+ throw new Error("Release journal completedAt exceeds updatedAt.");
438
+ }
439
+ } else if (isComplete(valueArg as unknown as IReleaseJournal)) {
440
+ throw new Error("Complete release journal must record completedAt.");
441
+ }
442
+
443
+ return valueArg as unknown as IReleaseJournal;
444
+ };
445
+
446
+ export const serializeReleaseJournal = (journalArg: IReleaseJournal): string =>
447
+ `${JSON.stringify(assertReleaseJournal(journalArg), null, 2)}\n`;
448
+
449
+ export const parseReleaseJournal = (contentArg: string): IReleaseJournal => {
450
+ let parsed: unknown;
451
+ try {
452
+ parsed = JSON.parse(contentArg);
453
+ } catch (error) {
454
+ throw new Error("Release journal contains invalid JSON.", { cause: error });
455
+ }
456
+ const journal = assertReleaseJournal(parsed);
457
+ if (serializeReleaseJournal(journal) !== contentArg) {
458
+ throw new Error(
459
+ "Release journal must use canonical JSON without duplicate keys or formatting drift.",
460
+ );
461
+ }
462
+ return journal;
463
+ };
464
+
465
+ export const createReleaseAttempt = (): IReleaseAttempt => ({
466
+ id: plugins.crypto.randomBytes(16).toString("hex"),
467
+ pid: process.pid,
468
+ startedAt: new Date().toISOString(),
469
+ });
470
+
471
+ const syncDirectory = async (directoryPathArg: string): Promise<void> => {
472
+ const handle = await plugins.fs.open(directoryPathArg, "r");
473
+ try {
474
+ await handle.sync();
475
+ } finally {
476
+ await handle.close();
477
+ }
478
+ };
479
+
480
+ export const resolveGitCommonDirectory = async (
481
+ smartshellArg: plugins.smartshell.Smartshell,
482
+ cwdArg: string,
483
+ ): Promise<string> => {
484
+ const result = await smartshellArg.execSpawn(
485
+ "git",
486
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
487
+ {
488
+ cwd: cwdArg,
489
+ silent: true,
490
+ timeout: 60_000,
491
+ timeoutKillGraceMs: 5_000,
492
+ },
493
+ );
494
+ const commonDirectory = result.stdout.trim();
495
+ if (
496
+ result.exitCode !== 0 ||
497
+ !plugins.path.isAbsolute(commonDirectory) ||
498
+ !commonDirectory
499
+ ) {
500
+ throw new Error("Unable to resolve the Git common directory for release state.");
501
+ }
502
+ return plugins.path.resolve(commonDirectory);
503
+ };
504
+
505
+ export class ReleaseJournalStore {
506
+ public readonly rootPath: string;
507
+
508
+ constructor(gitCommonDirectoryArg: string) {
509
+ if (!plugins.path.isAbsolute(gitCommonDirectoryArg)) {
510
+ throw new Error("Release journal storage requires an absolute Git common directory.");
511
+ }
512
+ this.rootPath = plugins.path.join(
513
+ plugins.path.resolve(gitCommonDirectoryArg),
514
+ "gitzone",
515
+ "releases",
516
+ `v${releaseJournalSchemaVersion}`,
517
+ );
518
+ }
519
+
520
+ public getReleaseDirectory(versionArg: string): string {
521
+ const version = normalizeReleaseVersion(versionArg);
522
+ return plugins.path.join(this.rootPath, `v${version}`);
523
+ }
524
+
525
+ public getArtifactPath(versionArg: string): string {
526
+ return plugins.path.join(
527
+ this.getReleaseDirectory(versionArg),
528
+ releaseArtifactFileName,
529
+ );
530
+ }
531
+
532
+ public async createTemporaryDirectory(versionArg: string): Promise<string> {
533
+ const version = normalizeReleaseVersion(versionArg);
534
+ await plugins.fs.mkdir(this.rootPath, { recursive: true, mode: 0o700 });
535
+ return plugins.fs.mkdtemp(plugins.path.join(this.rootPath, `.v${version}.tmp-`));
536
+ }
537
+
538
+ public async read(versionArg: string): Promise<IReleaseJournal> {
539
+ const version = normalizeReleaseVersion(versionArg);
540
+ const filePath = plugins.path.join(
541
+ this.getReleaseDirectory(version),
542
+ releaseJournalFileName,
543
+ );
544
+ let content: string;
545
+ try {
546
+ content = await plugins.fs.readFile(filePath, "utf8");
547
+ } catch (error) {
548
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
549
+ throw new Error(`Release journal v${version} does not exist.`);
550
+ }
551
+ throw new Error(`Release journal v${version} could not be read.`, {
552
+ cause: error,
553
+ });
554
+ }
555
+ const journal = parseReleaseJournal(content);
556
+ if (journal.release.version !== version) {
557
+ throw new Error("Release journal directory and version do not match.");
558
+ }
559
+ return journal;
560
+ }
561
+
562
+ public async list(): Promise<IReleaseJournal[]> {
563
+ let entries: Array<{ name: string; isDirectory: () => boolean }>;
564
+ try {
565
+ entries = await plugins.fs.readdir(this.rootPath, { withFileTypes: true });
566
+ } catch (error) {
567
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
568
+ return [];
569
+ }
570
+ throw new Error("Release journal directory could not be listed.", {
571
+ cause: error,
572
+ });
573
+ }
574
+ const versions = entries
575
+ .filter((entryArg) => entryArg.isDirectory() && /^v\d+\.\d+\.\d+$/.test(entryArg.name))
576
+ .map((entryArg) => entryArg.name.slice(1));
577
+ const journals: IReleaseJournal[] = [];
578
+ for (const version of versions) {
579
+ journals.push(await this.read(version));
580
+ }
581
+ return journals.sort((leftArg, rightArg) =>
582
+ rightArg.createdAt.localeCompare(leftArg.createdAt),
583
+ );
584
+ }
585
+
586
+ public async installPrepared(
587
+ temporaryDirectoryArg: string,
588
+ journalArg: IReleaseJournal,
589
+ ): Promise<IReleaseJournal> {
590
+ const journal = assertReleaseJournal(journalArg);
591
+ if (journal.revision !== 1) {
592
+ throw new Error("New release journals must start at revision 1.");
593
+ }
594
+ const temporaryDirectory = plugins.path.resolve(temporaryDirectoryArg);
595
+ if (
596
+ plugins.path.dirname(temporaryDirectory) !== this.rootPath ||
597
+ !plugins.path.basename(temporaryDirectory).startsWith(`.v${journal.release.version}.tmp-`)
598
+ ) {
599
+ throw new Error("Prepared release directory is outside canonical storage.");
600
+ }
601
+ if (journal.artifact) {
602
+ const artifactPath = plugins.path.join(
603
+ temporaryDirectory,
604
+ releaseArtifactFileName,
605
+ );
606
+ const artifactStat = await plugins.fs.lstat(artifactPath);
607
+ if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) {
608
+ throw new Error("Prepared release artifact must be a regular file.");
609
+ }
610
+ const artifactBytes = await plugins.fs.readFile(artifactPath);
611
+ if (
612
+ artifactBytes.byteLength !== journal.artifact.size ||
613
+ plugins.crypto.createHash("sha1").update(artifactBytes).digest("hex") !==
614
+ journal.artifact.sha1 ||
615
+ plugins.crypto.createHash("sha256").update(artifactBytes).digest("hex") !==
616
+ journal.artifact.sha256 ||
617
+ `sha512-${plugins.crypto.createHash("sha512").update(artifactBytes).digest("base64")}` !==
618
+ journal.artifact.integrity
619
+ ) {
620
+ throw new Error("Prepared release artifact does not match its journal identity.");
621
+ }
622
+ }
623
+
624
+ const releaseDirectory = this.getReleaseDirectory(journal.release.version);
625
+ const lock = new InterProcessLock({
626
+ lockPath: `${releaseDirectory}.create.lock`,
627
+ description: `Release journal v${journal.release.version} creation`,
628
+ });
629
+ return lock.runExclusive(async () => {
630
+ try {
631
+ await plugins.fs.lstat(releaseDirectory);
632
+ throw new Error(
633
+ `Release journal v${journal.release.version} already exists. Inspect or resume it instead of regenerating artifacts.`,
634
+ );
635
+ } catch (error) {
636
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
637
+ throw error;
638
+ }
639
+ }
640
+
641
+ const journalPath = plugins.path.join(
642
+ temporaryDirectory,
643
+ releaseJournalFileName,
644
+ );
645
+ const handle = await plugins.fs.open(journalPath, "wx", 0o600);
646
+ try {
647
+ await handle.writeFile(serializeReleaseJournal(journal), "utf8");
648
+ await handle.sync();
649
+ } finally {
650
+ await handle.close();
651
+ }
652
+ await syncDirectory(temporaryDirectory);
653
+ await plugins.fs.rename(temporaryDirectory, releaseDirectory);
654
+ await syncDirectory(this.rootPath);
655
+ return journal;
656
+ });
657
+ }
658
+
659
+ public async transact(
660
+ versionArg: string,
661
+ expectedRevisionArg: number,
662
+ operationArg: (journalArg: IReleaseJournal) => IReleaseJournal,
663
+ ): Promise<IReleaseJournal> {
664
+ const version = normalizeReleaseVersion(versionArg);
665
+ const releaseDirectory = this.getReleaseDirectory(version);
666
+ const lock = new InterProcessLock({
667
+ lockPath: `${releaseDirectory}.lock`,
668
+ description: `Release journal v${version}`,
669
+ });
670
+ return lock.runExclusive(async () => {
671
+ const current = await this.read(version);
672
+ if (current.revision !== expectedRevisionArg) {
673
+ throw new Error(
674
+ `Release journal v${version} changed concurrently (expected revision ${expectedRevisionArg}, found ${current.revision}).`,
675
+ );
676
+ }
677
+ const proposed = operationArg(structuredClone(current));
678
+ const now = new Date().toISOString();
679
+ const next = assertReleaseJournal({
680
+ ...proposed,
681
+ revision: current.revision + 1,
682
+ createdAt: current.createdAt,
683
+ updatedAt: now,
684
+ });
685
+ if (immutableJournalIdentity(next) !== immutableJournalIdentity(current)) {
686
+ throw new Error("Release journal immutable identity changed during a transaction.");
687
+ }
688
+ await this.writeAtomic(version, next);
689
+ return next;
690
+ });
691
+ }
692
+
693
+ private async writeAtomic(
694
+ versionArg: string,
695
+ journalArg: IReleaseJournal,
696
+ ): Promise<void> {
697
+ const releaseDirectory = this.getReleaseDirectory(versionArg);
698
+ const journalPath = plugins.path.join(releaseDirectory, releaseJournalFileName);
699
+ const temporaryPath = plugins.path.join(
700
+ releaseDirectory,
701
+ `.journal.tmp-${process.pid}-${plugins.crypto.randomBytes(12).toString("hex")}`,
702
+ );
703
+ let handle: Awaited<ReturnType<typeof plugins.fs.open>> | undefined;
704
+ try {
705
+ handle = await plugins.fs.open(temporaryPath, "wx", 0o600);
706
+ await handle.writeFile(serializeReleaseJournal(journalArg), "utf8");
707
+ await handle.sync();
708
+ await handle.close();
709
+ handle = undefined;
710
+ await plugins.fs.rename(temporaryPath, journalPath);
711
+ await syncDirectory(releaseDirectory);
712
+ } catch (error) {
713
+ await handle?.close().catch(() => {});
714
+ await plugins.fs.rm(temporaryPath, { force: true }).catch(() => {});
715
+ throw new Error(`Release journal v${versionArg} could not be updated atomically.`, {
716
+ cause: error,
717
+ });
718
+ }
719
+ }
720
+ }
721
+
722
+ export const createInitialTargetStatus = (
723
+ enabledArg: boolean,
724
+ ): IReleaseTargetStatus => ({
725
+ state: enabledArg ? "pending" : "skipped",
726
+ attempts: 0,
727
+ attempt: null,
728
+ error: null,
729
+ });
730
+
731
+ export const finalizeJournalCompletion = (
732
+ journalArg: IReleaseJournal,
733
+ completedAtArg = new Date().toISOString(),
734
+ ): IReleaseJournal => {
735
+ const completed = isComplete(journalArg);
736
+ return {
737
+ ...journalArg,
738
+ completedAt: completed ? journalArg.completedAt || completedAtArg : null,
739
+ };
740
+ };