@git.zone/cli 6.0.1 → 6.1.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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/helpers.workflow.d.ts +2 -0
- package/dist_ts/helpers.workflow.js +96 -25
- package/dist_ts/mod_config/index.js +176 -88
- package/dist_ts/mod_release/classes.releasejournal.d.ts +60 -10
- package/dist_ts/mod_release/classes.releasejournal.js +513 -21
- package/dist_ts/mod_release/helpers.releasepublication.d.ts +5 -3
- package/dist_ts/mod_release/helpers.releasepublication.js +386 -13
- package/dist_ts/mod_release/helpers.tsdockerprotocol.d.ts +60 -0
- package/dist_ts/mod_release/helpers.tsdockerprotocol.js +442 -0
- package/dist_ts/mod_release/index.d.ts +1 -1
- package/dist_ts/mod_release/index.js +101 -19
- package/dist_ts/plugins.d.ts +5 -1
- package/dist_ts/plugins.js +5 -2
- package/package.json +2 -1
- package/readme.hints.md +43 -17
- package/readme.md +83 -75
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.workflow.ts +191 -49
- package/ts/mod_config/index.ts +320 -146
- package/ts/mod_release/classes.releasejournal.ts +939 -70
- package/ts/mod_release/helpers.releasepublication.ts +780 -49
- package/ts/mod_release/helpers.tsdockerprotocol.ts +806 -0
- package/ts/mod_release/index.ts +231 -74
- package/ts/plugins.ts +20 -0
|
@@ -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
|
|
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
|
-
| "
|
|
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
|
|
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 =
|
|
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 = (
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
[
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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 !==
|
|
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) !==
|
|
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(
|
|
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(
|
|
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) !==
|
|
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(
|
|
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 (
|
|
420
|
-
throw new Error(
|
|
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
|
-
|
|
424
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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:
|
|
447
|
-
`${JSON.stringify(
|
|
1045
|
+
export const serializeReleaseJournal = (journalArg: TReleaseJournal): string =>
|
|
1046
|
+
`${JSON.stringify(prepareReleaseJournalSerialization(journalArg), null, 2)}\n`;
|
|
448
1047
|
|
|
449
|
-
export const parseReleaseJournal = (contentArg: string):
|
|
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(
|
|
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(
|
|
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${
|
|
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(
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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, {
|
|
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(
|
|
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:
|
|
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:
|
|
589
|
-
): Promise<
|
|
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
|
|
1414
|
+
!plugins.path
|
|
1415
|
+
.basename(temporaryDirectory)
|
|
1416
|
+
.startsWith(`.v${journal.release.version}.tmp-`)
|
|
598
1417
|
) {
|
|
599
|
-
throw new Error(
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
journal.artifact.
|
|
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(
|
|
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:
|
|
663
|
-
): Promise<
|
|
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 (
|
|
686
|
-
|
|
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:
|
|
1532
|
+
journalArg: TReleaseJournal,
|
|
696
1533
|
): Promise<void> {
|
|
697
1534
|
const releaseDirectory = this.getReleaseDirectory(versionArg);
|
|
698
|
-
const journalPath = plugins.path.join(
|
|
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(
|
|
716
|
-
|
|
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:
|
|
1601
|
+
journalArg: TReleaseJournal,
|
|
733
1602
|
completedAtArg = new Date().toISOString(),
|
|
734
|
-
):
|
|
1603
|
+
): TReleaseJournal => {
|
|
735
1604
|
const completed = isComplete(journalArg);
|
|
736
1605
|
return {
|
|
737
1606
|
...journalArg,
|