@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.
@@ -2,8 +2,11 @@ import * as plugins from "./mod.plugins.js";
2
2
  import {
3
3
  ReleaseJournalStore,
4
4
  createReleaseAttempt,
5
+ createReleaseDockerPromotions,
5
6
  finalizeJournalCompletion,
6
- type IReleaseJournal,
7
+ getReleaseDockerPromotionContext,
8
+ type TReleaseJournal,
9
+ type IReleaseJournalV2,
7
10
  type IReleaseTargetStatus,
8
11
  type TReleaseErrorCode,
9
12
  type TReleaseTargetState,
@@ -17,6 +20,18 @@ import {
17
20
  type INpmArtifactProbeResult,
18
21
  } from "./helpers.npmartifact.js";
19
22
  import { releaseGitEnv } from "./helpers.releasebranch.js";
23
+ import {
24
+ TsdockerReleaseCommandError,
25
+ TsdockerReleaseRequestCleanupError,
26
+ assertReleaseCleanupResult,
27
+ assertReleaseProbeResult,
28
+ assertReleasePromotionResult,
29
+ assertReleaseQualificationResult,
30
+ classifyReleasePromotionEvidence,
31
+ type ITsdockerReleaseClient,
32
+ type IReleaseProbeResult,
33
+ type TReleasePromotionEvidence,
34
+ } from "./helpers.tsdockerprotocol.js";
20
35
 
21
36
  const gitProbeTimeoutMs = 60_000;
22
37
  const gitPushTimeoutMs = 5 * 60_000;
@@ -39,9 +54,7 @@ export function buildReleaseGitPushArgs(
39
54
  refspecs.push(`${optionsArg.mainOid}:refs/heads/main`);
40
55
  }
41
56
  if (optionsArg.pushTags) {
42
- refspecs.push(
43
- `${optionsArg.tagOid}:refs/tags/v${optionsArg.newVersion}`,
44
- );
57
+ refspecs.push(`${optionsArg.tagOid}:refs/tags/v${optionsArg.newVersion}`);
45
58
  }
46
59
  const args = ["push", "--no-follow-tags"];
47
60
  if (refspecs.length > 1) {
@@ -58,7 +71,10 @@ export function buildReleaseGitPushArgs(
58
71
 
59
72
  type TReleaseTargetSelector =
60
73
  | { kind: "git" }
61
- | { kind: "npm"; registry: string };
74
+ | { kind: "npm"; registry: string }
75
+ | { kind: "docker-qualification" }
76
+ | { kind: "docker-promotion"; promotionId: string }
77
+ | { kind: "docker-cleanup" };
62
78
 
63
79
  type TGitProbeStatus = "pending" | "exact" | "conflict" | "inconclusive";
64
80
 
@@ -70,37 +86,66 @@ export interface IReleasePublicationOptions {
70
86
  smartshell: plugins.smartshell.Smartshell;
71
87
  cwd: string;
72
88
  store: ReleaseJournalStore;
73
- journal: IReleaseJournal;
89
+ journal: TReleaseJournal;
74
90
  pushUrl?: string;
75
91
  recoveryAttemptId?: string;
76
92
  npmProbeOptions?: INpmArtifactProbeOptions;
93
+ tsdocker?: ITsdockerReleaseClient;
77
94
  ensurePublicationState: (expectedRemoteMainOidArg?: string) => Promise<void>;
78
95
  }
79
96
 
80
97
  const getTargetStatus = (
81
- journalArg: IReleaseJournal,
98
+ journalArg: TReleaseJournal,
82
99
  selectorArg: TReleaseTargetSelector,
83
100
  ): IReleaseTargetStatus => {
84
101
  if (selectorArg.kind === "git") {
85
102
  return journalArg.git;
86
103
  }
104
+ if (selectorArg.kind === "docker-qualification") {
105
+ if (journalArg.schemaVersion !== 2) {
106
+ throw new Error("Release journal does not contain Docker qualification.");
107
+ }
108
+ return journalArg.docker.qualification;
109
+ }
110
+ if (selectorArg.kind === "docker-cleanup") {
111
+ if (journalArg.schemaVersion !== 2) {
112
+ throw new Error("Release journal does not contain Docker cleanup.");
113
+ }
114
+ return journalArg.docker.cleanup;
115
+ }
116
+ if (selectorArg.kind === "docker-promotion") {
117
+ if (journalArg.schemaVersion !== 2) {
118
+ throw new Error("Release journal does not contain Docker promotions.");
119
+ }
120
+ const promotion = journalArg.docker.promotions.find(
121
+ (promotionArg) => promotionArg.promotionId === selectorArg.promotionId,
122
+ );
123
+ if (!promotion) {
124
+ throw new Error(
125
+ `Release journal does not contain Docker promotion ${selectorArg.promotionId}.`,
126
+ );
127
+ }
128
+ return promotion;
129
+ }
87
130
  const registry = journalArg.npm.registries.find(
88
131
  (registryArg) => registryArg.registry === selectorArg.registry,
89
132
  );
90
133
  if (!registry) {
91
- throw new Error(`Release journal does not contain npm registry ${selectorArg.registry}.`);
134
+ throw new Error(
135
+ `Release journal does not contain npm registry ${selectorArg.registry}.`,
136
+ );
92
137
  }
93
138
  return registry;
94
139
  };
95
140
 
96
141
  const updateTarget = async (
97
142
  storeArg: ReleaseJournalStore,
98
- journalArg: IReleaseJournal,
143
+ journalArg: TReleaseJournal,
99
144
  selectorArg: TReleaseTargetSelector,
100
145
  expectedStateArg: TReleaseTargetState | TReleaseTargetState[],
101
146
  expectedAttemptIdArg: string | null,
102
147
  updateArg: (statusArg: IReleaseTargetStatus) => void,
103
- ): Promise<IReleaseJournal> => {
148
+ ): Promise<TReleaseJournal> => {
104
149
  const expectedStates = Array.isArray(expectedStateArg)
105
150
  ? expectedStateArg
106
151
  : [expectedStateArg];
@@ -113,7 +158,9 @@ const updateTarget = async (
113
158
  throw new Error("Release target state changed concurrently.");
114
159
  }
115
160
  if ((status.attempt?.id || null) !== expectedAttemptIdArg) {
116
- throw new Error("Release target attempt ownership changed concurrently.");
161
+ throw new Error(
162
+ "Release target attempt ownership changed concurrently.",
163
+ );
117
164
  }
118
165
  updateArg(status);
119
166
  return finalizeJournalCompletion(currentArg);
@@ -123,9 +170,9 @@ const updateTarget = async (
123
170
 
124
171
  const claimTarget = async (
125
172
  storeArg: ReleaseJournalStore,
126
- journalArg: IReleaseJournal,
173
+ journalArg: TReleaseJournal,
127
174
  selectorArg: TReleaseTargetSelector,
128
- ): Promise<IReleaseJournal> =>
175
+ ): Promise<TReleaseJournal> =>
129
176
  updateTarget(
130
177
  storeArg,
131
178
  journalArg,
@@ -142,11 +189,11 @@ const claimTarget = async (
142
189
 
143
190
  const finishPublishingTarget = async (
144
191
  storeArg: ReleaseJournalStore,
145
- journalArg: IReleaseJournal,
192
+ journalArg: TReleaseJournal,
146
193
  selectorArg: TReleaseTargetSelector,
147
194
  stateArg: "verified" | "failed" | "conflict",
148
195
  errorArg: TReleaseErrorCode | null,
149
- ): Promise<IReleaseJournal> => {
196
+ ): Promise<TReleaseJournal> => {
150
197
  const attemptId = getTargetStatus(journalArg, selectorArg).attempt?.id;
151
198
  if (!attemptId) {
152
199
  throw new Error("Release target has no active publication attempt.");
@@ -167,12 +214,12 @@ const finishPublishingTarget = async (
167
214
 
168
215
  const markUnclaimedTarget = async (
169
216
  storeArg: ReleaseJournalStore,
170
- journalArg: IReleaseJournal,
217
+ journalArg: TReleaseJournal,
171
218
  selectorArg: TReleaseTargetSelector,
172
219
  expectedStatesArg: TReleaseTargetState[],
173
220
  stateArg: "verified" | "failed" | "conflict",
174
221
  errorArg: TReleaseErrorCode | null,
175
- ): Promise<IReleaseJournal> =>
222
+ ): Promise<TReleaseJournal> =>
176
223
  updateTarget(
177
224
  storeArg,
178
225
  journalArg,
@@ -187,10 +234,10 @@ const markUnclaimedTarget = async (
187
234
 
188
235
  const recoverPublishingTarget = async (
189
236
  storeArg: ReleaseJournalStore,
190
- journalArg: IReleaseJournal,
237
+ journalArg: TReleaseJournal,
191
238
  selectorArg: TReleaseTargetSelector,
192
239
  recoveryAttemptIdArg: string | undefined,
193
- ): Promise<IReleaseJournal> => {
240
+ ): Promise<TReleaseJournal> => {
194
241
  const status = getTargetStatus(journalArg, selectorArg);
195
242
  const attemptId = status.attempt?.id;
196
243
  if (!attemptId || recoveryAttemptIdArg !== attemptId) {
@@ -212,18 +259,12 @@ const inspectRemoteRelease = async (
212
259
  smartshellArg: plugins.smartshell.Smartshell,
213
260
  cwdArg: string,
214
261
  destinationArg: string,
215
- journalArg: IReleaseJournal,
262
+ journalArg: TReleaseJournal,
216
263
  ): Promise<IGitProbeResult> => {
217
264
  const tagRef = `refs/tags/${journalArg.release.tag}`;
218
265
  const result = await smartshellArg.execSpawn(
219
266
  "git",
220
- [
221
- "ls-remote",
222
- destinationArg,
223
- "refs/heads/main",
224
- tagRef,
225
- `${tagRef}^{}`,
226
- ],
267
+ ["ls-remote", destinationArg, "refs/heads/main", tagRef, `${tagRef}^{}`],
227
268
  {
228
269
  cwd: cwdArg,
229
270
  env: releaseGitEnv,
@@ -262,8 +303,8 @@ const inspectRemoteRelease = async (
262
303
 
263
304
  const executeGitTarget = async (
264
305
  optionsArg: IReleasePublicationOptions,
265
- journalArg: IReleaseJournal,
266
- ): Promise<IReleaseJournal> => {
306
+ journalArg: TReleaseJournal,
307
+ ): Promise<TReleaseJournal> => {
267
308
  const selector: TReleaseTargetSelector = { kind: "git" };
268
309
  if (journalArg.git.state === "skipped") {
269
310
  return journalArg;
@@ -335,7 +376,9 @@ const executeGitTarget = async (
335
376
  }
336
377
 
337
378
  if (journal.git.state === "conflict") {
338
- throw new Error("Git release target is in conflict and cannot resume automatically.");
379
+ throw new Error(
380
+ "Git release target is in conflict and cannot resume automatically.",
381
+ );
339
382
  }
340
383
  probe = await inspectRemoteRelease(
341
384
  optionsArg.smartshell,
@@ -430,15 +473,13 @@ const executeGitTarget = async (
430
473
  journal,
431
474
  selector,
432
475
  "failed",
433
- pushResult.exitCode === 0
434
- ? "verification-inconclusive"
435
- : "command-failed",
476
+ pushResult.exitCode === 0 ? "verification-inconclusive" : "command-failed",
436
477
  );
437
478
  throw new Error("Git publication did not produce verifiable remote refs.");
438
479
  };
439
480
 
440
481
  const probeNpm = async (
441
- journalArg: IReleaseJournal,
482
+ journalArg: TReleaseJournal,
442
483
  registryArg: string,
443
484
  optionsArg: IReleasePublicationOptions,
444
485
  waitArg = false,
@@ -463,11 +504,11 @@ const probeNpm = async (
463
504
 
464
505
  const markNpmProbeFailure = async (
465
506
  optionsArg: IReleasePublicationOptions,
466
- journalArg: IReleaseJournal,
507
+ journalArg: TReleaseJournal,
467
508
  selectorArg: TReleaseTargetSelector,
468
509
  expectedStatesArg: TReleaseTargetState[],
469
510
  probeArg: INpmArtifactProbeResult,
470
- ): Promise<IReleaseJournal> => {
511
+ ): Promise<TReleaseJournal> => {
471
512
  const conflict = probeArg.status === "conflict";
472
513
  return markUnclaimedTarget(
473
514
  optionsArg.store,
@@ -481,10 +522,13 @@ const markNpmProbeFailure = async (
481
522
 
482
523
  const executeNpmRegistry = async (
483
524
  optionsArg: IReleasePublicationOptions,
484
- journalArg: IReleaseJournal,
525
+ journalArg: TReleaseJournal,
485
526
  registryArg: string,
486
- ): Promise<IReleaseJournal> => {
487
- const selector: TReleaseTargetSelector = { kind: "npm", registry: registryArg };
527
+ ): Promise<TReleaseJournal> => {
528
+ const selector: TReleaseTargetSelector = {
529
+ kind: "npm",
530
+ registry: registryArg,
531
+ };
488
532
  let journal = journalArg;
489
533
  let status = getTargetStatus(journal, selector);
490
534
  let probe = await probeNpm(journal, registryArg, optionsArg);
@@ -502,7 +546,9 @@ const executeNpmRegistry = async (
502
546
  "conflict",
503
547
  "artifact-conflict",
504
548
  );
505
- throw new Error(`Previously verified npm registry drifted: ${registryArg}`);
549
+ throw new Error(
550
+ `Previously verified npm registry drifted: ${registryArg}`,
551
+ );
506
552
  }
507
553
  throw new Error(
508
554
  `Previously verified npm registry cannot currently be verified: ${registryArg}`,
@@ -526,10 +572,14 @@ const executeNpmRegistry = async (
526
572
  "conflict",
527
573
  "artifact-conflict",
528
574
  );
529
- throw new Error(`npm registry contains conflicting bytes: ${registryArg}`);
575
+ throw new Error(
576
+ `npm registry contains conflicting bytes: ${registryArg}`,
577
+ );
530
578
  }
531
579
  if (probe.status === "inconclusive") {
532
- throw new Error(`npm registry publication is inconclusive: ${registryArg}`);
580
+ throw new Error(
581
+ `npm registry publication is inconclusive: ${registryArg}`,
582
+ );
533
583
  }
534
584
  journal = await recoverPublishingTarget(
535
585
  optionsArg.store,
@@ -576,7 +626,9 @@ const executeNpmRegistry = async (
576
626
  throw new Error(`npm registry cannot be verified safely: ${registryArg}`);
577
627
  }
578
628
 
579
- const artifactPath = optionsArg.store.getArtifactPath(journal.release.version);
629
+ const artifactPath = optionsArg.store.getArtifactPath(
630
+ journal.release.version,
631
+ );
580
632
  if (!journal.artifact) {
581
633
  throw new Error("npm release journal has no artifact.");
582
634
  }
@@ -620,10 +672,686 @@ const executeNpmRegistry = async (
620
672
  throw new Error(`npm publication could not be verified: ${registryArg}`);
621
673
  };
622
674
 
675
+ const requireTsdocker = (
676
+ optionsArg: IReleasePublicationOptions,
677
+ ): ITsdockerReleaseClient => {
678
+ if (!optionsArg.tsdocker) {
679
+ throw new Error(
680
+ "Docker release journal requires the verified project-local tSDocker protocol client.",
681
+ );
682
+ }
683
+ return optionsArg.tsdocker;
684
+ };
685
+
686
+ const getDockerRequestDirectory = (
687
+ optionsArg: IReleasePublicationOptions,
688
+ journalArg: IReleaseJournalV2,
689
+ ): string => optionsArg.store.getReleaseDirectory(journalArg.release.version);
690
+
691
+ const assertDockerRecoveryAttempt = (
692
+ statusArg: IReleaseTargetStatus,
693
+ recoveryAttemptIdArg: string | undefined,
694
+ ): string => {
695
+ const attemptId = statusArg.attempt?.id;
696
+ if (!attemptId || recoveryAttemptIdArg !== attemptId) {
697
+ throw new Error(
698
+ `Release target has unresolved publication attempt ${attemptId || "unknown"}. ` +
699
+ `After proving its publisher stopped, retry with --recover-attempt=${attemptId || "<attempt-id>"}.`,
700
+ );
701
+ }
702
+ return attemptId;
703
+ };
704
+
705
+ const reclaimDockerRequestOwnership = async (
706
+ optionsArg: IReleasePublicationOptions,
707
+ journalArg: IReleaseJournalV2,
708
+ statusArg: IReleaseTargetStatus,
709
+ ): Promise<string> => {
710
+ const attemptId = assertDockerRecoveryAttempt(
711
+ statusArg,
712
+ optionsArg.recoveryAttemptId,
713
+ );
714
+ await requireTsdocker(optionsArg).reclaimRequestFiles(
715
+ getDockerRequestDirectory(optionsArg, journalArg),
716
+ [attemptId],
717
+ );
718
+ return attemptId;
719
+ };
720
+
721
+ const recoverRequestCleanupOutcome = async <T>(
722
+ optionsArg: IReleasePublicationOptions,
723
+ journalArg: IReleaseJournalV2,
724
+ ownerIdArg: string,
725
+ errorArg: unknown,
726
+ assertResultArg: (valueArg: unknown) => T,
727
+ ): Promise<T> => {
728
+ if (!(errorArg instanceof TsdockerReleaseRequestCleanupError)) {
729
+ throw errorArg;
730
+ }
731
+ try {
732
+ await requireTsdocker(optionsArg).reclaimRequestFiles(
733
+ getDockerRequestDirectory(optionsArg, journalArg),
734
+ [ownerIdArg],
735
+ );
736
+ } catch (reclamationError) {
737
+ errorArg.reclamationError = reclamationError;
738
+ throw errorArg;
739
+ }
740
+ if (errorArg.result !== undefined) {
741
+ return assertResultArg(errorArg.result);
742
+ }
743
+ if (errorArg.commandError !== undefined) {
744
+ throw errorArg.commandError;
745
+ }
746
+ throw errorArg;
747
+ };
748
+
749
+ const finishDockerQualification = async (
750
+ optionsArg: IReleasePublicationOptions,
751
+ journalArg: IReleaseJournalV2,
752
+ resultArg: IReleaseJournalV2["docker"]["qualification"]["result"],
753
+ stateArg: "verified" | "conflict",
754
+ errorArg: TReleaseErrorCode | null,
755
+ ): Promise<IReleaseJournalV2> => {
756
+ const attemptId = journalArg.docker.qualification.attempt?.id;
757
+ if (!attemptId) {
758
+ throw new Error("Docker qualification has no active publication attempt.");
759
+ }
760
+ return (await optionsArg.store.transact(
761
+ journalArg.release.version,
762
+ journalArg.revision,
763
+ (currentArg) => {
764
+ if (currentArg.schemaVersion !== 2) {
765
+ throw new Error("Docker qualification requires journal schema 2.");
766
+ }
767
+ const qualification = currentArg.docker.qualification;
768
+ if (
769
+ qualification.state !== "publishing" ||
770
+ qualification.attempt?.id !== attemptId
771
+ ) {
772
+ throw new Error("Docker qualification ownership changed concurrently.");
773
+ }
774
+ qualification.state = stateArg;
775
+ qualification.attempt = null;
776
+ qualification.error = errorArg;
777
+ qualification.result = resultArg;
778
+ if (resultArg) {
779
+ currentArg.docker.promotions = createReleaseDockerPromotions(resultArg);
780
+ }
781
+ return finalizeJournalCompletion(currentArg);
782
+ },
783
+ )) as IReleaseJournalV2;
784
+ };
785
+
786
+ const finishDockerCleanup = async (
787
+ optionsArg: IReleasePublicationOptions,
788
+ journalArg: IReleaseJournalV2,
789
+ resultArg: IReleaseJournalV2["docker"]["cleanup"]["result"],
790
+ ): Promise<IReleaseJournalV2> => {
791
+ const attemptId = journalArg.docker.cleanup.attempt?.id;
792
+ if (!attemptId) {
793
+ throw new Error("Docker cleanup has no active publication attempt.");
794
+ }
795
+ return (await optionsArg.store.transact(
796
+ journalArg.release.version,
797
+ journalArg.revision,
798
+ (currentArg) => {
799
+ if (currentArg.schemaVersion !== 2) {
800
+ throw new Error("Docker cleanup requires journal schema 2.");
801
+ }
802
+ const cleanup = currentArg.docker.cleanup;
803
+ if (cleanup.state !== "publishing" || cleanup.attempt?.id !== attemptId) {
804
+ throw new Error("Docker cleanup ownership changed concurrently.");
805
+ }
806
+ cleanup.state = "verified";
807
+ cleanup.attempt = null;
808
+ cleanup.error = null;
809
+ cleanup.result = resultArg;
810
+ return finalizeJournalCompletion(currentArg);
811
+ },
812
+ )) as IReleaseJournalV2;
813
+ };
814
+
815
+ const runClaimedDockerCleanup = async (
816
+ optionsArg: IReleasePublicationOptions,
817
+ journalArg: IReleaseJournalV2,
818
+ ): Promise<IReleaseJournalV2> => {
819
+ const client = requireTsdocker(optionsArg);
820
+ const ownerId = journalArg.docker.cleanup.attempt?.id;
821
+ if (!ownerId) {
822
+ throw new Error("Docker cleanup has no active publication attempt.");
823
+ }
824
+ let result: IReleaseJournalV2["docker"]["cleanup"]["result"];
825
+ try {
826
+ result = await client.cleanup(
827
+ journalArg.docker.request,
828
+ getDockerRequestDirectory(optionsArg, journalArg),
829
+ ownerId,
830
+ );
831
+ } catch (error) {
832
+ result = await recoverRequestCleanupOutcome(
833
+ optionsArg,
834
+ journalArg,
835
+ ownerId,
836
+ error,
837
+ (valueArg) =>
838
+ assertReleaseCleanupResult(valueArg, journalArg.docker.request),
839
+ );
840
+ }
841
+ return finishDockerCleanup(optionsArg, journalArg, result);
842
+ };
843
+
844
+ const executeDockerCleanup = async (
845
+ optionsArg: IReleasePublicationOptions,
846
+ journalArg: IReleaseJournalV2,
847
+ ): Promise<IReleaseJournalV2> => {
848
+ let journal = journalArg;
849
+ if (journal.docker.cleanup.state === "verified") return journal;
850
+ const qualificationConflict =
851
+ journal.docker.qualification.state === "conflict" &&
852
+ journal.docker.qualification.error === "destination-conflict" &&
853
+ journal.docker.promotions.length === 0;
854
+ const promotionsVerified =
855
+ journal.docker.promotions.length > 0 &&
856
+ journal.docker.promotions.every(
857
+ (promotionArg) => promotionArg.state === "verified",
858
+ );
859
+ if (!qualificationConflict && !promotionsVerified) {
860
+ throw new Error(
861
+ "Docker candidate cleanup requires verified promotions or a terminal qualification conflict.",
862
+ );
863
+ }
864
+ if (journal.docker.cleanup.state === "publishing") {
865
+ await reclaimDockerRequestOwnership(
866
+ optionsArg,
867
+ journal,
868
+ journal.docker.cleanup,
869
+ );
870
+ journal = (await finishPublishingTarget(
871
+ optionsArg.store,
872
+ journal,
873
+ { kind: "docker-cleanup" },
874
+ "failed",
875
+ "attempt-recovery-required",
876
+ )) as IReleaseJournalV2;
877
+ }
878
+ if (journal.docker.cleanup.state === "conflict") {
879
+ throw new Error("Docker cleanup is in conflict.");
880
+ }
881
+ if (!qualificationConflict) {
882
+ await optionsArg.ensurePublicationState(journal.release.mainOid);
883
+ }
884
+ journal = (await claimTarget(optionsArg.store, journal, {
885
+ kind: "docker-cleanup",
886
+ })) as IReleaseJournalV2;
887
+ return runClaimedDockerCleanup(optionsArg, journal);
888
+ };
889
+
890
+ const cleanupIncompleteQualificationPreparation = async (
891
+ optionsArg: IReleasePublicationOptions,
892
+ journalArg: IReleaseJournalV2,
893
+ ): Promise<void> => {
894
+ const ownerId = journalArg.docker.qualification.attempt?.id;
895
+ if (!ownerId) {
896
+ throw new Error("Docker qualification has no active publication attempt.");
897
+ }
898
+ const client = requireTsdocker(optionsArg);
899
+ try {
900
+ await client.cleanup(
901
+ journalArg.docker.request,
902
+ getDockerRequestDirectory(optionsArg, journalArg),
903
+ ownerId,
904
+ );
905
+ } catch (error) {
906
+ await recoverRequestCleanupOutcome(
907
+ optionsArg,
908
+ journalArg,
909
+ ownerId,
910
+ error,
911
+ (valueArg) =>
912
+ assertReleaseCleanupResult(valueArg, journalArg.docker.request),
913
+ );
914
+ }
915
+ };
916
+
917
+ const runClaimedDockerQualification = async (
918
+ optionsArg: IReleasePublicationOptions,
919
+ journalArg: IReleaseJournalV2,
920
+ incompletePreparationCleanupAvailableArg: boolean,
921
+ ): Promise<IReleaseJournalV2> => {
922
+ const client = requireTsdocker(optionsArg);
923
+ const ownerId = journalArg.docker.qualification.attempt?.id;
924
+ if (!ownerId) {
925
+ throw new Error("Docker qualification has no active publication attempt.");
926
+ }
927
+ let result: IReleaseJournalV2["docker"]["qualification"]["result"];
928
+ try {
929
+ result = await client.qualify(
930
+ journalArg.docker.request,
931
+ getDockerRequestDirectory(optionsArg, journalArg),
932
+ ownerId,
933
+ );
934
+ } catch (error) {
935
+ try {
936
+ result = await recoverRequestCleanupOutcome(
937
+ optionsArg,
938
+ journalArg,
939
+ ownerId,
940
+ error,
941
+ (valueArg) =>
942
+ assertReleaseQualificationResult(valueArg, journalArg.docker.request),
943
+ );
944
+ } catch (commandError) {
945
+ if (
946
+ commandError instanceof TsdockerReleaseCommandError &&
947
+ commandError.code === "DESTINATION_DIGEST_CONFLICT"
948
+ ) {
949
+ return finishDockerQualification(
950
+ optionsArg,
951
+ journalArg,
952
+ null,
953
+ "conflict",
954
+ "destination-conflict",
955
+ );
956
+ }
957
+ if (
958
+ incompletePreparationCleanupAvailableArg &&
959
+ commandError instanceof TsdockerReleaseCommandError &&
960
+ commandError.code === "RECOVERY_REQUIRED"
961
+ ) {
962
+ await cleanupIncompleteQualificationPreparation(optionsArg, journalArg);
963
+ let journal = (await finishPublishingTarget(
964
+ optionsArg.store,
965
+ journalArg,
966
+ { kind: "docker-qualification" },
967
+ "failed",
968
+ "attempt-recovery-required",
969
+ )) as IReleaseJournalV2;
970
+ journal = (await claimTarget(optionsArg.store, journal, {
971
+ kind: "docker-qualification",
972
+ })) as IReleaseJournalV2;
973
+ return runClaimedDockerQualification(optionsArg, journal, false);
974
+ }
975
+ throw commandError;
976
+ }
977
+ }
978
+ return finishDockerQualification(
979
+ optionsArg,
980
+ journalArg,
981
+ result,
982
+ "verified",
983
+ null,
984
+ );
985
+ };
986
+
987
+ const executeDockerQualification = async (
988
+ optionsArg: IReleasePublicationOptions,
989
+ journalArg: IReleaseJournalV2,
990
+ ): Promise<IReleaseJournalV2> => {
991
+ let journal = journalArg;
992
+ if (journal.docker.qualification.state === "verified") {
993
+ return journal;
994
+ }
995
+ if (journal.docker.qualification.state === "conflict") {
996
+ await executeDockerCleanup(optionsArg, journal);
997
+ throw new Error(
998
+ "Docker qualification destination conflict is terminal; the candidate was cleaned and Git/npm publication was not attempted.",
999
+ );
1000
+ }
1001
+ if (journal.docker.qualification.state === "publishing") {
1002
+ await reclaimDockerRequestOwnership(
1003
+ optionsArg,
1004
+ journal,
1005
+ journal.docker.qualification,
1006
+ );
1007
+ journal = (await finishPublishingTarget(
1008
+ optionsArg.store,
1009
+ journal,
1010
+ { kind: "docker-qualification" },
1011
+ "failed",
1012
+ "attempt-recovery-required",
1013
+ )) as IReleaseJournalV2;
1014
+ }
1015
+ await optionsArg.ensurePublicationState(
1016
+ journal.git.expectedRemoteMainOid || undefined,
1017
+ );
1018
+ journal = (await claimTarget(optionsArg.store, journal, {
1019
+ kind: "docker-qualification",
1020
+ })) as IReleaseJournalV2;
1021
+ journal = await runClaimedDockerQualification(optionsArg, journal, true);
1022
+ if (journal.docker.qualification.state === "conflict") {
1023
+ await executeDockerCleanup(optionsArg, journal);
1024
+ throw new Error(
1025
+ "Docker qualification destination conflict is terminal; the candidate was cleaned and Git/npm publication was not attempted.",
1026
+ );
1027
+ }
1028
+ return journal;
1029
+ };
1030
+
1031
+ const recordDockerPromotion = async (
1032
+ optionsArg: IReleasePublicationOptions,
1033
+ journalArg: IReleaseJournalV2,
1034
+ promotionIdArg: string,
1035
+ expectedStatesArg: TReleaseTargetState[],
1036
+ expectedAttemptIdArg: string | null,
1037
+ stateArg: "verified" | "failed" | "conflict",
1038
+ errorArg: TReleaseErrorCode | null,
1039
+ evidenceArg?: TReleasePromotionEvidence,
1040
+ ): Promise<IReleaseJournalV2> =>
1041
+ (await optionsArg.store.transact(
1042
+ journalArg.release.version,
1043
+ journalArg.revision,
1044
+ (currentArg) => {
1045
+ if (currentArg.schemaVersion !== 2) {
1046
+ throw new Error("Docker promotion requires journal schema 2.");
1047
+ }
1048
+ const promotion = currentArg.docker.promotions.find(
1049
+ (promotionArg) => promotionArg.promotionId === promotionIdArg,
1050
+ );
1051
+ if (
1052
+ !promotion ||
1053
+ !expectedStatesArg.includes(promotion.state) ||
1054
+ (promotion.attempt?.id || null) !== expectedAttemptIdArg
1055
+ ) {
1056
+ throw new Error("Docker promotion ownership changed concurrently.");
1057
+ }
1058
+ promotion.state = stateArg;
1059
+ promotion.attempt = null;
1060
+ promotion.error = errorArg;
1061
+ if (evidenceArg) promotion.evidence.push(evidenceArg);
1062
+ return finalizeJournalCompletion(currentArg);
1063
+ },
1064
+ )) as IReleaseJournalV2;
1065
+
1066
+ const probeDockerPromotion = async (
1067
+ optionsArg: IReleasePublicationOptions,
1068
+ journalArg: IReleaseJournalV2,
1069
+ promotionIdArg: string,
1070
+ ownerIdArg?: string,
1071
+ ): Promise<IReleaseProbeResult> => {
1072
+ const context = getReleaseDockerPromotionContext(journalArg, promotionIdArg);
1073
+ try {
1074
+ return await requireTsdocker(optionsArg).probe(
1075
+ journalArg.docker.request,
1076
+ context,
1077
+ ownerIdArg
1078
+ ? getDockerRequestDirectory(optionsArg, journalArg)
1079
+ : undefined,
1080
+ ownerIdArg,
1081
+ );
1082
+ } catch (error) {
1083
+ if (!ownerIdArg) throw error;
1084
+ return recoverRequestCleanupOutcome(
1085
+ optionsArg,
1086
+ journalArg,
1087
+ ownerIdArg,
1088
+ error,
1089
+ (valueArg) =>
1090
+ assertReleaseProbeResult(valueArg, journalArg.docker.request, context),
1091
+ );
1092
+ }
1093
+ };
1094
+
1095
+ const executeDockerPromotion = async (
1096
+ optionsArg: IReleasePublicationOptions,
1097
+ journalArg: IReleaseJournalV2,
1098
+ promotionIdArg: string,
1099
+ ): Promise<IReleaseJournalV2> => {
1100
+ let journal = journalArg;
1101
+ let promotion = journal.docker.promotions.find(
1102
+ (promotionArg) => promotionArg.promotionId === promotionIdArg,
1103
+ );
1104
+ if (!promotion) {
1105
+ throw new Error(`Docker promotion ${promotionIdArg} is not journaled.`);
1106
+ }
1107
+ if (promotion.state === "conflict") {
1108
+ throw new Error(`Docker promotion ${promotionIdArg} is in conflict.`);
1109
+ }
1110
+
1111
+ if (promotion.state === "verified") {
1112
+ const probe = await probeDockerPromotion(
1113
+ optionsArg,
1114
+ journal,
1115
+ promotionIdArg,
1116
+ );
1117
+ const classification = classifyReleasePromotionEvidence(probe);
1118
+ if (classification === "exact") return journal;
1119
+ if (classification === "inconclusive") {
1120
+ throw new Error(
1121
+ `Previously verified Docker promotion cannot currently be verified: ${promotionIdArg}`,
1122
+ );
1123
+ }
1124
+ await recordDockerPromotion(
1125
+ optionsArg,
1126
+ journal,
1127
+ promotionIdArg,
1128
+ ["verified"],
1129
+ null,
1130
+ "conflict",
1131
+ "destination-conflict",
1132
+ probe,
1133
+ );
1134
+ throw new Error(
1135
+ `Previously verified Docker promotion drifted: ${promotionIdArg}`,
1136
+ );
1137
+ }
1138
+
1139
+ let recoveredPublishingAttempt = false;
1140
+ if (promotion.state === "publishing") {
1141
+ await reclaimDockerRequestOwnership(optionsArg, journal, promotion);
1142
+ recoveredPublishingAttempt = true;
1143
+ } else {
1144
+ await optionsArg.ensurePublicationState(journal.release.mainOid);
1145
+ journal = (await claimTarget(optionsArg.store, journal, {
1146
+ kind: "docker-promotion",
1147
+ promotionId: promotionIdArg,
1148
+ })) as IReleaseJournalV2;
1149
+ promotion = journal.docker.promotions.find(
1150
+ (promotionArg) => promotionArg.promotionId === promotionIdArg,
1151
+ )!;
1152
+ }
1153
+
1154
+ let attemptId = promotion.attempt?.id;
1155
+ if (!attemptId) {
1156
+ throw new Error("Docker promotion has no active publication attempt.");
1157
+ }
1158
+ let probe: IReleaseProbeResult;
1159
+ try {
1160
+ probe = await probeDockerPromotion(
1161
+ optionsArg,
1162
+ journal,
1163
+ promotionIdArg,
1164
+ attemptId,
1165
+ );
1166
+ } catch (error) {
1167
+ throw error;
1168
+ }
1169
+ let classification = classifyReleasePromotionEvidence(probe);
1170
+ if (classification === "exact") {
1171
+ return recordDockerPromotion(
1172
+ optionsArg,
1173
+ journal,
1174
+ promotionIdArg,
1175
+ ["publishing"],
1176
+ attemptId,
1177
+ "verified",
1178
+ null,
1179
+ probe,
1180
+ );
1181
+ }
1182
+ if (classification === "conflict") {
1183
+ await recordDockerPromotion(
1184
+ optionsArg,
1185
+ journal,
1186
+ promotionIdArg,
1187
+ ["publishing"],
1188
+ attemptId,
1189
+ "conflict",
1190
+ "destination-conflict",
1191
+ probe,
1192
+ );
1193
+ throw new Error(`Docker promotion conflicted: ${promotionIdArg}`);
1194
+ }
1195
+ if (classification === "inconclusive") {
1196
+ if (!recoveredPublishingAttempt) {
1197
+ await recordDockerPromotion(
1198
+ optionsArg,
1199
+ journal,
1200
+ promotionIdArg,
1201
+ ["publishing"],
1202
+ attemptId,
1203
+ "failed",
1204
+ "verification-inconclusive",
1205
+ );
1206
+ }
1207
+ throw new Error(
1208
+ `Docker promotion probe is inconclusive: ${promotionIdArg}`,
1209
+ );
1210
+ }
1211
+ if (recoveredPublishingAttempt) {
1212
+ journal = (await finishPublishingTarget(
1213
+ optionsArg.store,
1214
+ journal,
1215
+ { kind: "docker-promotion", promotionId: promotionIdArg },
1216
+ "failed",
1217
+ "attempt-recovery-required",
1218
+ )) as IReleaseJournalV2;
1219
+ promotion = journal.docker.promotions.find(
1220
+ (promotionArg) => promotionArg.promotionId === promotionIdArg,
1221
+ )!;
1222
+ await optionsArg.ensurePublicationState(journal.release.mainOid);
1223
+ journal = (await claimTarget(optionsArg.store, journal, {
1224
+ kind: "docker-promotion",
1225
+ promotionId: promotionIdArg,
1226
+ })) as IReleaseJournalV2;
1227
+ promotion = journal.docker.promotions.find(
1228
+ (promotionArg) => promotionArg.promotionId === promotionIdArg,
1229
+ )!;
1230
+ attemptId = promotion.attempt!.id;
1231
+ }
1232
+
1233
+ const context = getReleaseDockerPromotionContext(journal, promotionIdArg);
1234
+ let result: TReleasePromotionEvidence;
1235
+ try {
1236
+ result = await requireTsdocker(optionsArg).promote(
1237
+ journal.docker.request,
1238
+ context,
1239
+ getDockerRequestDirectory(optionsArg, journal),
1240
+ attemptId,
1241
+ );
1242
+ } catch (error) {
1243
+ let commandError = error;
1244
+ if (error instanceof TsdockerReleaseRequestCleanupError) {
1245
+ try {
1246
+ result = await recoverRequestCleanupOutcome(
1247
+ optionsArg,
1248
+ journal,
1249
+ attemptId,
1250
+ error,
1251
+ (valueArg) =>
1252
+ assertReleasePromotionResult(
1253
+ valueArg,
1254
+ journal.docker.request,
1255
+ context,
1256
+ ),
1257
+ );
1258
+ commandError = undefined;
1259
+ } catch (recoveredError) {
1260
+ commandError = recoveredError;
1261
+ }
1262
+ }
1263
+ if (
1264
+ commandError instanceof TsdockerReleaseRequestCleanupError &&
1265
+ commandError.reclamationError !== undefined
1266
+ ) {
1267
+ throw commandError;
1268
+ }
1269
+ if (commandError === undefined) {
1270
+ classification = classifyReleasePromotionEvidence(result!);
1271
+ } else {
1272
+ try {
1273
+ const reconciliation = await probeDockerPromotion(
1274
+ optionsArg,
1275
+ journal,
1276
+ promotionIdArg,
1277
+ attemptId,
1278
+ );
1279
+ const reconciled = classifyReleasePromotionEvidence(reconciliation);
1280
+ if (reconciled === "exact") {
1281
+ return recordDockerPromotion(
1282
+ optionsArg,
1283
+ journal,
1284
+ promotionIdArg,
1285
+ ["publishing"],
1286
+ attemptId,
1287
+ "verified",
1288
+ null,
1289
+ reconciliation,
1290
+ );
1291
+ }
1292
+ if (reconciled === "conflict") {
1293
+ await recordDockerPromotion(
1294
+ optionsArg,
1295
+ journal,
1296
+ promotionIdArg,
1297
+ ["publishing"],
1298
+ attemptId,
1299
+ "conflict",
1300
+ "destination-conflict",
1301
+ reconciliation,
1302
+ );
1303
+ }
1304
+ } catch {
1305
+ // Preserve publishing ownership when post-command reconciliation is unavailable.
1306
+ }
1307
+ throw commandError;
1308
+ }
1309
+ }
1310
+
1311
+ classification = classifyReleasePromotionEvidence(result!);
1312
+ if (classification === "exact") {
1313
+ return recordDockerPromotion(
1314
+ optionsArg,
1315
+ journal,
1316
+ promotionIdArg,
1317
+ ["publishing"],
1318
+ attemptId,
1319
+ "verified",
1320
+ null,
1321
+ result!,
1322
+ );
1323
+ }
1324
+ if (classification === "conflict") {
1325
+ await recordDockerPromotion(
1326
+ optionsArg,
1327
+ journal,
1328
+ promotionIdArg,
1329
+ ["publishing"],
1330
+ attemptId,
1331
+ "conflict",
1332
+ "destination-conflict",
1333
+ result!,
1334
+ );
1335
+ throw new Error(`Docker promotion conflicted: ${promotionIdArg}`);
1336
+ }
1337
+ throw new Error(
1338
+ `Docker promotion outcome is inconclusive: ${promotionIdArg}`,
1339
+ );
1340
+ };
1341
+
623
1342
  export const executeReleasePublication = async (
624
1343
  optionsArg: IReleasePublicationOptions,
625
- ): Promise<IReleaseJournal> => {
1344
+ ): Promise<TReleaseJournal> => {
626
1345
  let journal = optionsArg.journal;
1346
+ if (journal.schemaVersion === 2) {
1347
+ journal = await executeDockerQualification(optionsArg, journal);
1348
+ if (
1349
+ journal.docker.cleanup.state === "publishing" ||
1350
+ journal.docker.cleanup.state === "failed"
1351
+ ) {
1352
+ journal = await executeDockerCleanup(optionsArg, journal);
1353
+ }
1354
+ }
627
1355
  journal = await executeGitTarget(optionsArg, journal);
628
1356
  const expectedRemoteMainOid =
629
1357
  journal.git.state === "verified"
@@ -631,11 +1359,20 @@ export const executeReleasePublication = async (
631
1359
  : journal.git.expectedRemoteMainOid || undefined;
632
1360
  for (const registry of journal.npm.registries) {
633
1361
  await optionsArg.ensurePublicationState(expectedRemoteMainOid);
634
- journal = await executeNpmRegistry(
635
- optionsArg,
636
- journal,
637
- registry.registry,
638
- );
1362
+ journal = await executeNpmRegistry(optionsArg, journal, registry.registry);
1363
+ }
1364
+ if (
1365
+ journal.schemaVersion === 2 &&
1366
+ journal.docker.cleanup.state !== "verified"
1367
+ ) {
1368
+ for (const promotion of journal.docker.promotions) {
1369
+ journal = await executeDockerPromotion(
1370
+ optionsArg,
1371
+ journal,
1372
+ promotion.promotionId,
1373
+ );
1374
+ }
1375
+ journal = await executeDockerCleanup(optionsArg, journal);
639
1376
  }
640
1377
  return journal;
641
1378
  };