@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
|
@@ -11,6 +11,10 @@ import fs from 'fs';
|
|
|
11
11
|
import ts from 'typescript';
|
|
12
12
|
import JSZip from 'jszip';
|
|
13
13
|
import readline from 'readline';
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
import zlib from 'zlib';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { fromError } from 'zod-validation-error';
|
|
14
18
|
|
|
15
19
|
function error(message) {
|
|
16
20
|
console.error(chalk.red("❌Error: ") + message);
|
|
@@ -1164,7 +1168,10 @@ async function createDebugContext(options) {
|
|
|
1164
1168
|
authKind: "pat" in auth ? "pat" : "api-key",
|
|
1165
1169
|
contentUrl,
|
|
1166
1170
|
filesDirectory,
|
|
1167
|
-
serverOps: new ValOpsHttp(contentUrl, project,
|
|
1171
|
+
serverOps: new ValOpsHttp(contentUrl, project, {
|
|
1172
|
+
commit,
|
|
1173
|
+
branch
|
|
1174
|
+
}, auth, valModules, {
|
|
1168
1175
|
root: config.root,
|
|
1169
1176
|
config
|
|
1170
1177
|
})
|
|
@@ -2010,6 +2017,1374 @@ function confirm(question) {
|
|
|
2010
2017
|
});
|
|
2011
2018
|
}
|
|
2012
2019
|
|
|
2020
|
+
/**
|
|
2021
|
+
* Where an artifact's bytes are, and what it is called on the wire.
|
|
2022
|
+
*
|
|
2023
|
+
* The key namespace is flat and is content's: `server`, `client`, `css`,
|
|
2024
|
+
* `rsc`, `layer`, and paths under `chunk/server/`, `chunk/client/`,
|
|
2025
|
+
* `chunk/rsc/`, `asset/` and `public/`. Content validates it and answers with
|
|
2026
|
+
* every problem at once, so this does not re-implement the rules - a second,
|
|
2027
|
+
* slightly different copy of them here is how a CLI comes to refuse a publish
|
|
2028
|
+
* the service would have taken.
|
|
2029
|
+
*/
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* The artifacts to publish, read from a directory laid out by key.
|
|
2033
|
+
*
|
|
2034
|
+
* The path under the directory IS the artifact key: a file at `public/app.css`
|
|
2035
|
+
* is the artifact `public/app.css`, and `server` is the server bundle. Nothing
|
|
2036
|
+
* is renamed on the way - an asset is addressed by the path the built code
|
|
2037
|
+
* imports it at, so a normalised name produces a bundle whose imports resolve
|
|
2038
|
+
* to nothing, at runtime, in the isolate.
|
|
2039
|
+
*
|
|
2040
|
+
* **This is the seam with the build.** `val publish` uploads artifacts; it does
|
|
2041
|
+
* not produce them. Whatever builds the project - today the platform's own
|
|
2042
|
+
* builder, which is where the wire, rolldown and dependency-layer steps live -
|
|
2043
|
+
* writes this directory, and this reads it.
|
|
2044
|
+
*/
|
|
2045
|
+
async function collectArtifacts(dir) {
|
|
2046
|
+
const artifacts = [];
|
|
2047
|
+
const skipped = [];
|
|
2048
|
+
let totalBytes = 0;
|
|
2049
|
+
const walk = async current => {
|
|
2050
|
+
const entries = await fs.promises.readdir(current, {
|
|
2051
|
+
withFileTypes: true
|
|
2052
|
+
});
|
|
2053
|
+
for (const entry of entries) {
|
|
2054
|
+
const absolute = path.join(current, entry.name);
|
|
2055
|
+
const key = path.relative(dir, absolute).split(path.sep).join("/");
|
|
2056
|
+
if (entry.isDirectory()) {
|
|
2057
|
+
await walk(absolute);
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
if (entry.isSymbolicLink()) {
|
|
2061
|
+
// A symlinked file is published as its bytes; a symlinked directory is
|
|
2062
|
+
// not walked, because one usually comes with a cycle, and a publisher
|
|
2063
|
+
// that hangs is worse than one that says what it left out.
|
|
2064
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
2065
|
+
if (stat === null) {
|
|
2066
|
+
skipped.push(`${key} (broken symlink)`);
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
if (!stat.isFile()) {
|
|
2070
|
+
skipped.push(`${key} (symlink to a ${stat.isDirectory() ? "directory" : "special file"})`);
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
} else if (!entry.isFile()) {
|
|
2074
|
+
skipped.push(`${key} (not a regular file)`);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
const {
|
|
2078
|
+
sha256,
|
|
2079
|
+
bytes
|
|
2080
|
+
} = await hashFile(absolute);
|
|
2081
|
+
artifacts.push({
|
|
2082
|
+
key,
|
|
2083
|
+
sha256,
|
|
2084
|
+
bytes,
|
|
2085
|
+
file: absolute
|
|
2086
|
+
});
|
|
2087
|
+
totalBytes += bytes;
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
await walk(dir);
|
|
2091
|
+
// Sorted so two publishes of one build declare the same list in the same
|
|
2092
|
+
// order, which is what makes the build hash below reproducible.
|
|
2093
|
+
artifacts.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
2094
|
+
return {
|
|
2095
|
+
artifacts,
|
|
2096
|
+
totalBytes,
|
|
2097
|
+
skipped
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
async function hashFile(absolute) {
|
|
2101
|
+
const hash = crypto.createHash("sha256");
|
|
2102
|
+
let bytes = 0;
|
|
2103
|
+
// Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
|
|
2104
|
+
// it is a needless way to run a CI runner out of memory.
|
|
2105
|
+
const stream = fs.createReadStream(absolute);
|
|
2106
|
+
await new Promise((resolve, reject) => {
|
|
2107
|
+
stream.on("data", chunk => {
|
|
2108
|
+
hash.update(chunk);
|
|
2109
|
+
bytes += chunk.length;
|
|
2110
|
+
});
|
|
2111
|
+
stream.on("end", resolve);
|
|
2112
|
+
stream.on("error", reject);
|
|
2113
|
+
});
|
|
2114
|
+
return {
|
|
2115
|
+
sha256: hash.digest("hex"),
|
|
2116
|
+
bytes
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* The build's own hash, when the build did not say.
|
|
2122
|
+
*
|
|
2123
|
+
* Content uses it for idempotency - declaring the same `buildHash` twice
|
|
2124
|
+
* returns the same publish, which is what makes a retried CI job resume rather
|
|
2125
|
+
* than start again - so it has to be a function of the build and nothing else.
|
|
2126
|
+
* Every artifact's key and hash, in order: two runs of one build agree, and
|
|
2127
|
+
* any change to any artifact is a different publish.
|
|
2128
|
+
*/
|
|
2129
|
+
function buildHashOf(artifacts) {
|
|
2130
|
+
const hash = crypto.createHash("sha256");
|
|
2131
|
+
for (const artifact of artifacts) {
|
|
2132
|
+
hash.update(`${artifact.key} ${artifact.sha256}\n`);
|
|
2133
|
+
}
|
|
2134
|
+
return hash.digest("hex");
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Which dependency layer this build was built against.
|
|
2139
|
+
*
|
|
2140
|
+
* A layer is sent or named, never neither: the loader reads the head's layer
|
|
2141
|
+
* by rev, and a publish that declares no layer at all loses every dependency
|
|
2142
|
+
* on the next render. When the layer is being uploaded, its rev is inside it -
|
|
2143
|
+
* it is gzipped JSON with a `rev` - so there is nothing to ask the caller for.
|
|
2144
|
+
* When it is not, the build knows which stored layer it used, and says so with
|
|
2145
|
+
* `--layer-rev`.
|
|
2146
|
+
*/
|
|
2147
|
+
async function layerRevOf(artifacts) {
|
|
2148
|
+
const layer = artifacts.find(artifact => artifact.key === "layer");
|
|
2149
|
+
if (!layer) {
|
|
2150
|
+
return {
|
|
2151
|
+
status: "ok",
|
|
2152
|
+
layerRev: null
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
const bytes = await fs.promises.readFile(layer.file);
|
|
2156
|
+
let parsed;
|
|
2157
|
+
try {
|
|
2158
|
+
parsed = JSON.parse(zlib.gunzipSync(bytes).toString("utf-8"));
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
return {
|
|
2161
|
+
status: "error",
|
|
2162
|
+
message: `The layer artifact is not gzipped JSON: ${err instanceof Error ? err.message : String(err)}\n\n` + "A layer is `{ rev, worker, browser, ... }`, gzipped. Pass --layer-rev to\n" + "name a layer content already holds instead of sending one."
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
const rev = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "rev") : undefined;
|
|
2166
|
+
if (typeof rev !== "string" || rev === "") {
|
|
2167
|
+
return {
|
|
2168
|
+
status: "error",
|
|
2169
|
+
message: "The layer artifact has no rev, and a layer that is sent has to say which\n" + "it is: that is the name the loader stores and later reuses it by."
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
return {
|
|
2173
|
+
status: "ok",
|
|
2174
|
+
layerRev: rev
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/** Where the artifacts are, if the caller did not say. */
|
|
2179
|
+
const DEFAULT_ARTIFACTS_DIR = ".val/publish";
|
|
2180
|
+
function resolveArtifactsDir(options) {
|
|
2181
|
+
const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
|
|
2182
|
+
const dir = path.resolve(options.root, named);
|
|
2183
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
2184
|
+
return {
|
|
2185
|
+
status: "error",
|
|
2186
|
+
message: `No artifacts to publish: ${dir} is not a directory.\n\n` + "Build the project first, then point at what it produced:\n\n" + " npx val publish --artifacts <directory>\n\n" + "The path of each file under it is its artifact key - `server`, `client`,\n" + "`public/...`, `chunk/client/...`, `layer`."
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
return {
|
|
2190
|
+
status: "ok",
|
|
2191
|
+
dir
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* The one host `val publish` talks to.
|
|
2197
|
+
*
|
|
2198
|
+
* Not a flag, and not several: publishing is a conversation with
|
|
2199
|
+
* content.val.build, which holds the operator relationship with whatever
|
|
2200
|
+
* actually serves the site and calls it on our behalf. A second host here
|
|
2201
|
+
* would be a second thing to configure in every repository that publishes,
|
|
2202
|
+
* and a second place for a credential to go.
|
|
2203
|
+
*
|
|
2204
|
+
* `VAL_CONTENT_URL` overrides it, as it does everywhere else in Val, so a
|
|
2205
|
+
* test can point the CLI at a fake and a developer at a local content service.
|
|
2206
|
+
*/
|
|
2207
|
+
function getContentHost(env = process.env) {
|
|
2208
|
+
const configured = env.VAL_CONTENT_URL;
|
|
2209
|
+
if (!configured) {
|
|
2210
|
+
return DEFAULT_CONTENT_HOST;
|
|
2211
|
+
}
|
|
2212
|
+
// A trailing slash turns every path below into a double slash, which some
|
|
2213
|
+
// routers answer with a redirect and others with a 404.
|
|
2214
|
+
return configured.replace(/\/+$/, "");
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* A refusal from content, carrying the status so a caller can tell "your
|
|
2219
|
+
* credential is no good" (401/403) from "content is down" (5xx) and say
|
|
2220
|
+
* something different about each.
|
|
2221
|
+
*/
|
|
2222
|
+
class ContentHostError extends Error {
|
|
2223
|
+
statusCode;
|
|
2224
|
+
/**
|
|
2225
|
+
* Whatever was in `details`, unread.
|
|
2226
|
+
*
|
|
2227
|
+
* The publish routes put a `PublishProblem[]` there - every problem with a
|
|
2228
|
+
* declaration rather than the first - and the older routes put a sentence.
|
|
2229
|
+
* Keeping it unparsed here lets each caller read the one it expects without
|
|
2230
|
+
* this file having to know about either.
|
|
2231
|
+
*/
|
|
2232
|
+
details;
|
|
2233
|
+
/**
|
|
2234
|
+
* The whole answer.
|
|
2235
|
+
*
|
|
2236
|
+
* Some refusals carry a field of their own beside the message - a stale
|
|
2237
|
+
* pointer answers with the `head` the branch is at now, which is the one
|
|
2238
|
+
* thing that tells a publisher what happened - so the body is kept rather
|
|
2239
|
+
* than reduced to two strings on the way past.
|
|
2240
|
+
*/
|
|
2241
|
+
body;
|
|
2242
|
+
constructor(statusCode, message, details, body) {
|
|
2243
|
+
super(message);
|
|
2244
|
+
this.name = "ContentHostError";
|
|
2245
|
+
this.statusCode = statusCode;
|
|
2246
|
+
this.details = details;
|
|
2247
|
+
this.body = body;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/** The `details` of a refusal, when it is a sentence rather than a list. */
|
|
2252
|
+
function detailText(details) {
|
|
2253
|
+
return typeof details === "string" && details !== "" ? details : null;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** GET JSON from content. Same envelope, same failures, one less body. */
|
|
2257
|
+
async function getJson(options) {
|
|
2258
|
+
return requestJson({
|
|
2259
|
+
method: "GET",
|
|
2260
|
+
...options
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/**
|
|
2265
|
+
* POST JSON to content and read JSON back.
|
|
2266
|
+
*
|
|
2267
|
+
* Content answers an error as `{ statusCode, message, details? }` (its
|
|
2268
|
+
* `sendResult`), so the message a user sees is the one the service wrote
|
|
2269
|
+
* rather than a status code they then have to look up. A body that is not
|
|
2270
|
+
* that shape - a proxy's HTML error page, say - still has to produce a
|
|
2271
|
+
* sentence, hence the fallback.
|
|
2272
|
+
*/
|
|
2273
|
+
async function postJson(options) {
|
|
2274
|
+
return requestJson({
|
|
2275
|
+
method: "POST",
|
|
2276
|
+
...options
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
async function requestJson(options) {
|
|
2280
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2281
|
+
let res;
|
|
2282
|
+
try {
|
|
2283
|
+
res = await fetchImpl(options.url, {
|
|
2284
|
+
method: options.method,
|
|
2285
|
+
headers: options.method === "GET" ? options.headers : {
|
|
2286
|
+
"Content-Type": "application/json",
|
|
2287
|
+
...options.headers
|
|
2288
|
+
},
|
|
2289
|
+
...(options.method === "GET" ? {} : {
|
|
2290
|
+
body: JSON.stringify(options.body ?? {})
|
|
2291
|
+
})
|
|
2292
|
+
});
|
|
2293
|
+
} catch (err) {
|
|
2294
|
+
// No status at all: DNS, TLS, a dropped connection. 0 is the shape the
|
|
2295
|
+
// rest of this file expects, and it is never a status a server sends.
|
|
2296
|
+
throw new ContentHostError(0, `Could not reach ${options.url}`, err instanceof Error ? err.message : String(err));
|
|
2297
|
+
}
|
|
2298
|
+
const text = await res.text();
|
|
2299
|
+
let parsed = undefined;
|
|
2300
|
+
if (text !== "") {
|
|
2301
|
+
try {
|
|
2302
|
+
parsed = JSON.parse(text);
|
|
2303
|
+
} catch {
|
|
2304
|
+
parsed = undefined;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
if (!res.ok) {
|
|
2308
|
+
throw new ContentHostError(res.status, errorMessageOf(parsed) ?? `${res.status} ${res.statusText}`, errorDetailsOf(parsed), parsed);
|
|
2309
|
+
}
|
|
2310
|
+
return parsed;
|
|
2311
|
+
}
|
|
2312
|
+
function errorMessageOf(body) {
|
|
2313
|
+
if (typeof body === "object" && body !== null && "message" in body) {
|
|
2314
|
+
const message = body.message;
|
|
2315
|
+
if (typeof message === "string" && message !== "") {
|
|
2316
|
+
return message;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
return undefined;
|
|
2320
|
+
}
|
|
2321
|
+
function errorDetailsOf(body) {
|
|
2322
|
+
if (typeof body === "object" && body !== null && "details" in body) {
|
|
2323
|
+
return body.details;
|
|
2324
|
+
}
|
|
2325
|
+
return undefined;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* Reading what content answers.
|
|
2330
|
+
*
|
|
2331
|
+
* Every type here comes from `contentApi.ts`, which is a copy of the
|
|
2332
|
+
* service's own `Api.ts` - so the shapes are not re-derived from what this end
|
|
2333
|
+
* wishes it received, and a copy brought up to date fails to compile wherever
|
|
2334
|
+
* this CLI has not caught up.
|
|
2335
|
+
*
|
|
2336
|
+
* The schemas are the runtime half, and they are needed as well as the types:
|
|
2337
|
+
* what arrives is JSON over HTTP from a service that versions separately, so a
|
|
2338
|
+
* type alone asserts a shape rather than checking one. Each schema is
|
|
2339
|
+
* annotated with the copied type it must produce (`z.ZodType<DeclareResponse>`
|
|
2340
|
+
* and so on), which is what ties the two halves together - a schema that drifts
|
|
2341
|
+
* from the copy does not compile, and an answer that drifts from the schema
|
|
2342
|
+
* does not parse. `ValOpsHttp` in `@valbuild/server` reads every other content
|
|
2343
|
+
* route the same way.
|
|
2344
|
+
*
|
|
2345
|
+
* Nothing here is laxer than the copy. An optional field is optional because
|
|
2346
|
+
* the service says so; making a required one optional here would hide exactly
|
|
2347
|
+
* the drift this file exists to catch.
|
|
2348
|
+
*/
|
|
2349
|
+
|
|
2350
|
+
/** An artifact, as it is declared: by key, by hash, by size. */
|
|
2351
|
+
|
|
2352
|
+
/** Permission to write one artifact's bytes to object storage. */
|
|
2353
|
+
|
|
2354
|
+
const publishState = z.enum(["awaiting-artifacts", "ready", "verified", "live", "failed", "expired"]);
|
|
2355
|
+
const publishProblem = z.object({
|
|
2356
|
+
code: z.string(),
|
|
2357
|
+
message: z.string(),
|
|
2358
|
+
hint: z.string().optional(),
|
|
2359
|
+
keys: z.array(z.string()).optional()
|
|
2360
|
+
});
|
|
2361
|
+
const uploadSlot = z.object({
|
|
2362
|
+
key: z.string(),
|
|
2363
|
+
url: z.string(),
|
|
2364
|
+
method: z.literal("PUT"),
|
|
2365
|
+
headers: z.record(z.string(), z.string()),
|
|
2366
|
+
expiresAt: z.string()
|
|
2367
|
+
});
|
|
2368
|
+
const declareResponse = z.object({
|
|
2369
|
+
publishId: z.string(),
|
|
2370
|
+
state: publishState,
|
|
2371
|
+
project: z.object({
|
|
2372
|
+
publicProjectId: z.string(),
|
|
2373
|
+
siteUrl: z.string().nullable()
|
|
2374
|
+
}),
|
|
2375
|
+
uploads: z.array(uploadSlot),
|
|
2376
|
+
have: z.array(z.string())
|
|
2377
|
+
});
|
|
2378
|
+
const artifactsResponse = z.object({
|
|
2379
|
+
state: publishState,
|
|
2380
|
+
problems: z.array(publishProblem)
|
|
2381
|
+
});
|
|
2382
|
+
const verifyResponse = z.object({
|
|
2383
|
+
state: publishState,
|
|
2384
|
+
ok: z.boolean(),
|
|
2385
|
+
previewUrl: z.string().nullable(),
|
|
2386
|
+
problems: z.array(publishProblem)
|
|
2387
|
+
});
|
|
2388
|
+
const promoteResponse = z.object({
|
|
2389
|
+
state: publishState,
|
|
2390
|
+
url: z.string().nullable(),
|
|
2391
|
+
commit: z.string().nullable()
|
|
2392
|
+
});
|
|
2393
|
+
const statusResponse = z.object({
|
|
2394
|
+
publishId: z.string(),
|
|
2395
|
+
state: publishState,
|
|
2396
|
+
buildHash: z.string(),
|
|
2397
|
+
missing: z.array(z.string()),
|
|
2398
|
+
problems: z.array(publishProblem)
|
|
2399
|
+
});
|
|
2400
|
+
const publishTokenResponse = z.object({
|
|
2401
|
+
token: z.string(),
|
|
2402
|
+
expiresAt: z.string().nullable(),
|
|
2403
|
+
publicProjectId: z.string(),
|
|
2404
|
+
productionUrl: z.string().nullable()
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Content answered, and the answer was not one this CLI can act on.
|
|
2409
|
+
*
|
|
2410
|
+
* Separate from a refusal, which is content saying no in a sentence meant for
|
|
2411
|
+
* a person. This is two programs disagreeing - the copy in `contentApi.ts` and
|
|
2412
|
+
* the service that has moved on from it - and the two want opposite things
|
|
2413
|
+
* said about them.
|
|
2414
|
+
*/
|
|
2415
|
+
class PublishProtocolError extends Error {
|
|
2416
|
+
constructor(message) {
|
|
2417
|
+
super(message);
|
|
2418
|
+
this.name = "PublishProtocolError";
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
const parseDeclare = body => parse(declareResponse, body, "POST /v1/publish");
|
|
2422
|
+
const parseArtifacts = body => parse(artifactsResponse, body, "POST /v1/publish/{id}/artifacts");
|
|
2423
|
+
const parseVerify = body => parse(verifyResponse, body, "POST /v1/publish/{id}/verify");
|
|
2424
|
+
const parsePromote = body => parse(promoteResponse, body, "POST /v1/publish/{id}/promote");
|
|
2425
|
+
const parseStatus = body => parse(statusResponse, body, "GET /v1/publish/{id}");
|
|
2426
|
+
const parsePublishToken = (body, call) => parse(publishTokenResponse, body, call);
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* The `details` of a refusal, when it carries a list of problems.
|
|
2430
|
+
*
|
|
2431
|
+
* Every non-2xx is `{ statusCode, message, details? }`, and the publish routes
|
|
2432
|
+
* put `PublishProblem[]` in `details` - a declaration is answered with EVERY
|
|
2433
|
+
* problem rather than the first, because finding them one round trip at a time
|
|
2434
|
+
* is how a publish takes six attempts. Anything else in there is somebody
|
|
2435
|
+
* else's `details` and is left to the caller's own message.
|
|
2436
|
+
*/
|
|
2437
|
+
function parseProblems(details) {
|
|
2438
|
+
const parsed = z.array(publishProblem).safeParse(details);
|
|
2439
|
+
return parsed.success ? parsed.data : [];
|
|
2440
|
+
}
|
|
2441
|
+
function parse(schema, body, call) {
|
|
2442
|
+
const parsed = schema.safeParse(body);
|
|
2443
|
+
if (!parsed.success) {
|
|
2444
|
+
throw new PublishProtocolError(`${call} answered with something this CLI cannot read: ${fromError(parsed.error).toString()}`);
|
|
2445
|
+
}
|
|
2446
|
+
return parsed.data;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function createPublishClient(options) {
|
|
2450
|
+
const {
|
|
2451
|
+
host,
|
|
2452
|
+
token
|
|
2453
|
+
} = options;
|
|
2454
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
2455
|
+
// Every call, and nothing else: not a cookie, not the project's api key, not
|
|
2456
|
+
// a personal access token.
|
|
2457
|
+
const headers = {
|
|
2458
|
+
Authorization: `Bearer ${token}`
|
|
2459
|
+
};
|
|
2460
|
+
const publishUrl = (publishId, step) => `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
|
|
2461
|
+
return {
|
|
2462
|
+
declare: async body => parseDeclare(await postJson({
|
|
2463
|
+
url: `${host}/v1/publish`,
|
|
2464
|
+
headers,
|
|
2465
|
+
body,
|
|
2466
|
+
fetchImpl
|
|
2467
|
+
})),
|
|
2468
|
+
confirmArtifacts: async publishId => parseArtifacts(await postJson({
|
|
2469
|
+
url: publishUrl(publishId, "artifacts"),
|
|
2470
|
+
headers,
|
|
2471
|
+
fetchImpl
|
|
2472
|
+
})),
|
|
2473
|
+
verify: async publishId => parseVerify(await postJson({
|
|
2474
|
+
url: publishUrl(publishId, "verify"),
|
|
2475
|
+
headers,
|
|
2476
|
+
fetchImpl
|
|
2477
|
+
})),
|
|
2478
|
+
promote: async publishId => parsePromote(await postJson({
|
|
2479
|
+
url: publishUrl(publishId, "promote"),
|
|
2480
|
+
headers,
|
|
2481
|
+
fetchImpl
|
|
2482
|
+
})),
|
|
2483
|
+
status: async publishId => parseStatus(await getJson({
|
|
2484
|
+
url: publishUrl(publishId),
|
|
2485
|
+
headers,
|
|
2486
|
+
fetchImpl
|
|
2487
|
+
})),
|
|
2488
|
+
upload: (slot, file) => putArtifact(slot, file, fetchImpl)
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/** Object storage refused the bytes, or could not be reached. */
|
|
2493
|
+
class UploadError extends Error {
|
|
2494
|
+
statusCode;
|
|
2495
|
+
key;
|
|
2496
|
+
constructor(statusCode, key, message) {
|
|
2497
|
+
super(message);
|
|
2498
|
+
this.name = "UploadError";
|
|
2499
|
+
this.statusCode = statusCode;
|
|
2500
|
+
this.key = key;
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* A slot's permission to write has run out.
|
|
2506
|
+
*
|
|
2507
|
+
* Not a failed publish: declaring again mints fresh slots and the publish
|
|
2508
|
+
* carries on from where it was, which is why this is its own kind of failure
|
|
2509
|
+
* rather than an error message.
|
|
2510
|
+
*/
|
|
2511
|
+
function isExpiredSlot(err) {
|
|
2512
|
+
return err.statusCode === 403 || err.statusCode === 401;
|
|
2513
|
+
}
|
|
2514
|
+
async function putArtifact(slot, file, fetchImpl) {
|
|
2515
|
+
/*
|
|
2516
|
+
* Read, rather than streamed.
|
|
2517
|
+
*
|
|
2518
|
+
* `ContentLength` is signed into the slot's URL, so the store rejects a body
|
|
2519
|
+
* of a different size - and a streamed body goes out chunked, which is a
|
|
2520
|
+
* different size as far as the signature is concerned. An artifact is capped
|
|
2521
|
+
* at 128 MB by the API, which is the bound this trades against.
|
|
2522
|
+
*/
|
|
2523
|
+
const body = await fs.promises.readFile(file);
|
|
2524
|
+
let res;
|
|
2525
|
+
try {
|
|
2526
|
+
res = await fetchImpl(slot.url, {
|
|
2527
|
+
method: slot.method,
|
|
2528
|
+
headers: slot.headers,
|
|
2529
|
+
body
|
|
2530
|
+
});
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
throw new UploadError(0, slot.key, `${slot.key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2533
|
+
}
|
|
2534
|
+
if (!res.ok) {
|
|
2535
|
+
// The store answers XML, and a whole error document in a CI log buries the
|
|
2536
|
+
// line that matters. The status is the actionable half.
|
|
2537
|
+
throw new UploadError(res.status, slot.key, `${slot.key}: object storage answered ${res.status} ${res.statusText}`);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/**
|
|
2542
|
+
* Run `worker` over `items`, `limit` at a time.
|
|
2543
|
+
*
|
|
2544
|
+
* A project can have hundreds of small artifacts, so one at a time is minutes
|
|
2545
|
+
* of latency and all at once is hundreds of open sockets on a CI runner. The
|
|
2546
|
+
* first failure stops the pool: there is no point uploading the rest of a
|
|
2547
|
+
* publish that is not going to be promoted.
|
|
2548
|
+
*/
|
|
2549
|
+
async function pool(items, limit, worker) {
|
|
2550
|
+
let next = 0;
|
|
2551
|
+
let failure = undefined;
|
|
2552
|
+
const run = async () => {
|
|
2553
|
+
for (;;) {
|
|
2554
|
+
if (failure !== undefined) {
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const index = next++;
|
|
2558
|
+
if (index >= items.length) {
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
try {
|
|
2562
|
+
await worker(items[index]);
|
|
2563
|
+
} catch (err) {
|
|
2564
|
+
failure = err;
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
};
|
|
2569
|
+
await Promise.all(Array.from({
|
|
2570
|
+
length: Math.min(limit, items.length)
|
|
2571
|
+
}, () => run()));
|
|
2572
|
+
if (failure !== undefined) {
|
|
2573
|
+
throw failure;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/**
|
|
2578
|
+
* The credential `val publish` presents to content, and where it came from.
|
|
2579
|
+
*
|
|
2580
|
+
* Whatever the publisher started with, this is a *project* token: it can do one
|
|
2581
|
+
* thing to one project. A personal access token is a person's credential -
|
|
2582
|
+
* org-wide, read and write on every project they can reach - so it never
|
|
2583
|
+
* travels further than val.build, which exchanges it for one of these.
|
|
2584
|
+
*/
|
|
2585
|
+
|
|
2586
|
+
const NO_CREDENTIAL = "Publishing needs a credential.\n\n" + " In CI: set VAL_PROJECT_TOKEN as a repository secret.\n" + " On your machine: run\n\n" + " npx val login\n\n" + "Neither is a command line flag, deliberately: an argument is visible to\n" + "anyone who can list processes, and it is kept in shell history and in the\n" + "log of every CI job that echoes its command line.";
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* The credential, in the order the publisher should prefer them.
|
|
2590
|
+
*
|
|
2591
|
+
* 1. `VAL_PROJECT_TOKEN` from the environment - used as it is, no exchange.
|
|
2592
|
+
* This is what a repository created by `/new` holds, and the only secret it
|
|
2593
|
+
* needs: the token names its project, so content can answer *where* to
|
|
2594
|
+
* publish as well as *whether*.
|
|
2595
|
+
* 2. The `val login` token in `<root>/.val/pat.json` - exchanged first, because
|
|
2596
|
+
* what publishes must be a project token. The exchange is addressed by org
|
|
2597
|
+
* and project, since a personal access token does not name one; that is why
|
|
2598
|
+
* this way needs `project` and the first does not.
|
|
2599
|
+
* 3. Neither, which is not an error we can guess our way out of.
|
|
2600
|
+
*
|
|
2601
|
+
* Returns a result rather than throwing: "no credential" is one of the things a
|
|
2602
|
+
* publisher has to say well, and the sentence differs by which half was missing.
|
|
2603
|
+
*/
|
|
2604
|
+
async function resolvePublishCredential(options) {
|
|
2605
|
+
var _env$VAL_PROJECT_TOKE;
|
|
2606
|
+
const env = options.env ?? process.env;
|
|
2607
|
+
// An unset secret reaches a CI job as the empty string rather than as absent,
|
|
2608
|
+
// and an empty credential presented to content is a 401 that reads as "your
|
|
2609
|
+
// token is bad" instead of "you never set one".
|
|
2610
|
+
const projectToken = (_env$VAL_PROJECT_TOKE = env.VAL_PROJECT_TOKEN) === null || _env$VAL_PROJECT_TOKE === void 0 ? void 0 : _env$VAL_PROJECT_TOKE.trim();
|
|
2611
|
+
if (projectToken) {
|
|
2612
|
+
if (looksLikePersonalAccessToken(projectToken)) {
|
|
2613
|
+
return {
|
|
2614
|
+
status: "error",
|
|
2615
|
+
message: "VAL_PROJECT_TOKEN looks like a personal access token, not a project\n" + "token. A project token begins with `val_pt_` and is made for one\n" + "project; a personal access token belongs to a person and cannot\n" + "publish. Mint a project token on the project's settings page."
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
return {
|
|
2619
|
+
status: "ok",
|
|
2620
|
+
credential: {
|
|
2621
|
+
token: projectToken,
|
|
2622
|
+
origin: "VAL_PROJECT_TOKEN",
|
|
2623
|
+
// A standing token need not expire, and nothing here has been told
|
|
2624
|
+
// otherwise. Content is where an expiry would come from.
|
|
2625
|
+
expiresAt: null
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
const pat = readPersonalAccessToken(options.root);
|
|
2630
|
+
if (pat.status === "error") {
|
|
2631
|
+
return pat;
|
|
2632
|
+
}
|
|
2633
|
+
if (pat.status === "none") {
|
|
2634
|
+
var _ref;
|
|
2635
|
+
/*
|
|
2636
|
+
* The old names, still read.
|
|
2637
|
+
*
|
|
2638
|
+
* This is where the api key used to be passed, and this platform's own CI
|
|
2639
|
+
* still passes it: a rename that breaks the publisher is a rename that gets
|
|
2640
|
+
* reverted. It will not be accepted for long - publishing is being taken
|
|
2641
|
+
* out of what an api key may do - and content says so in its own words
|
|
2642
|
+
* when it refuses one, which is a better sentence than a guess here.
|
|
2643
|
+
*/
|
|
2644
|
+
const legacy = (_ref = env.VAL_APP_TOKEN ?? env.PLATFORM_TOKEN) === null || _ref === void 0 ? void 0 : _ref.trim();
|
|
2645
|
+
if (legacy) {
|
|
2646
|
+
return {
|
|
2647
|
+
status: "ok",
|
|
2648
|
+
credential: {
|
|
2649
|
+
token: legacy,
|
|
2650
|
+
origin: "VAL_APP_TOKEN",
|
|
2651
|
+
expiresAt: null
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
return {
|
|
2656
|
+
status: "error",
|
|
2657
|
+
message: NO_CREDENTIAL
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
if (!options.project) {
|
|
2661
|
+
return {
|
|
2662
|
+
status: "error",
|
|
2663
|
+
message: "You are logged in, but nothing says which project to publish.\n\n" + 'Set `project` ("<org>/<project>") in val.config, or the VAL_PROJECT\n' + "environment variable.\n\n" + "A `val login` token belongs to you rather than to a project, so it\n" + "cannot say which one you are standing in."
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
const parts = options.project.split("/");
|
|
2667
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
2668
|
+
return {
|
|
2669
|
+
status: "error",
|
|
2670
|
+
message: `Invalid project: "${options.project}". Expected "<org>/<project>".`
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
const [orgName, projectName] = parts;
|
|
2674
|
+
return exchangePersonalAccessToken({
|
|
2675
|
+
pat: pat.pat,
|
|
2676
|
+
orgName,
|
|
2677
|
+
projectName,
|
|
2678
|
+
env,
|
|
2679
|
+
...(options.fetchImpl ? {
|
|
2680
|
+
fetchImpl: options.fetchImpl
|
|
2681
|
+
} : {})
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/**
|
|
2686
|
+
* Trade the token you have for the token that may publish.
|
|
2687
|
+
*
|
|
2688
|
+
* Ten minutes, one project, one scope. The person's identity survives the
|
|
2689
|
+
* exchange even though the credential will not outlive the command, which is
|
|
2690
|
+
* what gives "who published this" an answer afterwards.
|
|
2691
|
+
*/
|
|
2692
|
+
async function exchangePersonalAccessToken(options) {
|
|
2693
|
+
const host = getContentHost(options.env);
|
|
2694
|
+
const url = `${host}/v1/${encodeURIComponent(options.orgName)}/${encodeURIComponent(options.projectName)}/publish-token`;
|
|
2695
|
+
let body;
|
|
2696
|
+
try {
|
|
2697
|
+
body = await postJson({
|
|
2698
|
+
url,
|
|
2699
|
+
headers: {
|
|
2700
|
+
"x-val-pat": options.pat
|
|
2701
|
+
},
|
|
2702
|
+
...(options.fetchImpl ? {
|
|
2703
|
+
fetchImpl: options.fetchImpl
|
|
2704
|
+
} : {})
|
|
2705
|
+
});
|
|
2706
|
+
} catch (err) {
|
|
2707
|
+
if (err instanceof ContentHostError) {
|
|
2708
|
+
return {
|
|
2709
|
+
status: "error",
|
|
2710
|
+
message: exchangeFailureMessage(err, options)
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
throw err;
|
|
2714
|
+
}
|
|
2715
|
+
let exchanged;
|
|
2716
|
+
try {
|
|
2717
|
+
exchanged = parsePublishToken(body, `POST ${url}`);
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
if (err instanceof PublishProtocolError) {
|
|
2720
|
+
return {
|
|
2721
|
+
status: "error",
|
|
2722
|
+
message: err.message
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
throw err;
|
|
2726
|
+
}
|
|
2727
|
+
return {
|
|
2728
|
+
status: "ok",
|
|
2729
|
+
credential: {
|
|
2730
|
+
token: exchanged.token,
|
|
2731
|
+
origin: "val login",
|
|
2732
|
+
expiresAt: exchanged.expiresAt
|
|
2733
|
+
}
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
function exchangeFailureMessage(err, options) {
|
|
2737
|
+
const project = `${options.orgName}/${options.projectName}`;
|
|
2738
|
+
if (err.statusCode === 401) {
|
|
2739
|
+
return "Your Val login is no longer valid - it may have expired. Log in again:\n\n" + " npx val login";
|
|
2740
|
+
}
|
|
2741
|
+
if (err.statusCode === 403) {
|
|
2742
|
+
return `Your Val account may not publish ${project}: ${err.message}`;
|
|
2743
|
+
}
|
|
2744
|
+
if (err.statusCode === 404) {
|
|
2745
|
+
return `No project ${project}. Check \`project\` in val.config (or VAL_PROJECT):\n` + "it is the org and project as val.build names them, not the repository.";
|
|
2746
|
+
}
|
|
2747
|
+
return `Could not get a publish token for ${project}: ${err.message}` + (err.details ? `\n${err.details}` : "");
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/** A personal access token is 32 random bytes as hex, and carries no prefix. */
|
|
2751
|
+
function looksLikePersonalAccessToken(token) {
|
|
2752
|
+
return /^[0-9a-f]{64}$/i.test(token);
|
|
2753
|
+
}
|
|
2754
|
+
function readPersonalAccessToken(root) {
|
|
2755
|
+
const patFile = getPersonalAccessTokenPath(root);
|
|
2756
|
+
if (!fs.existsSync(patFile)) {
|
|
2757
|
+
return {
|
|
2758
|
+
status: "none"
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
const parsed = parsePersonalAccessTokenFile(fs.readFileSync(patFile, "utf-8"));
|
|
2762
|
+
if (!parsed.success) {
|
|
2763
|
+
return {
|
|
2764
|
+
status: "error",
|
|
2765
|
+
message: `Could not read the Val login at ${patFile}: ${parsed.error}.\n` + "Log in again:\n\n npx val login"
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
return {
|
|
2769
|
+
status: "ok",
|
|
2770
|
+
pat: parsed.data.pat
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
/** How many artifacts go up at once, and how often a lost one is retried. */
|
|
2775
|
+
const UPLOAD_CONCURRENCY = 6;
|
|
2776
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
2777
|
+
/**
|
|
2778
|
+
* How many times the publish is declared before giving up.
|
|
2779
|
+
*
|
|
2780
|
+
* A round ends by declaring again, and there are two reasons to: a slot's hour
|
|
2781
|
+
* ran out mid-upload, or content re-hashed what landed and something did not
|
|
2782
|
+
* arrive as declared. Both are fixed by fresh slots and sending again. A third
|
|
2783
|
+
* round that still does not confirm is not a slow network.
|
|
2784
|
+
*/
|
|
2785
|
+
const DECLARE_ROUNDS = 3;
|
|
2786
|
+
/**
|
|
2787
|
+
* Publish a build through content.val.build.
|
|
2788
|
+
*
|
|
2789
|
+
* Declare what the build is made of, upload the artifacts content does not
|
|
2790
|
+
* already hold, confirm, let content build and render a canary, and only then
|
|
2791
|
+
* ask it to promote. Every decision in that sequence is content's; this
|
|
2792
|
+
* carries bytes and reports back.
|
|
2793
|
+
*
|
|
2794
|
+
* Content is also the only host it talks to. Content holds the operator
|
|
2795
|
+
* relationship with the build platform and calls it during verify and promote,
|
|
2796
|
+
* which is what lets a repository publish with a token that can do nothing
|
|
2797
|
+
* else - there is no loader address here, no project id, and no credential
|
|
2798
|
+
* that could reach either.
|
|
2799
|
+
*/
|
|
2800
|
+
async function runPublish(options) {
|
|
2801
|
+
const env = options.env ?? process.env;
|
|
2802
|
+
const log = options.log ?? (line => console.log(line));
|
|
2803
|
+
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
2804
|
+
const root = options.root ? path.resolve(options.root) : process.cwd();
|
|
2805
|
+
const dir = resolveArtifactsDir({
|
|
2806
|
+
root,
|
|
2807
|
+
...(options.artifacts ? {
|
|
2808
|
+
dir: options.artifacts
|
|
2809
|
+
} : {})
|
|
2810
|
+
});
|
|
2811
|
+
if (dir.status === "error") {
|
|
2812
|
+
return {
|
|
2813
|
+
status: "error",
|
|
2814
|
+
message: dir.message
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
const collected = await collectArtifacts(dir.dir);
|
|
2818
|
+
for (const skipped of collected.skipped) {
|
|
2819
|
+
log(`Skipped ${skipped}`);
|
|
2820
|
+
}
|
|
2821
|
+
if (collected.artifacts.length === 0) {
|
|
2822
|
+
return {
|
|
2823
|
+
status: "error",
|
|
2824
|
+
message: `There is nothing in ${dir.dir}. Build the project before publishing it.`
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
const layer = await layerRevOf(collected.artifacts);
|
|
2828
|
+
if (layer.status === "error") {
|
|
2829
|
+
return {
|
|
2830
|
+
status: "error",
|
|
2831
|
+
message: layer.message
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
const layerRev = options.layerRev ?? layer.layerRev;
|
|
2835
|
+
const git = await resolveGit({
|
|
2836
|
+
root,
|
|
2837
|
+
options,
|
|
2838
|
+
env
|
|
2839
|
+
});
|
|
2840
|
+
if (git.status === "error") {
|
|
2841
|
+
return git;
|
|
2842
|
+
}
|
|
2843
|
+
const config = await findAndEvalValConfigFile(root).catch(() => null);
|
|
2844
|
+
const credential = await resolvePublishCredential({
|
|
2845
|
+
root,
|
|
2846
|
+
project: (config === null || config === void 0 ? void 0 : config.project) ?? env.VAL_PROJECT ?? null,
|
|
2847
|
+
env,
|
|
2848
|
+
...(options.fetchImpl ? {
|
|
2849
|
+
fetchImpl: options.fetchImpl
|
|
2850
|
+
} : {})
|
|
2851
|
+
});
|
|
2852
|
+
if (credential.status === "error") {
|
|
2853
|
+
return {
|
|
2854
|
+
status: "error",
|
|
2855
|
+
message: credential.message
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
const client = createPublishClient({
|
|
2859
|
+
host: getContentHost(env),
|
|
2860
|
+
token: credential.credential.token,
|
|
2861
|
+
...(options.fetchImpl ? {
|
|
2862
|
+
fetchImpl: options.fetchImpl
|
|
2863
|
+
} : {})
|
|
2864
|
+
});
|
|
2865
|
+
const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
|
|
2866
|
+
log(`${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` + `, at ${git.commit.slice(0, 7)} on ${git.branch}`);
|
|
2867
|
+
try {
|
|
2868
|
+
return await publishDeclaredBuild({
|
|
2869
|
+
client,
|
|
2870
|
+
log,
|
|
2871
|
+
sleep,
|
|
2872
|
+
artifacts: collected.artifacts,
|
|
2873
|
+
totalBytes: collected.totalBytes,
|
|
2874
|
+
body: {
|
|
2875
|
+
buildHash,
|
|
2876
|
+
commit: git.commit,
|
|
2877
|
+
branch: git.branch,
|
|
2878
|
+
layerRev,
|
|
2879
|
+
linksOwnCss: options.linksOwnCss ?? null,
|
|
2880
|
+
artifacts: collected.artifacts.map(({
|
|
2881
|
+
key,
|
|
2882
|
+
sha256,
|
|
2883
|
+
bytes
|
|
2884
|
+
}) => ({
|
|
2885
|
+
key,
|
|
2886
|
+
sha256,
|
|
2887
|
+
bytes
|
|
2888
|
+
}))
|
|
2889
|
+
},
|
|
2890
|
+
dryRun: options.dryRun === true
|
|
2891
|
+
});
|
|
2892
|
+
} catch (err) {
|
|
2893
|
+
return errorFrom(err);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
async function publishDeclaredBuild(args) {
|
|
2897
|
+
const {
|
|
2898
|
+
client,
|
|
2899
|
+
log,
|
|
2900
|
+
sleep,
|
|
2901
|
+
artifacts,
|
|
2902
|
+
body,
|
|
2903
|
+
dryRun
|
|
2904
|
+
} = args;
|
|
2905
|
+
const fileOf = new Map(artifacts.map(artifact => [artifact.key, artifact.file]));
|
|
2906
|
+
const bytesOf = new Map(artifacts.map(artifact => [artifact.key, artifact.bytes]));
|
|
2907
|
+
const uploadedKeys = new Set();
|
|
2908
|
+
let uploadedBytes = 0;
|
|
2909
|
+
let publishId = null;
|
|
2910
|
+
let confirmed = false;
|
|
2911
|
+
for (let round = 1; round <= DECLARE_ROUNDS && !confirmed; round++) {
|
|
2912
|
+
const declared = await declare(client, body);
|
|
2913
|
+
if (declared.status === "refused") {
|
|
2914
|
+
return {
|
|
2915
|
+
status: "failed",
|
|
2916
|
+
publishId,
|
|
2917
|
+
state: null,
|
|
2918
|
+
message: declared.message,
|
|
2919
|
+
problems: declared.problems,
|
|
2920
|
+
previewUrl: null
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
publishId = declared.response.publishId;
|
|
2924
|
+
if (declared.response.state === "live") {
|
|
2925
|
+
/*
|
|
2926
|
+
* Already published, and not re-opened - re-declaring would mint slots
|
|
2927
|
+
* to overwrite the bytes of a build that is currently serving. A CI job
|
|
2928
|
+
* re-run after a successful publish lands here, and it is a success.
|
|
2929
|
+
*/
|
|
2930
|
+
log("This build is already live.");
|
|
2931
|
+
return {
|
|
2932
|
+
status: "live",
|
|
2933
|
+
publishId: declared.response.publishId,
|
|
2934
|
+
url: declared.response.project.siteUrl,
|
|
2935
|
+
commit: body.commit,
|
|
2936
|
+
artifacts: artifacts.length,
|
|
2937
|
+
uploaded: uploadedKeys.size,
|
|
2938
|
+
uploadedBytes,
|
|
2939
|
+
previewUrl: null
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
const slots = declared.response.uploads;
|
|
2943
|
+
if (slots.length > 0) {
|
|
2944
|
+
const bytes = slots.reduce((sum, slot) => sum + (bytesOf.get(slot.key) ?? 0), 0);
|
|
2945
|
+
log(round === 1 ? `Uploading ${count(slots.length, "artifact")}, ${formatBytes(bytes)}` + ` (${declared.response.have.length} already held)` : `Uploading ${count(slots.length, "artifact")} again`);
|
|
2946
|
+
const outcome = await uploadAll({
|
|
2947
|
+
client,
|
|
2948
|
+
slots,
|
|
2949
|
+
fileOf,
|
|
2950
|
+
sleep
|
|
2951
|
+
});
|
|
2952
|
+
for (const key of outcome.uploaded) {
|
|
2953
|
+
if (!uploadedKeys.has(key)) {
|
|
2954
|
+
uploadedKeys.add(key);
|
|
2955
|
+
uploadedBytes += bytesOf.get(key) ?? 0;
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
if (outcome.status === "expired") {
|
|
2959
|
+
// The hour on the slots ran out while we were using them. Declaring
|
|
2960
|
+
// again mints fresh ones; nothing about the publish has failed.
|
|
2961
|
+
log("Upload slots expired. Declaring again for fresh ones.");
|
|
2962
|
+
continue;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
const confirmation = await confirmArtifacts(client, declared.response.publishId);
|
|
2966
|
+
if (confirmation.status === "mismatch") {
|
|
2967
|
+
log(`Content did not receive ${count(confirmation.problems.length, "artifact")} as declared. Declaring again.`);
|
|
2968
|
+
if (round === DECLARE_ROUNDS) {
|
|
2969
|
+
return {
|
|
2970
|
+
status: "failed",
|
|
2971
|
+
publishId,
|
|
2972
|
+
state: null,
|
|
2973
|
+
message: "Some artifacts did not arrive as declared.",
|
|
2974
|
+
problems: confirmation.problems,
|
|
2975
|
+
previewUrl: null
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
continue;
|
|
2979
|
+
}
|
|
2980
|
+
if (confirmation.state !== "ready") {
|
|
2981
|
+
return {
|
|
2982
|
+
status: "failed",
|
|
2983
|
+
publishId,
|
|
2984
|
+
state: confirmation.state,
|
|
2985
|
+
message: `Content left this publish in "${confirmation.state}" after confirming the artifacts.`,
|
|
2986
|
+
problems: confirmation.problems,
|
|
2987
|
+
previewUrl: null
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
confirmed = true;
|
|
2991
|
+
}
|
|
2992
|
+
if (!confirmed || publishId === null) {
|
|
2993
|
+
return {
|
|
2994
|
+
status: "failed",
|
|
2995
|
+
publishId,
|
|
2996
|
+
state: null,
|
|
2997
|
+
message: `The artifacts were not confirmed after ${DECLARE_ROUNDS} attempts.`,
|
|
2998
|
+
problems: [],
|
|
2999
|
+
previewUrl: null
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
log("Verifying: content is building and rendering this as a canary");
|
|
3003
|
+
let verified;
|
|
3004
|
+
try {
|
|
3005
|
+
verified = await client.verify(publishId);
|
|
3006
|
+
} catch (err) {
|
|
3007
|
+
verified = await recoverWithStatus(err, client, publishId);
|
|
3008
|
+
}
|
|
3009
|
+
const summary = {
|
|
3010
|
+
publishId,
|
|
3011
|
+
artifacts: artifacts.length,
|
|
3012
|
+
uploaded: uploadedKeys.size,
|
|
3013
|
+
uploadedBytes,
|
|
3014
|
+
previewUrl: "previewUrl" in verified ? verified.previewUrl : null
|
|
3015
|
+
};
|
|
3016
|
+
const verifiedOk = "ok" in verified ? verified.ok : verified.state === "verified";
|
|
3017
|
+
if (!verifiedOk) {
|
|
3018
|
+
return {
|
|
3019
|
+
status: "failed",
|
|
3020
|
+
publishId,
|
|
3021
|
+
state: verified.state,
|
|
3022
|
+
message: "The canary did not build and render, so nothing was promoted.",
|
|
3023
|
+
problems: verified.problems,
|
|
3024
|
+
previewUrl: summary.previewUrl
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
if (dryRun) {
|
|
3028
|
+
log("Verified. Stopping here: --dry-run does not move the pointer.");
|
|
3029
|
+
return {
|
|
3030
|
+
status: "verified",
|
|
3031
|
+
...summary
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
const promoted = await promote(client, publishId);
|
|
3035
|
+
if (promoted.status === "stale") {
|
|
3036
|
+
return {
|
|
3037
|
+
status: "failed",
|
|
3038
|
+
publishId,
|
|
3039
|
+
state: null,
|
|
3040
|
+
message: promoted.message,
|
|
3041
|
+
problems: promoted.problems,
|
|
3042
|
+
previewUrl: summary.previewUrl
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
if (promoted.response.state !== "live") {
|
|
3046
|
+
return {
|
|
3047
|
+
status: "failed",
|
|
3048
|
+
publishId,
|
|
3049
|
+
state: promoted.response.state,
|
|
3050
|
+
message: `The pointer was not moved: this publish is "${promoted.response.state}".`,
|
|
3051
|
+
problems: [],
|
|
3052
|
+
previewUrl: summary.previewUrl
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
return {
|
|
3056
|
+
status: "live",
|
|
3057
|
+
url: promoted.response.url,
|
|
3058
|
+
commit: promoted.response.commit,
|
|
3059
|
+
...summary
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
async function declare(client, body) {
|
|
3063
|
+
try {
|
|
3064
|
+
return {
|
|
3065
|
+
status: "ok",
|
|
3066
|
+
response: await client.declare(body)
|
|
3067
|
+
};
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
// A malformed declaration is answered with EVERY problem rather than the
|
|
3070
|
+
// first, so all of them are carried through to the report.
|
|
3071
|
+
if (err instanceof ContentHostError && err.statusCode === 400) {
|
|
3072
|
+
return {
|
|
3073
|
+
status: "refused",
|
|
3074
|
+
message: err.message,
|
|
3075
|
+
problems: parseProblems(err.details)
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
throw err;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
async function confirmArtifacts(client, publishId) {
|
|
3082
|
+
try {
|
|
3083
|
+
const response = await client.confirmArtifacts(publishId);
|
|
3084
|
+
return {
|
|
3085
|
+
status: "ok",
|
|
3086
|
+
state: response.state,
|
|
3087
|
+
problems: response.problems
|
|
3088
|
+
};
|
|
3089
|
+
} catch (err) {
|
|
3090
|
+
if (err instanceof ContentHostError && err.statusCode === 409) {
|
|
3091
|
+
// Either nothing is at that key or the bytes are not what was declared.
|
|
3092
|
+
// Both are fixed by fresh slots and sending again.
|
|
3093
|
+
return {
|
|
3094
|
+
status: "mismatch",
|
|
3095
|
+
problems: parseProblems(err.details)
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
throw err;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
async function promote(client, publishId) {
|
|
3102
|
+
try {
|
|
3103
|
+
return {
|
|
3104
|
+
status: "ok",
|
|
3105
|
+
response: await client.promote(publishId)
|
|
3106
|
+
};
|
|
3107
|
+
} catch (err) {
|
|
3108
|
+
if (err instanceof ContentHostError && err.statusCode === 0) {
|
|
3109
|
+
// The pointer may well have moved; the answer saying so was lost. Ask.
|
|
3110
|
+
const status = await client.status(publishId);
|
|
3111
|
+
return {
|
|
3112
|
+
status: "ok",
|
|
3113
|
+
response: {
|
|
3114
|
+
state: status.state,
|
|
3115
|
+
url: null,
|
|
3116
|
+
commit: null
|
|
3117
|
+
}
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
if (err instanceof ContentHostError && err.statusCode === 409) {
|
|
3121
|
+
/*
|
|
3122
|
+
* Somebody pushed while this was building.
|
|
3123
|
+
*
|
|
3124
|
+
* Content's own sentence already says what to do ("Rebuild from the
|
|
3125
|
+
* current head"), so it is passed through rather than replaced, and the
|
|
3126
|
+
* `POINTER_STALE` code and the loader's words come out of `details` the
|
|
3127
|
+
* way every other refusal's problems do.
|
|
3128
|
+
*
|
|
3129
|
+
* `head` is read if it is there and not required: the API's written
|
|
3130
|
+
* contract says a stale promote carries the sha the branch is at now,
|
|
3131
|
+
* and `postPublishPromote.ts` does not send it today. Naming the sha is
|
|
3132
|
+
* worth having when it arrives; failing without it would be this CLI
|
|
3133
|
+
* refusing an answer that is otherwise complete.
|
|
3134
|
+
*/
|
|
3135
|
+
const head = stringField(err.body, "head");
|
|
3136
|
+
return {
|
|
3137
|
+
status: "stale",
|
|
3138
|
+
message: err.message + (head ? ` The branch is at ${head.slice(0, 7)} now.` : ""),
|
|
3139
|
+
problems: parseProblems(err.details)
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
throw err;
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
/**
|
|
3147
|
+
* A step that did not answer is not a step that did not happen.
|
|
3148
|
+
*
|
|
3149
|
+
* Verify and promote can take minutes on content's side, and a connection that
|
|
3150
|
+
* dies in the middle says nothing about what content did. Problems are kept on
|
|
3151
|
+
* the row rather than only returned by the call that found them, so asking is
|
|
3152
|
+
* not retrying: one `GET` gets the same answer the lost response carried.
|
|
3153
|
+
*/
|
|
3154
|
+
async function recoverWithStatus(err, client, publishId) {
|
|
3155
|
+
if (err instanceof ContentHostError && err.statusCode === 0) {
|
|
3156
|
+
return client.status(publishId);
|
|
3157
|
+
}
|
|
3158
|
+
throw err;
|
|
3159
|
+
}
|
|
3160
|
+
async function uploadAll(args) {
|
|
3161
|
+
const {
|
|
3162
|
+
client,
|
|
3163
|
+
slots,
|
|
3164
|
+
fileOf,
|
|
3165
|
+
sleep
|
|
3166
|
+
} = args;
|
|
3167
|
+
const uploaded = [];
|
|
3168
|
+
try {
|
|
3169
|
+
await pool(slots, UPLOAD_CONCURRENCY, async slot => {
|
|
3170
|
+
const file = fileOf.get(slot.key);
|
|
3171
|
+
if (file === undefined) {
|
|
3172
|
+
// Content asked for a key this build does not have. Declaring again
|
|
3173
|
+
// cannot fix that, so it is not a round; it is a bug on one side.
|
|
3174
|
+
throw new PublishProtocolError(`Content asked for an artifact this build does not have: ${slot.key}`);
|
|
3175
|
+
}
|
|
3176
|
+
for (let attempt = 1;; attempt++) {
|
|
3177
|
+
try {
|
|
3178
|
+
await client.upload(slot, file);
|
|
3179
|
+
uploaded.push(slot.key);
|
|
3180
|
+
return;
|
|
3181
|
+
} catch (err) {
|
|
3182
|
+
if (attempt >= UPLOAD_ATTEMPTS || !(err instanceof UploadError) || !isWorthRetrying(err)) {
|
|
3183
|
+
throw err;
|
|
3184
|
+
}
|
|
3185
|
+
await sleep(250 * 2 ** attempt);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
});
|
|
3189
|
+
} catch (err) {
|
|
3190
|
+
if (err instanceof UploadError && isExpiredSlot(err)) {
|
|
3191
|
+
return {
|
|
3192
|
+
status: "expired",
|
|
3193
|
+
uploaded
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
throw err;
|
|
3197
|
+
}
|
|
3198
|
+
return {
|
|
3199
|
+
status: "ok",
|
|
3200
|
+
uploaded
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
/**
|
|
3205
|
+
* Whether to try the same slot again.
|
|
3206
|
+
*
|
|
3207
|
+
* A dropped connection or a busy store, yes. A 403 is an expired slot and will
|
|
3208
|
+
* be a 403 every time - that one is answered by declaring again, which is a
|
|
3209
|
+
* round rather than a retry.
|
|
3210
|
+
*/
|
|
3211
|
+
function isWorthRetrying(err) {
|
|
3212
|
+
return err.statusCode === 0 || err.statusCode === 429 || err.statusCode >= 500;
|
|
3213
|
+
}
|
|
3214
|
+
function errorFrom(err) {
|
|
3215
|
+
if (err instanceof ContentHostError) {
|
|
3216
|
+
const detail = detailText(err.details);
|
|
3217
|
+
return {
|
|
3218
|
+
status: "error",
|
|
3219
|
+
message: err.statusCode === 401 || err.statusCode === 403 ? `${err.message}\n\nThe credential was refused. A project token can be revoked, and only a token carrying val:publish may publish.` : `${err.message}${detail ? `\n${detail}` : ""}`
|
|
3220
|
+
};
|
|
3221
|
+
}
|
|
3222
|
+
if (err instanceof UploadError || err instanceof PublishProtocolError) {
|
|
3223
|
+
return {
|
|
3224
|
+
status: "error",
|
|
3225
|
+
message: err.message
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
throw err;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/**
|
|
3232
|
+
* Which commit this build is of, and which branch content commits saves to.
|
|
3233
|
+
*
|
|
3234
|
+
* The commit is baked into the published server and decides which version of
|
|
3235
|
+
* its own content the site reads, so a wrong answer is worse than none: what
|
|
3236
|
+
* the caller said, then Val's own variables, then the CI runner, then git.
|
|
3237
|
+
*
|
|
3238
|
+
* `GITHUB_SHA` before `git rev-parse` because on a `pull_request` event the
|
|
3239
|
+
* checkout is a detached merge commit that exists only on the runner, and
|
|
3240
|
+
* content committed against it would be committed against nothing.
|
|
3241
|
+
*/
|
|
3242
|
+
async function resolveGit(args) {
|
|
3243
|
+
const {
|
|
3244
|
+
root,
|
|
3245
|
+
options,
|
|
3246
|
+
env
|
|
3247
|
+
} = args;
|
|
3248
|
+
const fromGit = await safeReadGit(root);
|
|
3249
|
+
const commit = options.commit ?? env.VAL_GIT_COMMIT ?? env.GITHUB_SHA ?? fromGit.commit;
|
|
3250
|
+
const branchName = options.branch ?? env.VAL_GIT_BRANCH ?? env.GITHUB_REF_NAME ?? fromGit.branch;
|
|
3251
|
+
// A detached HEAD has no branch, and "HEAD" is not one: content commits
|
|
3252
|
+
// saves to a branch, and the failure would arrive hours later as a save that
|
|
3253
|
+
// cannot be published.
|
|
3254
|
+
const branch = branchName === "HEAD" ? undefined : branchName;
|
|
3255
|
+
if (!commit || !branch) {
|
|
3256
|
+
return {
|
|
3257
|
+
status: "error",
|
|
3258
|
+
message: `Could not tell which ${!commit ? "commit" : "branch"} this build is of.\n\n` + "Pass --commit and --branch, or set VAL_GIT_COMMIT and VAL_GIT_BRANCH.\n" + "The commit is baked into the published site and decides which version of\n" + "its own content it reads, so there is nothing safe to guess."
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
return {
|
|
3262
|
+
status: "ok",
|
|
3263
|
+
commit,
|
|
3264
|
+
branch
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
function stringField(body, key) {
|
|
3268
|
+
if (typeof body === "object" && body !== null) {
|
|
3269
|
+
const value = Reflect.get(body, key);
|
|
3270
|
+
if (typeof value === "string" && value !== "") {
|
|
3271
|
+
return value;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
return null;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
/** "1 artifact", "3 artifacts". Every one of these lines ends up in a CI log. */
|
|
3278
|
+
function count(n, noun) {
|
|
3279
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
3280
|
+
}
|
|
3281
|
+
function formatBytes(bytes) {
|
|
3282
|
+
if (bytes < 1024) {
|
|
3283
|
+
return `${bytes} B`;
|
|
3284
|
+
}
|
|
3285
|
+
const units = ["kB", "MB", "GB"];
|
|
3286
|
+
let value = bytes / 1024;
|
|
3287
|
+
let unit = 0;
|
|
3288
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
3289
|
+
value /= 1024;
|
|
3290
|
+
unit++;
|
|
3291
|
+
}
|
|
3292
|
+
return `${value.toFixed(1)} ${units[unit]}`;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/**
|
|
3296
|
+
* `val publish` - a built project in, a live site out.
|
|
3297
|
+
*
|
|
3298
|
+
* A thin shell around `runPublish`: everything it decides is decided there,
|
|
3299
|
+
* and everything colourful happens here. A CI job reads the exit code, a
|
|
3300
|
+
* person reads the lines.
|
|
3301
|
+
*/
|
|
3302
|
+
async function publish(options) {
|
|
3303
|
+
const result = await runPublish({
|
|
3304
|
+
...(options.root ? {
|
|
3305
|
+
root: options.root
|
|
3306
|
+
} : {}),
|
|
3307
|
+
...(options.artifacts ? {
|
|
3308
|
+
artifacts: options.artifacts
|
|
3309
|
+
} : {}),
|
|
3310
|
+
...(options.commit ? {
|
|
3311
|
+
commit: options.commit
|
|
3312
|
+
} : {}),
|
|
3313
|
+
...(options.branch ? {
|
|
3314
|
+
branch: options.branch
|
|
3315
|
+
} : {}),
|
|
3316
|
+
...(options.layerRev ? {
|
|
3317
|
+
layerRev: options.layerRev
|
|
3318
|
+
} : {}),
|
|
3319
|
+
...(options.buildHash ? {
|
|
3320
|
+
buildHash: options.buildHash
|
|
3321
|
+
} : {}),
|
|
3322
|
+
...(options.linksOwnCss === undefined ? {} : {
|
|
3323
|
+
linksOwnCss: options.linksOwnCss
|
|
3324
|
+
}),
|
|
3325
|
+
...(options.dryRun ? {
|
|
3326
|
+
dryRun: options.dryRun
|
|
3327
|
+
} : {}),
|
|
3328
|
+
log: line => console.log(pc.dim(line))
|
|
3329
|
+
});
|
|
3330
|
+
switch (result.status) {
|
|
3331
|
+
case "live":
|
|
3332
|
+
case "verified":
|
|
3333
|
+
{
|
|
3334
|
+
const uploaded = result.uploaded === 0 ? "nothing new to upload" : `${result.uploaded} uploaded, ${formatBytes(result.uploadedBytes)}`;
|
|
3335
|
+
console.log(pc.green(result.status === "live" ? "✅ Published" : "✅ Verified (not published: --dry-run)") + pc.dim(` — ${result.artifacts} artifact${result.artifacts === 1 ? "" : "s"}, ${uploaded}`));
|
|
3336
|
+
if (result.status === "live" && result.url) {
|
|
3337
|
+
console.log(pc.cyan(result.url));
|
|
3338
|
+
}
|
|
3339
|
+
if (result.previewUrl) {
|
|
3340
|
+
console.log(pc.dim("Canary: ") + pc.cyan(result.previewUrl));
|
|
3341
|
+
}
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
case "failed":
|
|
3345
|
+
{
|
|
3346
|
+
error(result.message);
|
|
3347
|
+
for (const problem of result.problems) {
|
|
3348
|
+
printProblem(problem);
|
|
3349
|
+
}
|
|
3350
|
+
if (result.previewUrl) {
|
|
3351
|
+
console.error(pc.dim("Canary: ") + pc.cyan(result.previewUrl));
|
|
3352
|
+
}
|
|
3353
|
+
if (result.publishId) {
|
|
3354
|
+
console.error(pc.dim(`Publish ${result.publishId}${result.state ? ` is "${result.state}"` : ""}.`));
|
|
3355
|
+
}
|
|
3356
|
+
process.exitCode = 1;
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
case "error":
|
|
3360
|
+
{
|
|
3361
|
+
error(result.message);
|
|
3362
|
+
process.exitCode = 1;
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
/**
|
|
3369
|
+
* A problem, as content wrote it.
|
|
3370
|
+
*
|
|
3371
|
+
* Verbatim, including the codes the build platform passed through: content
|
|
3372
|
+
* knows what went wrong with the canary and this does not, so rewording can
|
|
3373
|
+
* only lose the sentence that says what to change. The hint is printed for the
|
|
3374
|
+
* same reason - a gate that merely fails is useless.
|
|
3375
|
+
*/
|
|
3376
|
+
function printProblem(problem) {
|
|
3377
|
+
console.error(pc.red(problem.code ? ` ${problem.code}: ` : " ") + problem.message);
|
|
3378
|
+
const keys = problem.keys ?? [];
|
|
3379
|
+
if (keys.length > 0) {
|
|
3380
|
+
const shown = keys.slice(0, 10).join(", ");
|
|
3381
|
+
console.error(pc.dim(` ${shown}${keys.length > 10 ? `, and ${keys.length - 10} more` : ""}`));
|
|
3382
|
+
}
|
|
3383
|
+
if (problem.hint) {
|
|
3384
|
+
console.error(pc.dim(` ${problem.hint}`));
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
2013
3388
|
async function main() {
|
|
2014
3389
|
const {
|
|
2015
3390
|
input,
|
|
@@ -2027,6 +3402,7 @@ async function main() {
|
|
|
2027
3402
|
login
|
|
2028
3403
|
files
|
|
2029
3404
|
connect
|
|
3405
|
+
publish
|
|
2030
3406
|
versions
|
|
2031
3407
|
lsp
|
|
2032
3408
|
debug
|
|
@@ -2051,6 +3427,32 @@ async function main() {
|
|
|
2051
3427
|
Options:
|
|
2052
3428
|
--root [root], -r [root] Set project root directory (default process.cwd())
|
|
2053
3429
|
|
|
3430
|
+
Command: publish
|
|
3431
|
+
Description: publish this project's build through content.val.build.
|
|
3432
|
+
Declares the build's artifacts, uploads the ones content does not already hold,
|
|
3433
|
+
and has content verify it by building and rendering a canary before the site
|
|
3434
|
+
changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
|
|
3435
|
+
.val/pat.json (which needs the project, as "<org>/<project>", in val.config or
|
|
3436
|
+
VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
|
|
3437
|
+
processes, and it is kept in shell history and in every CI log.
|
|
3438
|
+
Options:
|
|
3439
|
+
--root [root], -r [root] Set project root directory (default process.cwd())
|
|
3440
|
+
--artifacts [dir] The built artifacts (default <root>/.val/publish). The path
|
|
3441
|
+
of each file under it is its artifact key: server, client,
|
|
3442
|
+
css, rsc, layer, or a path under chunk/server, chunk/client,
|
|
3443
|
+
chunk/rsc, asset, public
|
|
3444
|
+
--commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
|
|
3445
|
+
else GITHUB_SHA, else git HEAD)
|
|
3446
|
+
--branch [name] The branch content commits saves to (default VAL_GIT_BRANCH,
|
|
3447
|
+
else GITHUB_REF_NAME, else the current git branch)
|
|
3448
|
+
--layer-rev [rev] Name the dependency layer content already holds, when this
|
|
3449
|
+
build does not send one
|
|
3450
|
+
--build-hash [hash] Identify the build (default: a hash of its artifacts).
|
|
3451
|
+
Declaring the same one twice resumes that publish
|
|
3452
|
+
--links-own-css The app links its own CSS (--no-links-own-css for the
|
|
3453
|
+
opposite; omit it when the build did not say)
|
|
3454
|
+
--dry-run Verify, and stop before the site changes
|
|
3455
|
+
|
|
2054
3456
|
Command: list-unused-files
|
|
2055
3457
|
Description: EXPERIMENTAL.
|
|
2056
3458
|
List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
|
|
@@ -2114,6 +3516,18 @@ async function main() {
|
|
|
2114
3516
|
out: {
|
|
2115
3517
|
type: "string"
|
|
2116
3518
|
},
|
|
3519
|
+
artifacts: {
|
|
3520
|
+
type: "string"
|
|
3521
|
+
},
|
|
3522
|
+
layerRev: {
|
|
3523
|
+
type: "string"
|
|
3524
|
+
},
|
|
3525
|
+
buildHash: {
|
|
3526
|
+
type: "string"
|
|
3527
|
+
},
|
|
3528
|
+
linksOwnCss: {
|
|
3529
|
+
type: "boolean"
|
|
3530
|
+
},
|
|
2117
3531
|
commit: {
|
|
2118
3532
|
type: "string"
|
|
2119
3533
|
},
|
|
@@ -2177,6 +3591,17 @@ async function main() {
|
|
|
2177
3591
|
yes: flags.yes,
|
|
2178
3592
|
verbose: flags.verbose
|
|
2179
3593
|
});
|
|
3594
|
+
case "publish":
|
|
3595
|
+
return publish({
|
|
3596
|
+
root: flags.root,
|
|
3597
|
+
artifacts: flags.artifacts,
|
|
3598
|
+
commit: flags.commit,
|
|
3599
|
+
branch: flags.branch,
|
|
3600
|
+
layerRev: flags.layerRev,
|
|
3601
|
+
buildHash: flags.buildHash,
|
|
3602
|
+
linksOwnCss: flags.linksOwnCss,
|
|
3603
|
+
dryRun: flags.dryRun
|
|
3604
|
+
});
|
|
2180
3605
|
case "login":
|
|
2181
3606
|
return login({
|
|
2182
3607
|
root: flags.root
|