@git.zone/cli 6.0.1 → 6.1.1

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.
@@ -1,17 +1,28 @@
1
1
  import * as plugins from "./mod.plugins.js";
2
2
  import { InterProcessLock } from "../mod_services/classes.interprocesslock.js";
3
+ import {
4
+ assertReleaseCleanupResult,
5
+ assertReleasePromotionEvidence,
6
+ assertReleaseQualificationRequest,
7
+ assertReleaseQualificationResult,
8
+ canonicalReleaseJson,
9
+ classifyReleasePromotionEvidence,
10
+ getReleasePromotionContexts,
11
+ type IReleaseCleanupResult,
12
+ type IReleasePromotionContext,
13
+ type IReleaseQualificationRequest,
14
+ type IReleaseQualificationResult,
15
+ type TReleasePromotionEvidence,
16
+ } from "./helpers.tsdockerprotocol.js";
3
17
 
4
- export const releaseJournalSchemaVersion = 1 as const;
18
+ export const releaseJournalStorageVersion = 1 as const;
19
+ export const legacyReleaseJournalSchemaVersion = 1 as const;
20
+ export const releaseJournalSchemaVersion = 2 as const;
5
21
  export const releaseArtifactFileName = "package.tgz" as const;
6
22
  export const releaseJournalFileName = "journal.json" as const;
7
23
 
8
24
  export type TReleaseTargetState =
9
- | "pending"
10
- | "publishing"
11
- | "verified"
12
- | "failed"
13
- | "conflict"
14
- | "skipped";
25
+ "pending" | "publishing" | "verified" | "failed" | "conflict" | "skipped";
15
26
 
16
27
  export type TReleaseErrorCode =
17
28
  | "command-failed"
@@ -55,7 +66,52 @@ export interface IReleaseNpmRegistryJournal extends IReleaseTargetStatus {
55
66
  registry: string;
56
67
  }
57
68
 
58
- export interface IReleaseJournal {
69
+ export interface IReleaseJournalV1 {
70
+ kind: "gitzone-release-journal";
71
+ schemaVersion: typeof legacyReleaseJournalSchemaVersion;
72
+ revision: number;
73
+ release: {
74
+ version: string;
75
+ tag: string;
76
+ mainOid: string;
77
+ tagOid: string;
78
+ };
79
+ artifact: IReleaseArtifact | null;
80
+ git: IReleaseGitJournal;
81
+ npm: {
82
+ access: "public";
83
+ tag: "latest";
84
+ alreadyPublished: "success" | "error";
85
+ registries: IReleaseNpmRegistryJournal[];
86
+ };
87
+ createdAt: string;
88
+ updatedAt: string;
89
+ completedAt: string | null;
90
+ }
91
+
92
+ export interface IReleaseDockerQualificationJournal extends IReleaseTargetStatus {
93
+ result: IReleaseQualificationResult | null;
94
+ }
95
+
96
+ export interface IReleaseDockerPromotionJournal extends IReleaseTargetStatus {
97
+ promotionId: string;
98
+ evidence: TReleasePromotionEvidence[];
99
+ }
100
+
101
+ export interface IReleaseDockerCleanupJournal extends IReleaseTargetStatus {
102
+ result: IReleaseCleanupResult | null;
103
+ }
104
+
105
+ export interface IReleaseDockerJournal {
106
+ engine: "tsdocker";
107
+ protocolVersion: 1;
108
+ request: IReleaseQualificationRequest;
109
+ qualification: IReleaseDockerQualificationJournal;
110
+ promotions: IReleaseDockerPromotionJournal[];
111
+ cleanup: IReleaseDockerCleanupJournal;
112
+ }
113
+
114
+ export interface IReleaseJournalV2 {
59
115
  kind: "gitzone-release-journal";
60
116
  schemaVersion: typeof releaseJournalSchemaVersion;
61
117
  revision: number;
@@ -73,18 +129,22 @@ export interface IReleaseJournal {
73
129
  alreadyPublished: "success" | "error";
74
130
  registries: IReleaseNpmRegistryJournal[];
75
131
  };
132
+ docker: IReleaseDockerJournal;
76
133
  createdAt: string;
77
134
  updatedAt: string;
78
135
  completedAt: string | null;
79
136
  }
80
137
 
138
+ export type TReleaseJournal = IReleaseJournalV1 | IReleaseJournalV2;
139
+
81
140
  const releaseVersionRegex = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
82
141
  const gitOidRegex = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
83
142
  const attemptIdRegex = /^[0-9a-f]{32}$/;
84
143
  const sha1Regex = /^[0-9a-f]{40}$/;
85
144
  const sha256Regex = /^[0-9a-f]{64}$/;
86
145
  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._-]*)$/;
146
+ const packageNameRegex =
147
+ /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
88
148
  const gitRemoteNameRegex = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
89
149
  const targetStates: TReleaseTargetState[] = [
90
150
  "pending",
@@ -103,7 +163,9 @@ const errorCodes: TReleaseErrorCode[] = [
103
163
  "attempt-recovery-required",
104
164
  ];
105
165
 
106
- const isPlainObject = (valueArg: unknown): valueArg is Record<string, unknown> =>
166
+ const isPlainObject = (
167
+ valueArg: unknown,
168
+ ): valueArg is Record<string, unknown> =>
107
169
  typeof valueArg === "object" && valueArg !== null && !Array.isArray(valueArg);
108
170
 
109
171
  const isCanonicalRegistryUrl = (valueArg: string): boolean => {
@@ -135,7 +197,9 @@ const assertExactKeys = (
135
197
  actualKeys.length !== keysArg.length ||
136
198
  actualKeys.some((keyArg, indexArg) => keyArg !== keysArg[indexArg])
137
199
  ) {
138
- throw new Error(`${contextArg} must use the exact canonical key set and order.`);
200
+ throw new Error(
201
+ `${contextArg} must use the exact canonical key set and order.`,
202
+ );
139
203
  }
140
204
  };
141
205
 
@@ -153,7 +217,9 @@ const assertIsoTimestamp = (valueArg: unknown, contextArg: string): string => {
153
217
  export const normalizeReleaseVersion = (valueArg: string): string => {
154
218
  const version = valueArg.startsWith("v") ? valueArg.slice(1) : valueArg;
155
219
  if (!releaseVersionRegex.test(version)) {
156
- throw new Error("Release journal versions must use canonical x.y.z semver.");
220
+ throw new Error(
221
+ "Release journal versions must use canonical x.y.z semver.",
222
+ );
157
223
  }
158
224
  return version;
159
225
  };
@@ -165,7 +231,9 @@ export const normalizeReleaseGitRemoteName = (valueArg: string): string => {
165
231
  valueArg.includes("..") ||
166
232
  valueArg.endsWith("/")
167
233
  ) {
168
- throw new Error("Release journals require a canonical credential-free Git remote name.");
234
+ throw new Error(
235
+ "Release journals require a canonical credential-free Git remote name.",
236
+ );
169
237
  }
170
238
  return valueArg;
171
239
  };
@@ -181,7 +249,9 @@ const assertTargetStatus = (
181
249
  !Number.isSafeInteger(valueArg.attempts) ||
182
250
  (valueArg.attempts as number) < 0
183
251
  ) {
184
- throw new Error(`${contextArg}.attempts must be a non-negative safe integer.`);
252
+ throw new Error(
253
+ `${contextArg}.attempts must be a non-negative safe integer.`,
254
+ );
185
255
  }
186
256
  if (
187
257
  valueArg.error !== null &&
@@ -194,7 +264,11 @@ const assertTargetStatus = (
194
264
  if (!isPlainObject(valueArg.attempt)) {
195
265
  throw new Error(`${contextArg}.attempt is required while publishing.`);
196
266
  }
197
- assertExactKeys(valueArg.attempt, ["id", "pid", "startedAt"], `${contextArg}.attempt`);
267
+ assertExactKeys(
268
+ valueArg.attempt,
269
+ ["id", "pid", "startedAt"],
270
+ `${contextArg}.attempt`,
271
+ );
198
272
  if (
199
273
  typeof valueArg.attempt.id !== "string" ||
200
274
  !attemptIdRegex.test(valueArg.attempt.id) ||
@@ -203,12 +277,17 @@ const assertTargetStatus = (
203
277
  ) {
204
278
  throw new Error(`${contextArg}.attempt owner is invalid.`);
205
279
  }
206
- assertIsoTimestamp(valueArg.attempt.startedAt, `${contextArg}.attempt.startedAt`);
280
+ assertIsoTimestamp(
281
+ valueArg.attempt.startedAt,
282
+ `${contextArg}.attempt.startedAt`,
283
+ );
207
284
  if ((valueArg.attempts as number) < 1 || valueArg.error !== null) {
208
285
  throw new Error(`${contextArg} publishing state is inconsistent.`);
209
286
  }
210
287
  } else if (valueArg.attempt !== null) {
211
- throw new Error(`${contextArg}.attempt must be null outside publishing state.`);
288
+ throw new Error(
289
+ `${contextArg}.attempt must be null outside publishing state.`,
290
+ );
212
291
  }
213
292
 
214
293
  if (
@@ -237,7 +316,16 @@ const assertArtifact = (valueArg: unknown): IReleaseArtifact | null => {
237
316
  }
238
317
  assertExactKeys(
239
318
  valueArg,
240
- ["kind", "file", "packageName", "version", "size", "sha1", "sha256", "integrity"],
319
+ [
320
+ "kind",
321
+ "file",
322
+ "packageName",
323
+ "version",
324
+ "size",
325
+ "sha1",
326
+ "sha256",
327
+ "integrity",
328
+ ],
241
329
  "Release journal artifact",
242
330
  );
243
331
  if (
@@ -261,14 +349,30 @@ const assertArtifact = (valueArg: unknown): IReleaseArtifact | null => {
261
349
  return valueArg as unknown as IReleaseArtifact;
262
350
  };
263
351
 
264
- const isComplete = (journalArg: IReleaseJournal): boolean =>
265
- [
352
+ const isComplete = (journalArg: TReleaseJournal): boolean => {
353
+ const commonComplete = [
266
354
  journalArg.git.state,
267
355
  ...journalArg.npm.registries.map((registryArg) => registryArg.state),
268
356
  ].every((stateArg) => stateArg === "verified" || stateArg === "skipped");
357
+ if (
358
+ !commonComplete ||
359
+ journalArg.schemaVersion === legacyReleaseJournalSchemaVersion
360
+ ) {
361
+ return commonComplete;
362
+ }
363
+ return (
364
+ journalArg.docker.qualification.state === "verified" &&
365
+ journalArg.docker.promotions.length > 0 &&
366
+ journalArg.docker.promotions.every(
367
+ (promotionArg) => promotionArg.state === "verified",
368
+ ) &&
369
+ journalArg.docker.cleanup.state === "verified"
370
+ );
371
+ };
269
372
 
270
- const immutableJournalIdentity = (journalArg: IReleaseJournal): string =>
373
+ const immutableJournalIdentity = (journalArg: TReleaseJournal): string =>
271
374
  JSON.stringify({
375
+ schemaVersion: journalArg.schemaVersion,
272
376
  release: journalArg.release,
273
377
  artifact: journalArg.artifact,
274
378
  git: {
@@ -280,13 +384,21 @@ const immutableJournalIdentity = (journalArg: IReleaseJournal): string =>
280
384
  access: journalArg.npm.access,
281
385
  tag: journalArg.npm.tag,
282
386
  alreadyPublished: journalArg.npm.alreadyPublished,
283
- registries: journalArg.npm.registries.map((registryArg) =>
284
- registryArg.registry
387
+ registries: journalArg.npm.registries.map(
388
+ (registryArg) => registryArg.registry,
285
389
  ),
286
390
  },
391
+ docker:
392
+ journalArg.schemaVersion === releaseJournalSchemaVersion
393
+ ? {
394
+ engine: journalArg.docker.engine,
395
+ protocolVersion: journalArg.docker.protocolVersion,
396
+ request: journalArg.docker.request,
397
+ }
398
+ : undefined,
287
399
  });
288
400
 
289
- export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
401
+ const assertReleaseJournalV1 = (valueArg: unknown): IReleaseJournalV1 => {
290
402
  if (!isPlainObject(valueArg)) {
291
403
  throw new Error("Release journal root must be a JSON object.");
292
404
  }
@@ -308,7 +420,7 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
308
420
  );
309
421
  if (
310
422
  valueArg.kind !== "gitzone-release-journal" ||
311
- valueArg.schemaVersion !== releaseJournalSchemaVersion ||
423
+ valueArg.schemaVersion !== legacyReleaseJournalSchemaVersion ||
312
424
  !Number.isSafeInteger(valueArg.revision) ||
313
425
  (valueArg.revision as number) < 1
314
426
  ) {
@@ -325,7 +437,8 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
325
437
  );
326
438
  if (
327
439
  typeof valueArg.release.version !== "string" ||
328
- normalizeReleaseVersion(valueArg.release.version) !== valueArg.release.version ||
440
+ normalizeReleaseVersion(valueArg.release.version) !==
441
+ valueArg.release.version ||
329
442
  valueArg.release.tag !== `v${valueArg.release.version}` ||
330
443
  typeof valueArg.release.mainOid !== "string" ||
331
444
  !gitOidRegex.test(valueArg.release.mainOid) ||
@@ -337,7 +450,9 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
337
450
 
338
451
  const artifact = assertArtifact(valueArg.artifact);
339
452
  if (artifact && artifact.version !== valueArg.release.version) {
340
- throw new Error("Release journal artifact version does not match the release.");
453
+ throw new Error(
454
+ "Release journal artifact version does not match the release.",
455
+ );
341
456
  }
342
457
 
343
458
  if (!isPlainObject(valueArg.git)) {
@@ -363,11 +478,14 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
363
478
  valueArg.git.destinationHash !== null ||
364
479
  valueArg.git.expectedRemoteMainOid !== null
365
480
  ) {
366
- throw new Error("Skipped Git journal targets must not retain destination data.");
481
+ throw new Error(
482
+ "Skipped Git journal targets must not retain destination data.",
483
+ );
367
484
  }
368
485
  } else if (
369
486
  typeof valueArg.git.remote !== "string" ||
370
- normalizeReleaseGitRemoteName(valueArg.git.remote) !== valueArg.git.remote ||
487
+ normalizeReleaseGitRemoteName(valueArg.git.remote) !==
488
+ valueArg.git.remote ||
371
489
  typeof valueArg.git.destinationHash !== "string" ||
372
490
  !sha256Regex.test(valueArg.git.destinationHash) ||
373
491
  typeof valueArg.git.expectedRemoteMainOid !== "string" ||
@@ -396,7 +514,9 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
396
514
  const registryUrls: string[] = [];
397
515
  for (const [index, rawRegistry] of valueArg.npm.registries.entries()) {
398
516
  if (!isPlainObject(rawRegistry)) {
399
- throw new Error(`Release journal npm registry ${index} must be an object.`);
517
+ throw new Error(
518
+ `Release journal npm registry ${index} must be an object.`,
519
+ );
400
520
  }
401
521
  assertExactKeys(
402
522
  rawRegistry,
@@ -416,12 +536,441 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
416
536
  if (new Set(registryUrls).size !== registryUrls.length) {
417
537
  throw new Error("Release journal npm registries must be unique.");
418
538
  }
419
- if ((valueArg.npm.registries.length > 0) !== Boolean(artifact)) {
420
- throw new Error("Release journal npm targets and artifact must exist together.");
539
+ if (valueArg.npm.registries.length > 0 !== Boolean(artifact)) {
540
+ throw new Error(
541
+ "Release journal npm targets and artifact must exist together.",
542
+ );
543
+ }
544
+
545
+ const createdAt = assertIsoTimestamp(
546
+ valueArg.createdAt,
547
+ "Release journal createdAt",
548
+ );
549
+ const updatedAt = assertIsoTimestamp(
550
+ valueArg.updatedAt,
551
+ "Release journal updatedAt",
552
+ );
553
+ if (updatedAt < createdAt) {
554
+ throw new Error("Release journal updatedAt predates createdAt.");
555
+ }
556
+ if (valueArg.completedAt !== null) {
557
+ const completedAt = assertIsoTimestamp(
558
+ valueArg.completedAt,
559
+ "Release journal completedAt",
560
+ );
561
+ if (
562
+ completedAt < createdAt ||
563
+ !isComplete(valueArg as unknown as IReleaseJournalV1)
564
+ ) {
565
+ throw new Error("Release journal completion state is inconsistent.");
566
+ }
567
+ if (completedAt > updatedAt) {
568
+ throw new Error("Release journal completedAt exceeds updatedAt.");
569
+ }
570
+ } else if (isComplete(valueArg as unknown as IReleaseJournalV1)) {
571
+ throw new Error("Complete release journal must record completedAt.");
572
+ }
573
+
574
+ return valueArg as unknown as IReleaseJournalV1;
575
+ };
576
+
577
+ export interface IReleaseDockerPromotionContext extends IReleasePromotionContext {}
578
+
579
+ const assertDockerAttemptHistory = (
580
+ statusArg: IReleaseTargetStatus,
581
+ contextArg: string,
582
+ ): void => {
583
+ if (statusArg.state === "pending" && statusArg.attempts !== 0) {
584
+ throw new Error(
585
+ `${contextArg} pending state cannot retain prior attempts.`,
586
+ );
587
+ }
588
+ if (statusArg.state !== "pending" && statusArg.attempts < 1) {
589
+ throw new Error(
590
+ `${contextArg} state requires publication attempt history.`,
591
+ );
592
+ }
593
+ if (
594
+ (statusArg.state === "conflict") !==
595
+ (statusArg.error === "destination-conflict")
596
+ ) {
597
+ throw new Error(
598
+ `${contextArg} destination-conflict must be terminal conflict state.`,
599
+ );
600
+ }
601
+ };
602
+
603
+ export const getReleaseDockerPromotionContext = (
604
+ journalArg: IReleaseJournalV2,
605
+ promotionIdArg: string,
606
+ ): IReleaseDockerPromotionContext => {
607
+ const result = journalArg.docker.qualification.result;
608
+ if (!result) {
609
+ throw new Error("Docker qualification evidence is not available.");
610
+ }
611
+ const context = getReleasePromotionContexts(result).find(
612
+ (entryArg) => entryArg.promotionId === promotionIdArg,
613
+ );
614
+ if (!context) {
615
+ throw new Error(
616
+ "Docker promotion is not part of the qualification result.",
617
+ );
618
+ }
619
+ return context;
620
+ };
621
+
622
+ const assertDockerJournal = (
623
+ valueArg: unknown,
624
+ releaseArg: IReleaseJournalV2["release"],
625
+ ): IReleaseDockerJournal => {
626
+ if (!isPlainObject(valueArg)) {
627
+ throw new Error("Release journal Docker target is missing.");
628
+ }
629
+ assertExactKeys(
630
+ valueArg,
631
+ [
632
+ "engine",
633
+ "protocolVersion",
634
+ "request",
635
+ "qualification",
636
+ "promotions",
637
+ "cleanup",
638
+ ],
639
+ "Release journal Docker target",
640
+ );
641
+ if (
642
+ valueArg.engine !== "tsdocker" ||
643
+ valueArg.protocolVersion !== 1 ||
644
+ !isPlainObject(valueArg.qualification) ||
645
+ !Array.isArray(valueArg.promotions) ||
646
+ !isPlainObject(valueArg.cleanup)
647
+ ) {
648
+ throw new Error("Release journal Docker target is invalid.");
649
+ }
650
+ const request = assertReleaseQualificationRequest(valueArg.request);
651
+ if (
652
+ request.release.version !== releaseArg.version ||
653
+ request.release.revision !== releaseArg.mainOid
654
+ ) {
655
+ throw new Error(
656
+ "Release journal Docker request does not match the release.",
657
+ );
658
+ }
659
+
660
+ assertExactKeys(
661
+ valueArg.qualification,
662
+ ["state", "attempts", "attempt", "error", "result"],
663
+ "Release journal Docker qualification",
664
+ );
665
+ assertTargetStatus(
666
+ valueArg.qualification,
667
+ "Release journal Docker qualification",
668
+ );
669
+ assertDockerAttemptHistory(
670
+ valueArg.qualification as unknown as IReleaseTargetStatus,
671
+ "Release journal Docker qualification",
672
+ );
673
+ if (valueArg.qualification.state === "skipped") {
674
+ throw new Error("Schema 2 Docker qualification cannot be skipped.");
675
+ }
676
+ const qualificationResult =
677
+ valueArg.qualification.result === null
678
+ ? null
679
+ : assertReleaseQualificationResult(
680
+ valueArg.qualification.result,
681
+ request,
682
+ );
683
+ if (
684
+ (valueArg.qualification.state === "verified") !==
685
+ Boolean(qualificationResult)
686
+ ) {
687
+ throw new Error(
688
+ "Release journal Docker qualification result is inconsistent.",
689
+ );
690
+ }
691
+ const promotionContexts = qualificationResult
692
+ ? getReleasePromotionContexts(qualificationResult)
693
+ : [];
694
+ if (valueArg.promotions.length !== promotionContexts.length) {
695
+ throw new Error(
696
+ "Release journal Docker promotions do not match qualification evidence.",
697
+ );
698
+ }
699
+ for (const [index, rawPromotion] of valueArg.promotions.entries()) {
700
+ if (!isPlainObject(rawPromotion)) {
701
+ throw new Error(`Release journal Docker promotion ${index} is invalid.`);
702
+ }
703
+ assertExactKeys(
704
+ rawPromotion,
705
+ ["state", "attempts", "attempt", "error", "promotionId", "evidence"],
706
+ `Release journal Docker promotion ${index}`,
707
+ );
708
+ assertTargetStatus(
709
+ rawPromotion,
710
+ `Release journal Docker promotion ${index}`,
711
+ );
712
+ assertDockerAttemptHistory(
713
+ rawPromotion as unknown as IReleaseTargetStatus,
714
+ `Release journal Docker promotion ${index}`,
715
+ );
716
+ if (
717
+ rawPromotion.state === "skipped" ||
718
+ rawPromotion.promotionId !== promotionContexts[index].promotionId ||
719
+ !Array.isArray(rawPromotion.evidence) ||
720
+ rawPromotion.evidence.length > 2
721
+ ) {
722
+ throw new Error(`Release journal Docker promotion ${index} is invalid.`);
723
+ }
724
+ const evidence = rawPromotion.evidence.map((entryArg) =>
725
+ assertReleasePromotionEvidence(
726
+ entryArg,
727
+ request,
728
+ promotionContexts[index],
729
+ ),
730
+ );
731
+ const classifications = evidence.map(classifyReleasePromotionEvidence);
732
+ const verifiedEvidence =
733
+ rawPromotion.state === "verified" &&
734
+ classifications.length === 1 &&
735
+ classifications[0] === "exact";
736
+ const initialConflictEvidence =
737
+ rawPromotion.state === "conflict" &&
738
+ classifications.length === 1 &&
739
+ classifications[0] === "conflict";
740
+ const verifiedDriftEvidence =
741
+ rawPromotion.state === "conflict" &&
742
+ classifications.length === 2 &&
743
+ classifications[0] === "exact" &&
744
+ (classifications[1] === "pending" || classifications[1] === "conflict");
745
+ const noEvidenceExpected =
746
+ (rawPromotion.state === "pending" ||
747
+ rawPromotion.state === "publishing" ||
748
+ rawPromotion.state === "failed") &&
749
+ classifications.length === 0;
750
+ if (
751
+ (!verifiedEvidence &&
752
+ !initialConflictEvidence &&
753
+ !verifiedDriftEvidence &&
754
+ !noEvidenceExpected) ||
755
+ (rawPromotion.state === "conflict" &&
756
+ rawPromotion.error !== "destination-conflict")
757
+ ) {
758
+ throw new Error(
759
+ `Release journal Docker promotion ${index} evidence is inconsistent.`,
760
+ );
761
+ }
421
762
  }
422
763
 
423
- const createdAt = assertIsoTimestamp(valueArg.createdAt, "Release journal createdAt");
424
- const updatedAt = assertIsoTimestamp(valueArg.updatedAt, "Release journal updatedAt");
764
+ assertExactKeys(
765
+ valueArg.cleanup,
766
+ ["state", "attempts", "attempt", "error", "result"],
767
+ "Release journal Docker cleanup",
768
+ );
769
+ assertTargetStatus(valueArg.cleanup, "Release journal Docker cleanup");
770
+ assertDockerAttemptHistory(
771
+ valueArg.cleanup as unknown as IReleaseTargetStatus,
772
+ "Release journal Docker cleanup",
773
+ );
774
+ if (
775
+ valueArg.cleanup.state === "skipped" ||
776
+ valueArg.cleanup.state === "conflict"
777
+ ) {
778
+ throw new Error("Release journal Docker cleanup state is invalid.");
779
+ }
780
+ const cleanupResult =
781
+ valueArg.cleanup.result === null
782
+ ? null
783
+ : assertReleaseCleanupResult(valueArg.cleanup.result, request);
784
+ if ((valueArg.cleanup.state === "verified") !== Boolean(cleanupResult)) {
785
+ throw new Error("Release journal Docker cleanup result is inconsistent.");
786
+ }
787
+ const promotionsVerified =
788
+ valueArg.promotions.length > 0 &&
789
+ valueArg.promotions.every(
790
+ (promotionArg) =>
791
+ isPlainObject(promotionArg) && promotionArg.state === "verified",
792
+ );
793
+ const qualificationConflictCleanup =
794
+ valueArg.qualification.state === "conflict" &&
795
+ valueArg.qualification.error === "destination-conflict" &&
796
+ valueArg.promotions.length === 0;
797
+ if (
798
+ (valueArg.cleanup.state === "publishing" ||
799
+ valueArg.cleanup.state === "verified") &&
800
+ !promotionsVerified &&
801
+ !qualificationConflictCleanup
802
+ ) {
803
+ throw new Error(
804
+ "Release journal Docker cleanup requires verified promotions or a terminal qualification conflict.",
805
+ );
806
+ }
807
+ return valueArg as unknown as IReleaseDockerJournal;
808
+ };
809
+
810
+ const assertReleaseJournalV2 = (valueArg: unknown): IReleaseJournalV2 => {
811
+ if (!isPlainObject(valueArg)) {
812
+ throw new Error("Release journal root must be a JSON object.");
813
+ }
814
+ assertExactKeys(
815
+ valueArg,
816
+ [
817
+ "kind",
818
+ "schemaVersion",
819
+ "revision",
820
+ "release",
821
+ "artifact",
822
+ "git",
823
+ "npm",
824
+ "docker",
825
+ "createdAt",
826
+ "updatedAt",
827
+ "completedAt",
828
+ ],
829
+ "Release journal",
830
+ );
831
+ if (
832
+ valueArg.kind !== "gitzone-release-journal" ||
833
+ valueArg.schemaVersion !== releaseJournalSchemaVersion ||
834
+ !Number.isSafeInteger(valueArg.revision) ||
835
+ (valueArg.revision as number) < 1
836
+ ) {
837
+ throw new Error("Release journal header is invalid or unsupported.");
838
+ }
839
+ if (!isPlainObject(valueArg.release)) {
840
+ throw new Error("Release journal release identity is missing.");
841
+ }
842
+ assertExactKeys(
843
+ valueArg.release,
844
+ ["version", "tag", "mainOid", "tagOid"],
845
+ "Release journal release",
846
+ );
847
+ if (
848
+ typeof valueArg.release.version !== "string" ||
849
+ normalizeReleaseVersion(valueArg.release.version) !==
850
+ valueArg.release.version ||
851
+ valueArg.release.tag !== `v${valueArg.release.version}` ||
852
+ typeof valueArg.release.mainOid !== "string" ||
853
+ !gitOidRegex.test(valueArg.release.mainOid) ||
854
+ typeof valueArg.release.tagOid !== "string" ||
855
+ !gitOidRegex.test(valueArg.release.tagOid)
856
+ ) {
857
+ throw new Error("Release journal Git identity is invalid.");
858
+ }
859
+ const artifact = assertArtifact(valueArg.artifact);
860
+ if (artifact && artifact.version !== valueArg.release.version) {
861
+ throw new Error(
862
+ "Release journal artifact version does not match the release.",
863
+ );
864
+ }
865
+ if (!isPlainObject(valueArg.git)) {
866
+ throw new Error("Release journal Git target is missing.");
867
+ }
868
+ assertExactKeys(
869
+ valueArg.git,
870
+ [
871
+ "state",
872
+ "attempts",
873
+ "attempt",
874
+ "error",
875
+ "remote",
876
+ "destinationHash",
877
+ "expectedRemoteMainOid",
878
+ ],
879
+ "Release journal Git target",
880
+ );
881
+ assertTargetStatus(valueArg.git, "Release journal Git target");
882
+ if (valueArg.git.state === "skipped") {
883
+ if (
884
+ valueArg.git.remote !== null ||
885
+ valueArg.git.destinationHash !== null ||
886
+ valueArg.git.expectedRemoteMainOid !== null
887
+ ) {
888
+ throw new Error(
889
+ "Skipped Git journal targets must not retain destination data.",
890
+ );
891
+ }
892
+ } else if (
893
+ typeof valueArg.git.remote !== "string" ||
894
+ normalizeReleaseGitRemoteName(valueArg.git.remote) !==
895
+ valueArg.git.remote ||
896
+ typeof valueArg.git.destinationHash !== "string" ||
897
+ !sha256Regex.test(valueArg.git.destinationHash) ||
898
+ typeof valueArg.git.expectedRemoteMainOid !== "string" ||
899
+ !gitOidRegex.test(valueArg.git.expectedRemoteMainOid)
900
+ ) {
901
+ throw new Error("Active Git journal target destination data is invalid.");
902
+ }
903
+ if (!isPlainObject(valueArg.npm)) {
904
+ throw new Error("Release journal npm target is missing.");
905
+ }
906
+ assertExactKeys(
907
+ valueArg.npm,
908
+ ["access", "tag", "alreadyPublished", "registries"],
909
+ "Release journal npm target",
910
+ );
911
+ if (
912
+ valueArg.npm.access !== "public" ||
913
+ valueArg.npm.tag !== "latest" ||
914
+ (valueArg.npm.alreadyPublished !== "success" &&
915
+ valueArg.npm.alreadyPublished !== "error") ||
916
+ !Array.isArray(valueArg.npm.registries)
917
+ ) {
918
+ throw new Error("Release journal npm settings are invalid.");
919
+ }
920
+ const registryUrls: string[] = [];
921
+ for (const [index, rawRegistry] of valueArg.npm.registries.entries()) {
922
+ if (!isPlainObject(rawRegistry)) {
923
+ throw new Error(
924
+ `Release journal npm registry ${index} must be an object.`,
925
+ );
926
+ }
927
+ assertExactKeys(
928
+ rawRegistry,
929
+ ["state", "attempts", "attempt", "error", "registry"],
930
+ `Release journal npm registry ${index}`,
931
+ );
932
+ assertTargetStatus(rawRegistry, `Release journal npm registry ${index}`);
933
+ if (
934
+ typeof rawRegistry.registry !== "string" ||
935
+ !isCanonicalRegistryUrl(rawRegistry.registry) ||
936
+ rawRegistry.state === "skipped"
937
+ ) {
938
+ throw new Error(`Release journal npm registry ${index} URL is invalid.`);
939
+ }
940
+ registryUrls.push(rawRegistry.registry);
941
+ }
942
+ if (new Set(registryUrls).size !== registryUrls.length) {
943
+ throw new Error("Release journal npm registries must be unique.");
944
+ }
945
+ if (valueArg.npm.registries.length > 0 !== Boolean(artifact)) {
946
+ throw new Error(
947
+ "Release journal npm targets and artifact must exist together.",
948
+ );
949
+ }
950
+ const docker = assertDockerJournal(
951
+ valueArg.docker,
952
+ valueArg.release as unknown as IReleaseJournalV2["release"],
953
+ );
954
+ const activePublishers = [
955
+ valueArg.git as unknown as IReleaseTargetStatus,
956
+ ...(valueArg.npm.registries as unknown as IReleaseTargetStatus[]),
957
+ docker.qualification,
958
+ ...docker.promotions,
959
+ docker.cleanup,
960
+ ].filter((statusArg) => statusArg.state === "publishing");
961
+ if (activePublishers.length > 1) {
962
+ throw new Error(
963
+ "Schema 2 release journals permit at most one active publication owner.",
964
+ );
965
+ }
966
+ const createdAt = assertIsoTimestamp(
967
+ valueArg.createdAt,
968
+ "Release journal createdAt",
969
+ );
970
+ const updatedAt = assertIsoTimestamp(
971
+ valueArg.updatedAt,
972
+ "Release journal updatedAt",
973
+ );
425
974
  if (updatedAt < createdAt) {
426
975
  throw new Error("Release journal updatedAt predates createdAt.");
427
976
  }
@@ -430,23 +979,73 @@ export const assertReleaseJournal = (valueArg: unknown): IReleaseJournal => {
430
979
  valueArg.completedAt,
431
980
  "Release journal completedAt",
432
981
  );
433
- if (completedAt < createdAt || !isComplete(valueArg as unknown as IReleaseJournal)) {
982
+ if (
983
+ completedAt < createdAt ||
984
+ !isComplete(valueArg as unknown as IReleaseJournalV2)
985
+ ) {
434
986
  throw new Error("Release journal completion state is inconsistent.");
435
987
  }
436
988
  if (completedAt > updatedAt) {
437
989
  throw new Error("Release journal completedAt exceeds updatedAt.");
438
990
  }
439
- } else if (isComplete(valueArg as unknown as IReleaseJournal)) {
991
+ } else if (isComplete(valueArg as unknown as IReleaseJournalV2)) {
440
992
  throw new Error("Complete release journal must record completedAt.");
441
993
  }
994
+ return valueArg as unknown as IReleaseJournalV2;
995
+ };
996
+
997
+ export const assertReleaseJournal = (valueArg: unknown): TReleaseJournal => {
998
+ if (!isPlainObject(valueArg)) {
999
+ throw new Error("Release journal root must be a JSON object.");
1000
+ }
1001
+ if (valueArg.schemaVersion === legacyReleaseJournalSchemaVersion) {
1002
+ return assertReleaseJournalV1(valueArg);
1003
+ }
1004
+ if (valueArg.schemaVersion === releaseJournalSchemaVersion) {
1005
+ return assertReleaseJournalV2(valueArg);
1006
+ }
1007
+ throw new Error("Release journal header is invalid or unsupported.");
1008
+ };
1009
+
1010
+ const canonicalizeProtocolValue = <T>(valueArg: T): T =>
1011
+ JSON.parse(canonicalReleaseJson(valueArg)) as T;
442
1012
 
443
- return valueArg as unknown as IReleaseJournal;
1013
+ const prepareReleaseJournalSerialization = (
1014
+ journalArg: TReleaseJournal,
1015
+ ): TReleaseJournal => {
1016
+ const journal = assertReleaseJournal(journalArg);
1017
+ if (journal.schemaVersion === legacyReleaseJournalSchemaVersion) {
1018
+ return journal;
1019
+ }
1020
+ return {
1021
+ ...journal,
1022
+ docker: {
1023
+ ...journal.docker,
1024
+ request: canonicalizeProtocolValue(journal.docker.request),
1025
+ qualification: {
1026
+ ...journal.docker.qualification,
1027
+ result: journal.docker.qualification.result
1028
+ ? canonicalizeProtocolValue(journal.docker.qualification.result)
1029
+ : null,
1030
+ },
1031
+ promotions: journal.docker.promotions.map((promotionArg) => ({
1032
+ ...promotionArg,
1033
+ evidence: promotionArg.evidence.map(canonicalizeProtocolValue),
1034
+ })),
1035
+ cleanup: {
1036
+ ...journal.docker.cleanup,
1037
+ result: journal.docker.cleanup.result
1038
+ ? canonicalizeProtocolValue(journal.docker.cleanup.result)
1039
+ : null,
1040
+ },
1041
+ },
1042
+ };
444
1043
  };
445
1044
 
446
- export const serializeReleaseJournal = (journalArg: IReleaseJournal): string =>
447
- `${JSON.stringify(assertReleaseJournal(journalArg), null, 2)}\n`;
1045
+ export const serializeReleaseJournal = (journalArg: TReleaseJournal): string =>
1046
+ `${JSON.stringify(prepareReleaseJournalSerialization(journalArg), null, 2)}\n`;
448
1047
 
449
- export const parseReleaseJournal = (contentArg: string): IReleaseJournal => {
1048
+ export const parseReleaseJournal = (contentArg: string): TReleaseJournal => {
450
1049
  let parsed: unknown;
451
1050
  try {
452
1051
  parsed = JSON.parse(contentArg);
@@ -462,6 +1061,173 @@ export const parseReleaseJournal = (contentArg: string): IReleaseJournal => {
462
1061
  return journal;
463
1062
  };
464
1063
 
1064
+ const assertDockerStatusAppendOnly = (
1065
+ currentArg: IReleaseTargetStatus,
1066
+ nextArg: IReleaseTargetStatus,
1067
+ contextArg: string,
1068
+ optionsArg: { allowVerifiedConflict?: boolean } = {},
1069
+ ): void => {
1070
+ if (currentArg.state === nextArg.state) {
1071
+ if (canonicalReleaseJson(currentArg) !== canonicalReleaseJson(nextArg)) {
1072
+ throw new Error(
1073
+ `${contextArg} cannot change without a state transition.`,
1074
+ );
1075
+ }
1076
+ return;
1077
+ }
1078
+ if (
1079
+ (currentArg.state === "pending" || currentArg.state === "failed") &&
1080
+ nextArg.state === "publishing" &&
1081
+ nextArg.attempts === currentArg.attempts + 1
1082
+ ) {
1083
+ return;
1084
+ }
1085
+ if (
1086
+ currentArg.state === "publishing" &&
1087
+ (nextArg.state === "verified" ||
1088
+ nextArg.state === "failed" ||
1089
+ nextArg.state === "conflict") &&
1090
+ nextArg.attempts === currentArg.attempts
1091
+ ) {
1092
+ return;
1093
+ }
1094
+ if (
1095
+ optionsArg.allowVerifiedConflict &&
1096
+ currentArg.state === "verified" &&
1097
+ nextArg.state === "conflict" &&
1098
+ nextArg.attempts === currentArg.attempts
1099
+ ) {
1100
+ return;
1101
+ }
1102
+ throw new Error(`${contextArg} state transition is not append-only.`);
1103
+ };
1104
+
1105
+ const assertSchema2AppendOnlyTransition = (
1106
+ currentArg: IReleaseJournalV2,
1107
+ nextArg: IReleaseJournalV2,
1108
+ ): void => {
1109
+ const currentDocker = currentArg.docker;
1110
+ const nextDocker = nextArg.docker;
1111
+ if (
1112
+ currentDocker.qualification.state === "conflict" &&
1113
+ canonicalReleaseJson(currentDocker.qualification) !==
1114
+ canonicalReleaseJson(nextDocker.qualification)
1115
+ ) {
1116
+ throw new Error("Docker qualification conflict is terminal.");
1117
+ }
1118
+ assertDockerStatusAppendOnly(
1119
+ currentDocker.qualification,
1120
+ nextDocker.qualification,
1121
+ "Docker qualification",
1122
+ );
1123
+ if (
1124
+ currentDocker.qualification.result &&
1125
+ canonicalReleaseJson(currentDocker.qualification.result) !==
1126
+ canonicalReleaseJson(nextDocker.qualification.result)
1127
+ ) {
1128
+ throw new Error("Docker qualification evidence is append-only.");
1129
+ }
1130
+ if (
1131
+ currentDocker.qualification.result &&
1132
+ nextDocker.qualification.state !== "verified"
1133
+ ) {
1134
+ throw new Error("Verified Docker qualification cannot regress.");
1135
+ }
1136
+ if (
1137
+ !currentDocker.qualification.result &&
1138
+ nextDocker.qualification.result &&
1139
+ (currentDocker.qualification.state !== "publishing" ||
1140
+ nextDocker.qualification.state !== "verified")
1141
+ ) {
1142
+ throw new Error(
1143
+ "Docker qualification evidence requires its owned publishing transition.",
1144
+ );
1145
+ }
1146
+ if (
1147
+ currentDocker.promotions.length === 0 &&
1148
+ nextDocker.promotions.length > 0
1149
+ ) {
1150
+ if (
1151
+ currentDocker.qualification.state !== "publishing" ||
1152
+ nextDocker.qualification.state !== "verified" ||
1153
+ nextDocker.promotions.some(
1154
+ (promotionArg) =>
1155
+ promotionArg.state !== "pending" ||
1156
+ promotionArg.attempts !== 0 ||
1157
+ promotionArg.attempt !== null ||
1158
+ promotionArg.error !== null ||
1159
+ promotionArg.evidence.length !== 0,
1160
+ )
1161
+ ) {
1162
+ throw new Error(
1163
+ "Docker promotions must begin pending with no attempt or evidence.",
1164
+ );
1165
+ }
1166
+ } else if (currentDocker.promotions.length > 0) {
1167
+ if (
1168
+ currentDocker.promotions.length !== nextDocker.promotions.length ||
1169
+ currentDocker.promotions.some(
1170
+ (promotionArg, indexArg) =>
1171
+ promotionArg.promotionId !==
1172
+ nextDocker.promotions[indexArg]?.promotionId,
1173
+ )
1174
+ ) {
1175
+ throw new Error("Docker promotion identity is append-only.");
1176
+ }
1177
+ for (const [
1178
+ index,
1179
+ currentPromotion,
1180
+ ] of currentDocker.promotions.entries()) {
1181
+ const nextPromotion = nextDocker.promotions[index];
1182
+ if (
1183
+ currentPromotion.state === "conflict" &&
1184
+ canonicalReleaseJson(currentPromotion) !==
1185
+ canonicalReleaseJson(nextPromotion)
1186
+ ) {
1187
+ throw new Error(
1188
+ `Docker promotion ${currentPromotion.promotionId} conflict is terminal.`,
1189
+ );
1190
+ }
1191
+ assertDockerStatusAppendOnly(
1192
+ currentPromotion,
1193
+ nextPromotion,
1194
+ `Docker promotion ${currentPromotion.promotionId}`,
1195
+ { allowVerifiedConflict: true },
1196
+ );
1197
+ if (
1198
+ nextPromotion.evidence.length < currentPromotion.evidence.length ||
1199
+ currentPromotion.evidence.some(
1200
+ (evidenceArg, evidenceIndexArg) =>
1201
+ canonicalReleaseJson(evidenceArg) !==
1202
+ canonicalReleaseJson(nextPromotion.evidence[evidenceIndexArg]),
1203
+ )
1204
+ ) {
1205
+ throw new Error(
1206
+ `Docker promotion ${currentPromotion.promotionId} evidence is append-only.`,
1207
+ );
1208
+ }
1209
+ }
1210
+ }
1211
+ assertDockerStatusAppendOnly(
1212
+ currentDocker.cleanup,
1213
+ nextDocker.cleanup,
1214
+ "Docker cleanup",
1215
+ );
1216
+ if (
1217
+ currentDocker.cleanup.result &&
1218
+ canonicalReleaseJson(currentDocker.cleanup.result) !==
1219
+ canonicalReleaseJson(nextDocker.cleanup.result)
1220
+ ) {
1221
+ throw new Error("Docker cleanup evidence is append-only.");
1222
+ }
1223
+ if (
1224
+ currentDocker.cleanup.state === "verified" &&
1225
+ canonicalReleaseJson(currentDocker) !== canonicalReleaseJson(nextDocker)
1226
+ ) {
1227
+ throw new Error("Completed Docker publication state is terminal.");
1228
+ }
1229
+ };
1230
+
465
1231
  export const createReleaseAttempt = (): IReleaseAttempt => ({
466
1232
  id: plugins.crypto.randomBytes(16).toString("hex"),
467
1233
  pid: process.pid,
@@ -497,7 +1263,9 @@ export const resolveGitCommonDirectory = async (
497
1263
  !plugins.path.isAbsolute(commonDirectory) ||
498
1264
  !commonDirectory
499
1265
  ) {
500
- throw new Error("Unable to resolve the Git common directory for release state.");
1266
+ throw new Error(
1267
+ "Unable to resolve the Git common directory for release state.",
1268
+ );
501
1269
  }
502
1270
  return plugins.path.resolve(commonDirectory);
503
1271
  };
@@ -507,13 +1275,15 @@ export class ReleaseJournalStore {
507
1275
 
508
1276
  constructor(gitCommonDirectoryArg: string) {
509
1277
  if (!plugins.path.isAbsolute(gitCommonDirectoryArg)) {
510
- throw new Error("Release journal storage requires an absolute Git common directory.");
1278
+ throw new Error(
1279
+ "Release journal storage requires an absolute Git common directory.",
1280
+ );
511
1281
  }
512
1282
  this.rootPath = plugins.path.join(
513
1283
  plugins.path.resolve(gitCommonDirectoryArg),
514
1284
  "gitzone",
515
1285
  "releases",
516
- `v${releaseJournalSchemaVersion}`,
1286
+ `v${releaseJournalStorageVersion}`,
517
1287
  );
518
1288
  }
519
1289
 
@@ -532,10 +1302,12 @@ export class ReleaseJournalStore {
532
1302
  public async createTemporaryDirectory(versionArg: string): Promise<string> {
533
1303
  const version = normalizeReleaseVersion(versionArg);
534
1304
  await plugins.fs.mkdir(this.rootPath, { recursive: true, mode: 0o700 });
535
- return plugins.fs.mkdtemp(plugins.path.join(this.rootPath, `.v${version}.tmp-`));
1305
+ return plugins.fs.mkdtemp(
1306
+ plugins.path.join(this.rootPath, `.v${version}.tmp-`),
1307
+ );
536
1308
  }
537
1309
 
538
- public async read(versionArg: string): Promise<IReleaseJournal> {
1310
+ public async read(versionArg: string): Promise<TReleaseJournal> {
539
1311
  const version = normalizeReleaseVersion(versionArg);
540
1312
  const filePath = plugins.path.join(
541
1313
  this.getReleaseDirectory(version),
@@ -543,7 +1315,18 @@ export class ReleaseJournalStore {
543
1315
  );
544
1316
  let content: string;
545
1317
  try {
546
- content = await plugins.fs.readFile(filePath, "utf8");
1318
+ const bytes = await plugins.fs.readFile(filePath);
1319
+ if (
1320
+ bytes.byteLength >= 3 &&
1321
+ bytes[0] === 0xef &&
1322
+ bytes[1] === 0xbb &&
1323
+ bytes[2] === 0xbf
1324
+ ) {
1325
+ throw new Error(
1326
+ "Release journal must not contain a UTF-8 byte-order mark.",
1327
+ );
1328
+ }
1329
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
547
1330
  } catch (error) {
548
1331
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
549
1332
  throw new Error(`Release journal v${version} does not exist.`);
@@ -559,10 +1342,12 @@ export class ReleaseJournalStore {
559
1342
  return journal;
560
1343
  }
561
1344
 
562
- public async list(): Promise<IReleaseJournal[]> {
1345
+ public async list(): Promise<TReleaseJournal[]> {
563
1346
  let entries: Array<{ name: string; isDirectory: () => boolean }>;
564
1347
  try {
565
- entries = await plugins.fs.readdir(this.rootPath, { withFileTypes: true });
1348
+ entries = await plugins.fs.readdir(this.rootPath, {
1349
+ withFileTypes: true,
1350
+ });
566
1351
  } catch (error) {
567
1352
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
568
1353
  return [];
@@ -572,9 +1357,12 @@ export class ReleaseJournalStore {
572
1357
  });
573
1358
  }
574
1359
  const versions = entries
575
- .filter((entryArg) => entryArg.isDirectory() && /^v\d+\.\d+\.\d+$/.test(entryArg.name))
1360
+ .filter(
1361
+ (entryArg) =>
1362
+ entryArg.isDirectory() && /^v\d+\.\d+\.\d+$/.test(entryArg.name),
1363
+ )
576
1364
  .map((entryArg) => entryArg.name.slice(1));
577
- const journals: IReleaseJournal[] = [];
1365
+ const journals: TReleaseJournal[] = [];
578
1366
  for (const version of versions) {
579
1367
  journals.push(await this.read(version));
580
1368
  }
@@ -585,18 +1373,51 @@ export class ReleaseJournalStore {
585
1373
 
586
1374
  public async installPrepared(
587
1375
  temporaryDirectoryArg: string,
588
- journalArg: IReleaseJournal,
589
- ): Promise<IReleaseJournal> {
1376
+ journalArg: TReleaseJournal,
1377
+ ): Promise<TReleaseJournal> {
590
1378
  const journal = assertReleaseJournal(journalArg);
591
1379
  if (journal.revision !== 1) {
592
1380
  throw new Error("New release journals must start at revision 1.");
593
1381
  }
1382
+ if (journal.schemaVersion === releaseJournalSchemaVersion) {
1383
+ const commonTargets = [journal.git, ...journal.npm.registries];
1384
+ const commonTargetsUnclaimed = commonTargets.every(
1385
+ (targetArg) =>
1386
+ (targetArg.state === "pending" || targetArg.state === "skipped") &&
1387
+ targetArg.attempts === 0 &&
1388
+ targetArg.attempt === null &&
1389
+ targetArg.error === null,
1390
+ );
1391
+ if (
1392
+ !commonTargetsUnclaimed ||
1393
+ journal.completedAt !== null ||
1394
+ journal.docker.qualification.state !== "pending" ||
1395
+ journal.docker.qualification.attempts !== 0 ||
1396
+ journal.docker.qualification.attempt !== null ||
1397
+ journal.docker.qualification.error !== null ||
1398
+ journal.docker.qualification.result !== null ||
1399
+ journal.docker.promotions.length !== 0 ||
1400
+ journal.docker.cleanup.state !== "pending" ||
1401
+ journal.docker.cleanup.attempts !== 0 ||
1402
+ journal.docker.cleanup.attempt !== null ||
1403
+ journal.docker.cleanup.error !== null ||
1404
+ journal.docker.cleanup.result !== null
1405
+ ) {
1406
+ throw new Error(
1407
+ "New schema 2 release journals must start with unclaimed publication targets.",
1408
+ );
1409
+ }
1410
+ }
594
1411
  const temporaryDirectory = plugins.path.resolve(temporaryDirectoryArg);
595
1412
  if (
596
1413
  plugins.path.dirname(temporaryDirectory) !== this.rootPath ||
597
- !plugins.path.basename(temporaryDirectory).startsWith(`.v${journal.release.version}.tmp-`)
1414
+ !plugins.path
1415
+ .basename(temporaryDirectory)
1416
+ .startsWith(`.v${journal.release.version}.tmp-`)
598
1417
  ) {
599
- throw new Error("Prepared release directory is outside canonical storage.");
1418
+ throw new Error(
1419
+ "Prepared release directory is outside canonical storage.",
1420
+ );
600
1421
  }
601
1422
  if (journal.artifact) {
602
1423
  const artifactPath = plugins.path.join(
@@ -610,14 +1431,20 @@ export class ReleaseJournalStore {
610
1431
  const artifactBytes = await plugins.fs.readFile(artifactPath);
611
1432
  if (
612
1433
  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 ||
1434
+ plugins.crypto
1435
+ .createHash("sha1")
1436
+ .update(artifactBytes)
1437
+ .digest("hex") !== journal.artifact.sha1 ||
1438
+ plugins.crypto
1439
+ .createHash("sha256")
1440
+ .update(artifactBytes)
1441
+ .digest("hex") !== journal.artifact.sha256 ||
617
1442
  `sha512-${plugins.crypto.createHash("sha512").update(artifactBytes).digest("base64")}` !==
618
1443
  journal.artifact.integrity
619
1444
  ) {
620
- throw new Error("Prepared release artifact does not match its journal identity.");
1445
+ throw new Error(
1446
+ "Prepared release artifact does not match its journal identity.",
1447
+ );
621
1448
  }
622
1449
  }
623
1450
 
@@ -659,8 +1486,8 @@ export class ReleaseJournalStore {
659
1486
  public async transact(
660
1487
  versionArg: string,
661
1488
  expectedRevisionArg: number,
662
- operationArg: (journalArg: IReleaseJournal) => IReleaseJournal,
663
- ): Promise<IReleaseJournal> {
1489
+ operationArg: (journalArg: TReleaseJournal) => TReleaseJournal,
1490
+ ): Promise<TReleaseJournal> {
664
1491
  const version = normalizeReleaseVersion(versionArg);
665
1492
  const releaseDirectory = this.getReleaseDirectory(version);
666
1493
  const lock = new InterProcessLock({
@@ -682,8 +1509,18 @@ export class ReleaseJournalStore {
682
1509
  createdAt: current.createdAt,
683
1510
  updatedAt: now,
684
1511
  });
685
- if (immutableJournalIdentity(next) !== immutableJournalIdentity(current)) {
686
- throw new Error("Release journal immutable identity changed during a transaction.");
1512
+ if (
1513
+ immutableJournalIdentity(next) !== immutableJournalIdentity(current)
1514
+ ) {
1515
+ throw new Error(
1516
+ "Release journal immutable identity changed during a transaction.",
1517
+ );
1518
+ }
1519
+ if (
1520
+ current.schemaVersion === releaseJournalSchemaVersion &&
1521
+ next.schemaVersion === releaseJournalSchemaVersion
1522
+ ) {
1523
+ assertSchema2AppendOnlyTransition(current, next);
687
1524
  }
688
1525
  await this.writeAtomic(version, next);
689
1526
  return next;
@@ -692,10 +1529,13 @@ export class ReleaseJournalStore {
692
1529
 
693
1530
  private async writeAtomic(
694
1531
  versionArg: string,
695
- journalArg: IReleaseJournal,
1532
+ journalArg: TReleaseJournal,
696
1533
  ): Promise<void> {
697
1534
  const releaseDirectory = this.getReleaseDirectory(versionArg);
698
- const journalPath = plugins.path.join(releaseDirectory, releaseJournalFileName);
1535
+ const journalPath = plugins.path.join(
1536
+ releaseDirectory,
1537
+ releaseJournalFileName,
1538
+ );
699
1539
  const temporaryPath = plugins.path.join(
700
1540
  releaseDirectory,
701
1541
  `.journal.tmp-${process.pid}-${plugins.crypto.randomBytes(12).toString("hex")}`,
@@ -712,9 +1552,12 @@ export class ReleaseJournalStore {
712
1552
  } catch (error) {
713
1553
  await handle?.close().catch(() => {});
714
1554
  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
- });
1555
+ throw new Error(
1556
+ `Release journal v${versionArg} could not be updated atomically.`,
1557
+ {
1558
+ cause: error,
1559
+ },
1560
+ );
718
1561
  }
719
1562
  }
720
1563
  }
@@ -728,10 +1571,36 @@ export const createInitialTargetStatus = (
728
1571
  error: null,
729
1572
  });
730
1573
 
1574
+ export const createInitialReleaseDockerJournal = (
1575
+ requestArg: IReleaseQualificationRequest,
1576
+ ): IReleaseDockerJournal => ({
1577
+ engine: "tsdocker",
1578
+ protocolVersion: 1,
1579
+ request: assertReleaseQualificationRequest(requestArg),
1580
+ qualification: {
1581
+ ...createInitialTargetStatus(true),
1582
+ result: null,
1583
+ },
1584
+ promotions: [],
1585
+ cleanup: {
1586
+ ...createInitialTargetStatus(true),
1587
+ result: null,
1588
+ },
1589
+ });
1590
+
1591
+ export const createReleaseDockerPromotions = (
1592
+ resultArg: IReleaseQualificationResult,
1593
+ ): IReleaseDockerPromotionJournal[] =>
1594
+ getReleasePromotionContexts(resultArg).map((contextArg) => ({
1595
+ ...createInitialTargetStatus(true),
1596
+ promotionId: contextArg.promotionId,
1597
+ evidence: [],
1598
+ }));
1599
+
731
1600
  export const finalizeJournalCompletion = (
732
- journalArg: IReleaseJournal,
1601
+ journalArg: TReleaseJournal,
733
1602
  completedAtArg = new Date().toISOString(),
734
- ): IReleaseJournal => {
1603
+ ): TReleaseJournal => {
735
1604
  const completed = isComplete(journalArg);
736
1605
  return {
737
1606
  ...journalArg,