@valbuild/cli 0.132.1 → 0.134.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/CHANGELOG.md +113 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1428 -1
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1428 -1
- package/cli/dist/valbuild-cli-cli.esm.js +1426 -1
- package/package.json +7 -5
- package/src/cli.ts +51 -0
- package/src/debug/context.ts +1 -2
- package/src/publish/artifacts.ts +194 -0
- package/src/publish/client.ts +177 -0
- package/src/publish/contentApi.ts +210 -0
- package/src/publish/contentHost.ts +160 -0
- package/src/publish/credentials.test.ts +348 -0
- package/src/publish/credentials.ts +258 -0
- package/src/publish/fakeContent.ts +482 -0
- package/src/publish/protocol.ts +174 -0
- package/src/publish/publishWireContract.test.ts +292 -0
- package/src/publish/runPublish.test.ts +536 -0
- package/src/publish/runPublish.ts +633 -0
- package/src/publish.ts +112 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { safeReadGit } from "@valbuild/server";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { findAndEvalValConfigFile } from "../utils/evalValConfigFile";
|
|
4
|
+
import {
|
|
5
|
+
Artifact,
|
|
6
|
+
buildHashOf,
|
|
7
|
+
collectArtifacts,
|
|
8
|
+
layerRevOf,
|
|
9
|
+
resolveArtifactsDir,
|
|
10
|
+
} from "./artifacts";
|
|
11
|
+
import {
|
|
12
|
+
PublishClient,
|
|
13
|
+
UploadError,
|
|
14
|
+
createPublishClient,
|
|
15
|
+
isExpiredSlot,
|
|
16
|
+
pool,
|
|
17
|
+
} from "./client";
|
|
18
|
+
import { ContentHostError, detailText, getContentHost } from "./contentHost";
|
|
19
|
+
import { resolvePublishCredential } from "./credentials";
|
|
20
|
+
import {
|
|
21
|
+
DeclareResponse,
|
|
22
|
+
PromoteResponse,
|
|
23
|
+
PublishProblem,
|
|
24
|
+
PublishProtocolError,
|
|
25
|
+
PublishState,
|
|
26
|
+
StatusResponse,
|
|
27
|
+
UploadSlot,
|
|
28
|
+
VerifyResponse,
|
|
29
|
+
parseProblems,
|
|
30
|
+
} from "./protocol";
|
|
31
|
+
|
|
32
|
+
export type PublishOptions = {
|
|
33
|
+
root?: string;
|
|
34
|
+
/** The directory whose layout is the artifact namespace. */
|
|
35
|
+
artifacts?: string;
|
|
36
|
+
commit?: string;
|
|
37
|
+
branch?: string;
|
|
38
|
+
/** Name a dependency layer content already holds, instead of sending one. */
|
|
39
|
+
layerRev?: string;
|
|
40
|
+
/** Override the hash the build is identified by. */
|
|
41
|
+
buildHash?: string;
|
|
42
|
+
/** Whether the app links its own CSS. Undefined is "this build did not say". */
|
|
43
|
+
linksOwnCss?: boolean;
|
|
44
|
+
/** Verify, and stop before the pointer moves. */
|
|
45
|
+
dryRun?: boolean;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
49
|
+
log?: (line: string) => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** How many artifacts go up at once, and how often a lost one is retried. */
|
|
53
|
+
const UPLOAD_CONCURRENCY = 6;
|
|
54
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
55
|
+
/**
|
|
56
|
+
* How many times the publish is declared before giving up.
|
|
57
|
+
*
|
|
58
|
+
* A round ends by declaring again, and there are two reasons to: a slot's hour
|
|
59
|
+
* ran out mid-upload, or content re-hashed what landed and something did not
|
|
60
|
+
* arrive as declared. Both are fixed by fresh slots and sending again. A third
|
|
61
|
+
* round that still does not confirm is not a slow network.
|
|
62
|
+
*/
|
|
63
|
+
const DECLARE_ROUNDS = 3;
|
|
64
|
+
|
|
65
|
+
type Summary = {
|
|
66
|
+
publishId: string;
|
|
67
|
+
artifacts: number;
|
|
68
|
+
uploaded: number;
|
|
69
|
+
uploadedBytes: number;
|
|
70
|
+
previewUrl: string | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type PublishResult =
|
|
74
|
+
| ({ status: "live"; url: string | null; commit: string | null } & Summary)
|
|
75
|
+
| ({ status: "verified" } & Summary)
|
|
76
|
+
| {
|
|
77
|
+
status: "failed";
|
|
78
|
+
publishId: string | null;
|
|
79
|
+
state: PublishState | null;
|
|
80
|
+
message: string;
|
|
81
|
+
problems: PublishProblem[];
|
|
82
|
+
previewUrl: string | null;
|
|
83
|
+
}
|
|
84
|
+
/** Never got as far as declaring: no credential, no artifacts, no commit. */
|
|
85
|
+
| { status: "error"; message: string };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Publish a build through content.val.build.
|
|
89
|
+
*
|
|
90
|
+
* Declare what the build is made of, upload the artifacts content does not
|
|
91
|
+
* already hold, confirm, let content build and render a canary, and only then
|
|
92
|
+
* ask it to promote. Every decision in that sequence is content's; this
|
|
93
|
+
* carries bytes and reports back.
|
|
94
|
+
*
|
|
95
|
+
* Content is also the only host it talks to. Content holds the operator
|
|
96
|
+
* relationship with the build platform and calls it during verify and promote,
|
|
97
|
+
* which is what lets a repository publish with a token that can do nothing
|
|
98
|
+
* else - there is no loader address here, no project id, and no credential
|
|
99
|
+
* that could reach either.
|
|
100
|
+
*/
|
|
101
|
+
export async function runPublish(
|
|
102
|
+
options: PublishOptions,
|
|
103
|
+
): Promise<PublishResult> {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
106
|
+
const sleep =
|
|
107
|
+
options.sleep ??
|
|
108
|
+
((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
109
|
+
const root = options.root ? path.resolve(options.root) : process.cwd();
|
|
110
|
+
|
|
111
|
+
const dir = resolveArtifactsDir({
|
|
112
|
+
root,
|
|
113
|
+
...(options.artifacts ? { dir: options.artifacts } : {}),
|
|
114
|
+
});
|
|
115
|
+
if (dir.status === "error") {
|
|
116
|
+
return { status: "error", message: dir.message };
|
|
117
|
+
}
|
|
118
|
+
const collected = await collectArtifacts(dir.dir);
|
|
119
|
+
for (const skipped of collected.skipped) {
|
|
120
|
+
log(`Skipped ${skipped}`);
|
|
121
|
+
}
|
|
122
|
+
if (collected.artifacts.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
status: "error",
|
|
125
|
+
message: `There is nothing in ${dir.dir}. Build the project before publishing it.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const layer = await layerRevOf(collected.artifacts);
|
|
130
|
+
if (layer.status === "error") {
|
|
131
|
+
return { status: "error", message: layer.message };
|
|
132
|
+
}
|
|
133
|
+
const layerRev = options.layerRev ?? layer.layerRev;
|
|
134
|
+
|
|
135
|
+
const git = await resolveGit({ root, options, env });
|
|
136
|
+
if (git.status === "error") {
|
|
137
|
+
return git;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const config = await findAndEvalValConfigFile(root).catch(() => null);
|
|
141
|
+
const credential = await resolvePublishCredential({
|
|
142
|
+
root,
|
|
143
|
+
project: config?.project ?? env.VAL_PROJECT ?? null,
|
|
144
|
+
env,
|
|
145
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
146
|
+
});
|
|
147
|
+
if (credential.status === "error") {
|
|
148
|
+
return { status: "error", message: credential.message };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const client = createPublishClient({
|
|
152
|
+
host: getContentHost(env),
|
|
153
|
+
token: credential.credential.token,
|
|
154
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
|
|
158
|
+
log(
|
|
159
|
+
`${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` +
|
|
160
|
+
`, at ${git.commit.slice(0, 7)} on ${git.branch}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
return await publishDeclaredBuild({
|
|
165
|
+
client,
|
|
166
|
+
log,
|
|
167
|
+
sleep,
|
|
168
|
+
artifacts: collected.artifacts,
|
|
169
|
+
totalBytes: collected.totalBytes,
|
|
170
|
+
body: {
|
|
171
|
+
buildHash,
|
|
172
|
+
commit: git.commit,
|
|
173
|
+
branch: git.branch,
|
|
174
|
+
layerRev,
|
|
175
|
+
linksOwnCss: options.linksOwnCss ?? null,
|
|
176
|
+
artifacts: collected.artifacts.map(({ key, sha256, bytes }) => ({
|
|
177
|
+
key,
|
|
178
|
+
sha256,
|
|
179
|
+
bytes,
|
|
180
|
+
})),
|
|
181
|
+
},
|
|
182
|
+
dryRun: options.dryRun === true,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return errorFrom(err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function publishDeclaredBuild(args: {
|
|
190
|
+
client: PublishClient;
|
|
191
|
+
log: (line: string) => void;
|
|
192
|
+
sleep: (ms: number) => Promise<void>;
|
|
193
|
+
artifacts: Artifact[];
|
|
194
|
+
totalBytes: number;
|
|
195
|
+
body: Parameters<PublishClient["declare"]>[0];
|
|
196
|
+
dryRun: boolean;
|
|
197
|
+
}): Promise<PublishResult> {
|
|
198
|
+
const { client, log, sleep, artifacts, body, dryRun } = args;
|
|
199
|
+
const fileOf = new Map(
|
|
200
|
+
artifacts.map((artifact) => [artifact.key, artifact.file]),
|
|
201
|
+
);
|
|
202
|
+
const bytesOf = new Map(
|
|
203
|
+
artifacts.map((artifact) => [artifact.key, artifact.bytes]),
|
|
204
|
+
);
|
|
205
|
+
const uploadedKeys = new Set<string>();
|
|
206
|
+
let uploadedBytes = 0;
|
|
207
|
+
let publishId: string | null = null;
|
|
208
|
+
let confirmed = false;
|
|
209
|
+
|
|
210
|
+
for (let round = 1; round <= DECLARE_ROUNDS && !confirmed; round++) {
|
|
211
|
+
const declared = await declare(client, body);
|
|
212
|
+
if (declared.status === "refused") {
|
|
213
|
+
return {
|
|
214
|
+
status: "failed",
|
|
215
|
+
publishId,
|
|
216
|
+
state: null,
|
|
217
|
+
message: declared.message,
|
|
218
|
+
problems: declared.problems,
|
|
219
|
+
previewUrl: null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
publishId = declared.response.publishId;
|
|
223
|
+
if (declared.response.state === "live") {
|
|
224
|
+
/*
|
|
225
|
+
* Already published, and not re-opened - re-declaring would mint slots
|
|
226
|
+
* to overwrite the bytes of a build that is currently serving. A CI job
|
|
227
|
+
* re-run after a successful publish lands here, and it is a success.
|
|
228
|
+
*/
|
|
229
|
+
log("This build is already live.");
|
|
230
|
+
return {
|
|
231
|
+
status: "live",
|
|
232
|
+
publishId: declared.response.publishId,
|
|
233
|
+
url: declared.response.project.siteUrl,
|
|
234
|
+
commit: body.commit,
|
|
235
|
+
artifacts: artifacts.length,
|
|
236
|
+
uploaded: uploadedKeys.size,
|
|
237
|
+
uploadedBytes,
|
|
238
|
+
previewUrl: null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const slots = declared.response.uploads;
|
|
243
|
+
if (slots.length > 0) {
|
|
244
|
+
const bytes = slots.reduce(
|
|
245
|
+
(sum, slot) => sum + (bytesOf.get(slot.key) ?? 0),
|
|
246
|
+
0,
|
|
247
|
+
);
|
|
248
|
+
log(
|
|
249
|
+
round === 1
|
|
250
|
+
? `Uploading ${count(slots.length, "artifact")}, ${formatBytes(bytes)}` +
|
|
251
|
+
` (${declared.response.have.length} already held)`
|
|
252
|
+
: `Uploading ${count(slots.length, "artifact")} again`,
|
|
253
|
+
);
|
|
254
|
+
const outcome = await uploadAll({ client, slots, fileOf, sleep });
|
|
255
|
+
for (const key of outcome.uploaded) {
|
|
256
|
+
if (!uploadedKeys.has(key)) {
|
|
257
|
+
uploadedKeys.add(key);
|
|
258
|
+
uploadedBytes += bytesOf.get(key) ?? 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (outcome.status === "expired") {
|
|
262
|
+
// The hour on the slots ran out while we were using them. Declaring
|
|
263
|
+
// again mints fresh ones; nothing about the publish has failed.
|
|
264
|
+
log("Upload slots expired. Declaring again for fresh ones.");
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const confirmation = await confirmArtifacts(
|
|
270
|
+
client,
|
|
271
|
+
declared.response.publishId,
|
|
272
|
+
);
|
|
273
|
+
if (confirmation.status === "mismatch") {
|
|
274
|
+
log(
|
|
275
|
+
`Content did not receive ${count(confirmation.problems.length, "artifact")} as declared. Declaring again.`,
|
|
276
|
+
);
|
|
277
|
+
if (round === DECLARE_ROUNDS) {
|
|
278
|
+
return {
|
|
279
|
+
status: "failed",
|
|
280
|
+
publishId,
|
|
281
|
+
state: null,
|
|
282
|
+
message: "Some artifacts did not arrive as declared.",
|
|
283
|
+
problems: confirmation.problems,
|
|
284
|
+
previewUrl: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (confirmation.state !== "ready") {
|
|
290
|
+
return {
|
|
291
|
+
status: "failed",
|
|
292
|
+
publishId,
|
|
293
|
+
state: confirmation.state,
|
|
294
|
+
message: `Content left this publish in "${confirmation.state}" after confirming the artifacts.`,
|
|
295
|
+
problems: confirmation.problems,
|
|
296
|
+
previewUrl: null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
confirmed = true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!confirmed || publishId === null) {
|
|
303
|
+
return {
|
|
304
|
+
status: "failed",
|
|
305
|
+
publishId,
|
|
306
|
+
state: null,
|
|
307
|
+
message: `The artifacts were not confirmed after ${DECLARE_ROUNDS} attempts.`,
|
|
308
|
+
problems: [],
|
|
309
|
+
previewUrl: null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
log("Verifying: content is building and rendering this as a canary");
|
|
314
|
+
let verified: VerifyResponse | StatusResponse;
|
|
315
|
+
try {
|
|
316
|
+
verified = await client.verify(publishId);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
verified = await recoverWithStatus(err, client, publishId);
|
|
319
|
+
}
|
|
320
|
+
const summary: Summary = {
|
|
321
|
+
publishId,
|
|
322
|
+
artifacts: artifacts.length,
|
|
323
|
+
uploaded: uploadedKeys.size,
|
|
324
|
+
uploadedBytes,
|
|
325
|
+
previewUrl: "previewUrl" in verified ? verified.previewUrl : null,
|
|
326
|
+
};
|
|
327
|
+
const verifiedOk =
|
|
328
|
+
"ok" in verified ? verified.ok : verified.state === "verified";
|
|
329
|
+
if (!verifiedOk) {
|
|
330
|
+
return {
|
|
331
|
+
status: "failed",
|
|
332
|
+
publishId,
|
|
333
|
+
state: verified.state,
|
|
334
|
+
message: "The canary did not build and render, so nothing was promoted.",
|
|
335
|
+
problems: verified.problems,
|
|
336
|
+
previewUrl: summary.previewUrl,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (dryRun) {
|
|
341
|
+
log("Verified. Stopping here: --dry-run does not move the pointer.");
|
|
342
|
+
return { status: "verified", ...summary };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const promoted = await promote(client, publishId);
|
|
346
|
+
if (promoted.status === "stale") {
|
|
347
|
+
return {
|
|
348
|
+
status: "failed",
|
|
349
|
+
publishId,
|
|
350
|
+
state: null,
|
|
351
|
+
message: promoted.message,
|
|
352
|
+
problems: promoted.problems,
|
|
353
|
+
previewUrl: summary.previewUrl,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (promoted.response.state !== "live") {
|
|
357
|
+
return {
|
|
358
|
+
status: "failed",
|
|
359
|
+
publishId,
|
|
360
|
+
state: promoted.response.state,
|
|
361
|
+
message: `The pointer was not moved: this publish is "${promoted.response.state}".`,
|
|
362
|
+
problems: [],
|
|
363
|
+
previewUrl: summary.previewUrl,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
status: "live",
|
|
368
|
+
url: promoted.response.url,
|
|
369
|
+
commit: promoted.response.commit,
|
|
370
|
+
...summary,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type Declared =
|
|
375
|
+
| { status: "ok"; response: DeclareResponse }
|
|
376
|
+
| { status: "refused"; message: string; problems: PublishProblem[] };
|
|
377
|
+
|
|
378
|
+
async function declare(
|
|
379
|
+
client: PublishClient,
|
|
380
|
+
body: Parameters<PublishClient["declare"]>[0],
|
|
381
|
+
): Promise<Declared> {
|
|
382
|
+
try {
|
|
383
|
+
return { status: "ok", response: await client.declare(body) };
|
|
384
|
+
} catch (err) {
|
|
385
|
+
// A malformed declaration is answered with EVERY problem rather than the
|
|
386
|
+
// first, so all of them are carried through to the report.
|
|
387
|
+
if (err instanceof ContentHostError && err.statusCode === 400) {
|
|
388
|
+
return {
|
|
389
|
+
status: "refused",
|
|
390
|
+
message: err.message,
|
|
391
|
+
problems: parseProblems(err.details),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function confirmArtifacts(
|
|
399
|
+
client: PublishClient,
|
|
400
|
+
publishId: string,
|
|
401
|
+
): Promise<
|
|
402
|
+
| { status: "ok"; state: PublishState; problems: PublishProblem[] }
|
|
403
|
+
| { status: "mismatch"; problems: PublishProblem[] }
|
|
404
|
+
> {
|
|
405
|
+
try {
|
|
406
|
+
const response = await client.confirmArtifacts(publishId);
|
|
407
|
+
return {
|
|
408
|
+
status: "ok",
|
|
409
|
+
state: response.state,
|
|
410
|
+
problems: response.problems,
|
|
411
|
+
};
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err instanceof ContentHostError && err.statusCode === 409) {
|
|
414
|
+
// Either nothing is at that key or the bytes are not what was declared.
|
|
415
|
+
// Both are fixed by fresh slots and sending again.
|
|
416
|
+
return { status: "mismatch", problems: parseProblems(err.details) };
|
|
417
|
+
}
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function promote(
|
|
423
|
+
client: PublishClient,
|
|
424
|
+
publishId: string,
|
|
425
|
+
): Promise<
|
|
426
|
+
| { status: "ok"; response: PromoteResponse }
|
|
427
|
+
| { status: "stale"; message: string; problems: PublishProblem[] }
|
|
428
|
+
> {
|
|
429
|
+
try {
|
|
430
|
+
return { status: "ok", response: await client.promote(publishId) };
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (err instanceof ContentHostError && err.statusCode === 0) {
|
|
433
|
+
// The pointer may well have moved; the answer saying so was lost. Ask.
|
|
434
|
+
const status = await client.status(publishId);
|
|
435
|
+
return {
|
|
436
|
+
status: "ok",
|
|
437
|
+
response: { state: status.state, url: null, commit: null },
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (err instanceof ContentHostError && err.statusCode === 409) {
|
|
441
|
+
/*
|
|
442
|
+
* Somebody pushed while this was building.
|
|
443
|
+
*
|
|
444
|
+
* Content's own sentence already says what to do ("Rebuild from the
|
|
445
|
+
* current head"), so it is passed through rather than replaced, and the
|
|
446
|
+
* `POINTER_STALE` code and the loader's words come out of `details` the
|
|
447
|
+
* way every other refusal's problems do.
|
|
448
|
+
*
|
|
449
|
+
* `head` is read if it is there and not required: the API's written
|
|
450
|
+
* contract says a stale promote carries the sha the branch is at now,
|
|
451
|
+
* and `postPublishPromote.ts` does not send it today. Naming the sha is
|
|
452
|
+
* worth having when it arrives; failing without it would be this CLI
|
|
453
|
+
* refusing an answer that is otherwise complete.
|
|
454
|
+
*/
|
|
455
|
+
const head = stringField(err.body, "head");
|
|
456
|
+
return {
|
|
457
|
+
status: "stale",
|
|
458
|
+
message:
|
|
459
|
+
err.message +
|
|
460
|
+
(head ? ` The branch is at ${head.slice(0, 7)} now.` : ""),
|
|
461
|
+
problems: parseProblems(err.details),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A step that did not answer is not a step that did not happen.
|
|
470
|
+
*
|
|
471
|
+
* Verify and promote can take minutes on content's side, and a connection that
|
|
472
|
+
* dies in the middle says nothing about what content did. Problems are kept on
|
|
473
|
+
* the row rather than only returned by the call that found them, so asking is
|
|
474
|
+
* not retrying: one `GET` gets the same answer the lost response carried.
|
|
475
|
+
*/
|
|
476
|
+
async function recoverWithStatus(
|
|
477
|
+
err: unknown,
|
|
478
|
+
client: PublishClient,
|
|
479
|
+
publishId: string,
|
|
480
|
+
): Promise<StatusResponse> {
|
|
481
|
+
if (err instanceof ContentHostError && err.statusCode === 0) {
|
|
482
|
+
return client.status(publishId);
|
|
483
|
+
}
|
|
484
|
+
throw err;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function uploadAll(args: {
|
|
488
|
+
client: PublishClient;
|
|
489
|
+
slots: UploadSlot[];
|
|
490
|
+
fileOf: Map<string, string>;
|
|
491
|
+
sleep: (ms: number) => Promise<void>;
|
|
492
|
+
}): Promise<{ status: "ok" | "expired"; uploaded: string[] }> {
|
|
493
|
+
const { client, slots, fileOf, sleep } = args;
|
|
494
|
+
const uploaded: string[] = [];
|
|
495
|
+
try {
|
|
496
|
+
await pool(slots, UPLOAD_CONCURRENCY, async (slot) => {
|
|
497
|
+
const file = fileOf.get(slot.key);
|
|
498
|
+
if (file === undefined) {
|
|
499
|
+
// Content asked for a key this build does not have. Declaring again
|
|
500
|
+
// cannot fix that, so it is not a round; it is a bug on one side.
|
|
501
|
+
throw new PublishProtocolError(
|
|
502
|
+
`Content asked for an artifact this build does not have: ${slot.key}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
for (let attempt = 1; ; attempt++) {
|
|
506
|
+
try {
|
|
507
|
+
await client.upload(slot, file);
|
|
508
|
+
uploaded.push(slot.key);
|
|
509
|
+
return;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (
|
|
512
|
+
attempt >= UPLOAD_ATTEMPTS ||
|
|
513
|
+
!(err instanceof UploadError) ||
|
|
514
|
+
!isWorthRetrying(err)
|
|
515
|
+
) {
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
await sleep(250 * 2 ** attempt);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (err instanceof UploadError && isExpiredSlot(err)) {
|
|
524
|
+
return { status: "expired", uploaded };
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
return { status: "ok", uploaded };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Whether to try the same slot again.
|
|
533
|
+
*
|
|
534
|
+
* A dropped connection or a busy store, yes. A 403 is an expired slot and will
|
|
535
|
+
* be a 403 every time - that one is answered by declaring again, which is a
|
|
536
|
+
* round rather than a retry.
|
|
537
|
+
*/
|
|
538
|
+
function isWorthRetrying(err: UploadError): boolean {
|
|
539
|
+
return (
|
|
540
|
+
err.statusCode === 0 || err.statusCode === 429 || err.statusCode >= 500
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function errorFrom(err: unknown): PublishResult {
|
|
545
|
+
if (err instanceof ContentHostError) {
|
|
546
|
+
const detail = detailText(err.details);
|
|
547
|
+
return {
|
|
548
|
+
status: "error",
|
|
549
|
+
message:
|
|
550
|
+
err.statusCode === 401 || err.statusCode === 403
|
|
551
|
+
? `${err.message}\n\nThe credential was refused. A project token can be revoked, and only a token carrying val:publish may publish.`
|
|
552
|
+
: `${err.message}${detail ? `\n${detail}` : ""}`,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (err instanceof UploadError || err instanceof PublishProtocolError) {
|
|
556
|
+
return { status: "error", message: err.message };
|
|
557
|
+
}
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Which commit this build is of, and which branch content commits saves to.
|
|
563
|
+
*
|
|
564
|
+
* The commit is baked into the published server and decides which version of
|
|
565
|
+
* its own content the site reads, so a wrong answer is worse than none: what
|
|
566
|
+
* the caller said, then Val's own variables, then the CI runner, then git.
|
|
567
|
+
*
|
|
568
|
+
* `GITHUB_SHA` before `git rev-parse` because on a `pull_request` event the
|
|
569
|
+
* checkout is a detached merge commit that exists only on the runner, and
|
|
570
|
+
* content committed against it would be committed against nothing.
|
|
571
|
+
*/
|
|
572
|
+
async function resolveGit(args: {
|
|
573
|
+
root: string;
|
|
574
|
+
options: PublishOptions;
|
|
575
|
+
env: NodeJS.ProcessEnv;
|
|
576
|
+
}): Promise<
|
|
577
|
+
| { status: "ok"; commit: string; branch: string }
|
|
578
|
+
| { status: "error"; message: string }
|
|
579
|
+
> {
|
|
580
|
+
const { root, options, env } = args;
|
|
581
|
+
const fromGit = await safeReadGit(root);
|
|
582
|
+
const commit =
|
|
583
|
+
options.commit ?? env.VAL_GIT_COMMIT ?? env.GITHUB_SHA ?? fromGit.commit;
|
|
584
|
+
const branchName =
|
|
585
|
+
options.branch ??
|
|
586
|
+
env.VAL_GIT_BRANCH ??
|
|
587
|
+
env.GITHUB_REF_NAME ??
|
|
588
|
+
fromGit.branch;
|
|
589
|
+
// A detached HEAD has no branch, and "HEAD" is not one: content commits
|
|
590
|
+
// saves to a branch, and the failure would arrive hours later as a save that
|
|
591
|
+
// cannot be published.
|
|
592
|
+
const branch = branchName === "HEAD" ? undefined : branchName;
|
|
593
|
+
if (!commit || !branch) {
|
|
594
|
+
return {
|
|
595
|
+
status: "error",
|
|
596
|
+
message:
|
|
597
|
+
`Could not tell which ${!commit ? "commit" : "branch"} this build is of.\n\n` +
|
|
598
|
+
"Pass --commit and --branch, or set VAL_GIT_COMMIT and VAL_GIT_BRANCH.\n" +
|
|
599
|
+
"The commit is baked into the published site and decides which version of\n" +
|
|
600
|
+
"its own content it reads, so there is nothing safe to guess.",
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { status: "ok", commit, branch };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function stringField(body: unknown, key: string): string | null {
|
|
607
|
+
if (typeof body === "object" && body !== null) {
|
|
608
|
+
const value = Reflect.get(body, key);
|
|
609
|
+
if (typeof value === "string" && value !== "") {
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** "1 artifact", "3 artifacts". Every one of these lines ends up in a CI log. */
|
|
617
|
+
function count(n: number, noun: string): string {
|
|
618
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function formatBytes(bytes: number): string {
|
|
622
|
+
if (bytes < 1024) {
|
|
623
|
+
return `${bytes} B`;
|
|
624
|
+
}
|
|
625
|
+
const units = ["kB", "MB", "GB"];
|
|
626
|
+
let value = bytes / 1024;
|
|
627
|
+
let unit = 0;
|
|
628
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
629
|
+
value /= 1024;
|
|
630
|
+
unit++;
|
|
631
|
+
}
|
|
632
|
+
return `${value.toFixed(1)} ${units[unit]}`;
|
|
633
|
+
}
|