@valbuild/cli 0.133.0 → 0.134.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.133.0",
4
+ "version": "0.134.1",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -24,11 +24,13 @@
24
24
  "jszip": "^3.10.1",
25
25
  "meow": "^9.0.0",
26
26
  "picocolors": "^1.1.1",
27
+ "zod": "^4.4.3",
28
+ "zod-validation-error": "^5.0.0",
27
29
  "@valbuild/eslint-plugin": "0.125.0",
28
- "@valbuild/language-server": "0.133.0",
29
- "@valbuild/server": "0.133.0",
30
- "@valbuild/shared": "0.133.0",
31
- "@valbuild/core": "0.130.0"
30
+ "@valbuild/core": "0.134.0",
31
+ "@valbuild/language-server": "0.134.1",
32
+ "@valbuild/server": "0.134.1",
33
+ "@valbuild/shared": "0.134.1"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "prettier": "*",
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { login } from "./login";
9
9
  import { lsp } from "./lsp";
10
10
  import { debug } from "./debug";
11
11
  import { deleteUnappliablePatches } from "./deleteUnappliablePatches";
12
+ import { publish } from "./publish";
12
13
 
13
14
  async function main(): Promise<void> {
14
15
  const { input, flags, showHelp } = meow(
@@ -24,6 +25,7 @@ async function main(): Promise<void> {
24
25
  login
25
26
  files
26
27
  connect
28
+ publish
27
29
  versions
28
30
  lsp
29
31
  debug
@@ -48,6 +50,32 @@ async function main(): Promise<void> {
48
50
  Options:
49
51
  --root [root], -r [root] Set project root directory (default process.cwd())
50
52
 
53
+ Command: publish
54
+ Description: publish this project's build through content.val.build.
55
+ Declares the build's artifacts, uploads the ones content does not already hold,
56
+ and has content verify it by building and rendering a canary before the site
57
+ changes. Authenticates with VAL_PROJECT_TOKEN, or with the "val login" token in
58
+ .val/pat.json (which needs the project, as "<org>/<project>", in val.config or
59
+ VAL_PROJECT). Never as a flag: an argument is visible to anyone who can list
60
+ processes, and it is kept in shell history and in every CI log.
61
+ Options:
62
+ --root [root], -r [root] Set project root directory (default process.cwd())
63
+ --artifacts [dir] The built artifacts (default <root>/.val/publish). The path
64
+ of each file under it is its artifact key: server, client,
65
+ css, rsc, layer, or a path under chunk/server, chunk/client,
66
+ chunk/rsc, asset, public
67
+ --commit [sha] The commit this build is of (default VAL_GIT_COMMIT,
68
+ else GITHUB_SHA, else git HEAD)
69
+ --branch [name] The branch content commits saves to (default VAL_GIT_BRANCH,
70
+ else GITHUB_REF_NAME, else the current git branch)
71
+ --layer-rev [rev] Name the dependency layer content already holds, when this
72
+ build does not send one
73
+ --build-hash [hash] Identify the build (default: a hash of its artifacts).
74
+ Declaring the same one twice resumes that publish
75
+ --links-own-css The app links its own CSS (--no-links-own-css for the
76
+ opposite; omit it when the build did not say)
77
+ --dry-run Verify, and stop before the site changes
78
+
51
79
  Command: list-unused-files
52
80
  Description: EXPERIMENTAL.
53
81
  List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
@@ -112,6 +140,18 @@ async function main(): Promise<void> {
112
140
  out: {
113
141
  type: "string",
114
142
  },
143
+ artifacts: {
144
+ type: "string",
145
+ },
146
+ layerRev: {
147
+ type: "string",
148
+ },
149
+ buildHash: {
150
+ type: "string",
151
+ },
152
+ linksOwnCss: {
153
+ type: "boolean",
154
+ },
115
155
  commit: {
116
156
  type: "string",
117
157
  },
@@ -181,6 +221,17 @@ async function main(): Promise<void> {
181
221
  yes: flags.yes,
182
222
  verbose: flags.verbose,
183
223
  });
224
+ case "publish":
225
+ return publish({
226
+ root: flags.root,
227
+ artifacts: flags.artifacts,
228
+ commit: flags.commit,
229
+ branch: flags.branch,
230
+ layerRev: flags.layerRev,
231
+ buildHash: flags.buildHash,
232
+ linksOwnCss: flags.linksOwnCss,
233
+ dryRun: flags.dryRun,
234
+ });
184
235
  case "login":
185
236
  return login({
186
237
  root: flags.root,
@@ -0,0 +1,194 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import zlib from "zlib";
5
+ import { DeclaredArtifact } from "./protocol";
6
+
7
+ /**
8
+ * Where an artifact's bytes are, and what it is called on the wire.
9
+ *
10
+ * The key namespace is flat and is content's: `server`, `client`, `css`,
11
+ * `rsc`, `layer`, and paths under `chunk/server/`, `chunk/client/`,
12
+ * `chunk/rsc/`, `asset/` and `public/`. Content validates it and answers with
13
+ * every problem at once, so this does not re-implement the rules - a second,
14
+ * slightly different copy of them here is how a CLI comes to refuse a publish
15
+ * the service would have taken.
16
+ */
17
+ export type Artifact = DeclaredArtifact & { file: string };
18
+
19
+ export type CollectedArtifacts = {
20
+ artifacts: Artifact[];
21
+ totalBytes: number;
22
+ skipped: string[];
23
+ };
24
+
25
+ /**
26
+ * The artifacts to publish, read from a directory laid out by key.
27
+ *
28
+ * The path under the directory IS the artifact key: a file at `public/app.css`
29
+ * is the artifact `public/app.css`, and `server` is the server bundle. Nothing
30
+ * is renamed on the way - an asset is addressed by the path the built code
31
+ * imports it at, so a normalised name produces a bundle whose imports resolve
32
+ * to nothing, at runtime, in the isolate.
33
+ *
34
+ * **This is the seam with the build.** `val publish` uploads artifacts; it does
35
+ * not produce them. Whatever builds the project - today the platform's own
36
+ * builder, which is where the wire, rolldown and dependency-layer steps live -
37
+ * writes this directory, and this reads it.
38
+ */
39
+ export async function collectArtifacts(
40
+ dir: string,
41
+ ): Promise<CollectedArtifacts> {
42
+ const artifacts: Artifact[] = [];
43
+ const skipped: string[] = [];
44
+ let totalBytes = 0;
45
+
46
+ const walk = async (current: string): Promise<void> => {
47
+ const entries = await fs.promises.readdir(current, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ const absolute = path.join(current, entry.name);
50
+ const key = path.relative(dir, absolute).split(path.sep).join("/");
51
+ if (entry.isDirectory()) {
52
+ await walk(absolute);
53
+ continue;
54
+ }
55
+ if (entry.isSymbolicLink()) {
56
+ // A symlinked file is published as its bytes; a symlinked directory is
57
+ // not walked, because one usually comes with a cycle, and a publisher
58
+ // that hangs is worse than one that says what it left out.
59
+ const stat = await fs.promises.stat(absolute).catch(() => null);
60
+ if (stat === null) {
61
+ skipped.push(`${key} (broken symlink)`);
62
+ continue;
63
+ }
64
+ if (!stat.isFile()) {
65
+ skipped.push(
66
+ `${key} (symlink to a ${stat.isDirectory() ? "directory" : "special file"})`,
67
+ );
68
+ continue;
69
+ }
70
+ } else if (!entry.isFile()) {
71
+ skipped.push(`${key} (not a regular file)`);
72
+ continue;
73
+ }
74
+ const { sha256, bytes } = await hashFile(absolute);
75
+ artifacts.push({ key, sha256, bytes, file: absolute });
76
+ totalBytes += bytes;
77
+ }
78
+ };
79
+
80
+ await walk(dir);
81
+ // Sorted so two publishes of one build declare the same list in the same
82
+ // order, which is what makes the build hash below reproducible.
83
+ artifacts.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
84
+ return { artifacts, totalBytes, skipped };
85
+ }
86
+
87
+ async function hashFile(
88
+ absolute: string,
89
+ ): Promise<{ sha256: string; bytes: number }> {
90
+ const hash = crypto.createHash("sha256");
91
+ let bytes = 0;
92
+ // Streamed: an artifact may be 128 MB, and reading one into a Buffer to hash
93
+ // it is a needless way to run a CI runner out of memory.
94
+ const stream = fs.createReadStream(absolute);
95
+ await new Promise<void>((resolve, reject) => {
96
+ stream.on("data", (chunk) => {
97
+ hash.update(chunk);
98
+ bytes += chunk.length;
99
+ });
100
+ stream.on("end", resolve);
101
+ stream.on("error", reject);
102
+ });
103
+ return { sha256: hash.digest("hex"), bytes };
104
+ }
105
+
106
+ /**
107
+ * The build's own hash, when the build did not say.
108
+ *
109
+ * Content uses it for idempotency - declaring the same `buildHash` twice
110
+ * returns the same publish, which is what makes a retried CI job resume rather
111
+ * than start again - so it has to be a function of the build and nothing else.
112
+ * Every artifact's key and hash, in order: two runs of one build agree, and
113
+ * any change to any artifact is a different publish.
114
+ */
115
+ export function buildHashOf(artifacts: Artifact[]): string {
116
+ const hash = crypto.createHash("sha256");
117
+ for (const artifact of artifacts) {
118
+ hash.update(`${artifact.key} ${artifact.sha256}\n`);
119
+ }
120
+ return hash.digest("hex");
121
+ }
122
+
123
+ /**
124
+ * Which dependency layer this build was built against.
125
+ *
126
+ * A layer is sent or named, never neither: the loader reads the head's layer
127
+ * by rev, and a publish that declares no layer at all loses every dependency
128
+ * on the next render. When the layer is being uploaded, its rev is inside it -
129
+ * it is gzipped JSON with a `rev` - so there is nothing to ask the caller for.
130
+ * When it is not, the build knows which stored layer it used, and says so with
131
+ * `--layer-rev`.
132
+ */
133
+ export async function layerRevOf(
134
+ artifacts: Artifact[],
135
+ ): Promise<
136
+ | { status: "ok"; layerRev: string | null }
137
+ | { status: "error"; message: string }
138
+ > {
139
+ const layer = artifacts.find((artifact) => artifact.key === "layer");
140
+ if (!layer) {
141
+ return { status: "ok", layerRev: null };
142
+ }
143
+ const bytes = await fs.promises.readFile(layer.file);
144
+ let parsed: unknown;
145
+ try {
146
+ parsed = JSON.parse(zlib.gunzipSync(bytes).toString("utf-8"));
147
+ } catch (err) {
148
+ return {
149
+ status: "error",
150
+ message:
151
+ `The layer artifact is not gzipped JSON: ${
152
+ err instanceof Error ? err.message : String(err)
153
+ }\n\n` +
154
+ "A layer is `{ rev, worker, browser, ... }`, gzipped. Pass --layer-rev to\n" +
155
+ "name a layer content already holds instead of sending one.",
156
+ };
157
+ }
158
+ const rev =
159
+ typeof parsed === "object" && parsed !== null
160
+ ? Reflect.get(parsed, "rev")
161
+ : undefined;
162
+ if (typeof rev !== "string" || rev === "") {
163
+ return {
164
+ status: "error",
165
+ message:
166
+ "The layer artifact has no rev, and a layer that is sent has to say which\n" +
167
+ "it is: that is the name the loader stores and later reuses it by.",
168
+ };
169
+ }
170
+ return { status: "ok", layerRev: rev };
171
+ }
172
+
173
+ /** Where the artifacts are, if the caller did not say. */
174
+ export const DEFAULT_ARTIFACTS_DIR = ".val/publish";
175
+
176
+ export function resolveArtifactsDir(options: {
177
+ root: string;
178
+ dir?: string;
179
+ }): { status: "ok"; dir: string } | { status: "error"; message: string } {
180
+ const named = options.dir ?? DEFAULT_ARTIFACTS_DIR;
181
+ const dir = path.resolve(options.root, named);
182
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
183
+ return {
184
+ status: "error",
185
+ message:
186
+ `No artifacts to publish: ${dir} is not a directory.\n\n` +
187
+ "Build the project first, then point at what it produced:\n\n" +
188
+ " npx val publish --artifacts <directory>\n\n" +
189
+ "The path of each file under it is its artifact key - `server`, `client`,\n" +
190
+ "`public/...`, `chunk/client/...`, `layer`.",
191
+ };
192
+ }
193
+ return { status: "ok", dir };
194
+ }
@@ -0,0 +1,177 @@
1
+ import fs from "fs";
2
+ import { getJson, postJson } from "./contentHost";
3
+ import {
4
+ ArtifactsResponse,
5
+ DeclareBody,
6
+ DeclareResponse,
7
+ PromoteResponse,
8
+ StatusResponse,
9
+ UploadSlot,
10
+ VerifyResponse,
11
+ parseArtifacts,
12
+ parseDeclare,
13
+ parsePromote,
14
+ parseStatus,
15
+ parseVerify,
16
+ } from "./protocol";
17
+
18
+ export type PublishClient = {
19
+ declare(body: DeclareBody): Promise<DeclareResponse>;
20
+ confirmArtifacts(publishId: string): Promise<ArtifactsResponse>;
21
+ verify(publishId: string): Promise<VerifyResponse>;
22
+ promote(publishId: string): Promise<PromoteResponse>;
23
+ status(publishId: string): Promise<StatusResponse>;
24
+ upload(slot: UploadSlot, file: string): Promise<void>;
25
+ };
26
+
27
+ export function createPublishClient(options: {
28
+ host: string;
29
+ token: string;
30
+ fetchImpl?: typeof fetch;
31
+ }): PublishClient {
32
+ const { host, token } = options;
33
+ const fetchImpl = options.fetchImpl ?? fetch;
34
+ // Every call, and nothing else: not a cookie, not the project's api key, not
35
+ // a personal access token.
36
+ const headers = { Authorization: `Bearer ${token}` };
37
+ const publishUrl = (publishId: string, step?: string) =>
38
+ `${host}/v1/publish/${encodeURIComponent(publishId)}${step ? `/${step}` : ""}`;
39
+
40
+ return {
41
+ declare: async (body) =>
42
+ parseDeclare(
43
+ await postJson({ url: `${host}/v1/publish`, headers, body, fetchImpl }),
44
+ ),
45
+ confirmArtifacts: async (publishId) =>
46
+ parseArtifacts(
47
+ await postJson({
48
+ url: publishUrl(publishId, "artifacts"),
49
+ headers,
50
+ fetchImpl,
51
+ }),
52
+ ),
53
+ verify: async (publishId) =>
54
+ parseVerify(
55
+ await postJson({
56
+ url: publishUrl(publishId, "verify"),
57
+ headers,
58
+ fetchImpl,
59
+ }),
60
+ ),
61
+ promote: async (publishId) =>
62
+ parsePromote(
63
+ await postJson({
64
+ url: publishUrl(publishId, "promote"),
65
+ headers,
66
+ fetchImpl,
67
+ }),
68
+ ),
69
+ status: async (publishId) =>
70
+ parseStatus(
71
+ await getJson({ url: publishUrl(publishId), headers, fetchImpl }),
72
+ ),
73
+ upload: (slot, file) => putArtifact(slot, file, fetchImpl),
74
+ };
75
+ }
76
+
77
+ /** Object storage refused the bytes, or could not be reached. */
78
+ export class UploadError extends Error {
79
+ readonly statusCode: number;
80
+ readonly key: string;
81
+ constructor(statusCode: number, key: string, message: string) {
82
+ super(message);
83
+ this.name = "UploadError";
84
+ this.statusCode = statusCode;
85
+ this.key = key;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * A slot's permission to write has run out.
91
+ *
92
+ * Not a failed publish: declaring again mints fresh slots and the publish
93
+ * carries on from where it was, which is why this is its own kind of failure
94
+ * rather than an error message.
95
+ */
96
+ export function isExpiredSlot(err: UploadError): boolean {
97
+ return err.statusCode === 403 || err.statusCode === 401;
98
+ }
99
+
100
+ async function putArtifact(
101
+ slot: UploadSlot,
102
+ file: string,
103
+ fetchImpl: typeof fetch,
104
+ ): Promise<void> {
105
+ /*
106
+ * Read, rather than streamed.
107
+ *
108
+ * `ContentLength` is signed into the slot's URL, so the store rejects a body
109
+ * of a different size - and a streamed body goes out chunked, which is a
110
+ * different size as far as the signature is concerned. An artifact is capped
111
+ * at 128 MB by the API, which is the bound this trades against.
112
+ */
113
+ const body = await fs.promises.readFile(file);
114
+ let res: Response;
115
+ try {
116
+ res = await fetchImpl(slot.url, {
117
+ method: slot.method,
118
+ headers: slot.headers,
119
+ body,
120
+ });
121
+ } catch (err) {
122
+ throw new UploadError(
123
+ 0,
124
+ slot.key,
125
+ `${slot.key}: ${err instanceof Error ? err.message : String(err)}`,
126
+ );
127
+ }
128
+ if (!res.ok) {
129
+ // The store answers XML, and a whole error document in a CI log buries the
130
+ // line that matters. The status is the actionable half.
131
+ throw new UploadError(
132
+ res.status,
133
+ slot.key,
134
+ `${slot.key}: object storage answered ${res.status} ${res.statusText}`,
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Run `worker` over `items`, `limit` at a time.
141
+ *
142
+ * A project can have hundreds of small artifacts, so one at a time is minutes
143
+ * of latency and all at once is hundreds of open sockets on a CI runner. The
144
+ * first failure stops the pool: there is no point uploading the rest of a
145
+ * publish that is not going to be promoted.
146
+ */
147
+ export async function pool<T>(
148
+ items: T[],
149
+ limit: number,
150
+ worker: (item: T) => Promise<void>,
151
+ ): Promise<void> {
152
+ let next = 0;
153
+ let failure: unknown = undefined;
154
+ const run = async (): Promise<void> => {
155
+ for (;;) {
156
+ if (failure !== undefined) {
157
+ return;
158
+ }
159
+ const index = next++;
160
+ if (index >= items.length) {
161
+ return;
162
+ }
163
+ try {
164
+ await worker(items[index]);
165
+ } catch (err) {
166
+ failure = err;
167
+ return;
168
+ }
169
+ }
170
+ };
171
+ await Promise.all(
172
+ Array.from({ length: Math.min(limit, items.length) }, () => run()),
173
+ );
174
+ if (failure !== undefined) {
175
+ throw failure;
176
+ }
177
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The publish API's types, COPIED from the service that serves them.
3
+ *
4
+ * Source: `content/src/handlers/Api.ts` in valbuild/home, branch
5
+ * `claude/new-project-studio-saves-0g9ksb`, commit `ecf3b8d`. The routes below
6
+ * are that file's `/publish*` entries and the two types they use, verbatim,
7
+ * comments included, so that the two can be diffed by eye.
8
+ *
9
+ * **Copied rather than imported, because it cannot be imported.** That file
10
+ * lives in a private repository which publishes nothing to npm, and its own
11
+ * header says this is how it is kept in step: "We have also used it (by just
12
+ * copying it in and setting the types there) in the @valbuild/server package to
13
+ * check that we are more or less in sync."
14
+ *
15
+ * **When home's `Api.ts` changes, change this with it.** Everything in
16
+ * `protocol.ts` is checked against these types, so a copy brought up to date
17
+ * fails to compile wherever this CLI has not caught up. That is the whole
18
+ * value: without it a wire change is a runtime 500 in somebody's CI, which is
19
+ * how `home` and `@valbuild/server` have already diverged three times - see
20
+ * `homeWireContract.test.ts` in `@valbuild/server` for the last one, and
21
+ * `publishWireContract.test.ts` here for the fixtures that go with these types.
22
+ *
23
+ * A copy is not a guarantee, only a tripwire: nothing checks it against the
24
+ * service, and the parsers in `protocol.ts` are what actually holds at runtime.
25
+ */
26
+
27
+ export type ContentPublishApi = {
28
+ /**
29
+ * Publishing a build, as a resource with a lifecycle.
30
+ *
31
+ * ```
32
+ * POST /publish declare -> an upload slot per MISSING artifact
33
+ * PUT <presigned url> upload -> each artifact, straight to storage
34
+ * POST /publish/{id}/artifacts confirm -> the uploads are checked
35
+ * POST /publish/{id}/verify render -> a canary build, server side
36
+ * POST /publish/{id}/promote go live -> the pointer moves
37
+ * GET /publish/{id} status
38
+ * ```
39
+ *
40
+ * Four steps rather than one `POST /publish`, because they fail differently
41
+ * and one call cannot say "the bytes are fine but it did not render" -- which
42
+ * is the sentence a publisher most needs. `promote` is separate from `verify`
43
+ * so that a dry run is the absence of a call rather than a flag.
44
+ *
45
+ * Not keyed by a project, like `/publish-target` above and for the same
46
+ * reason: a project token names one. That is what lets a generated repository
47
+ * hold one secret and no variables at all.
48
+ *
49
+ * ## What is deliberately not here
50
+ *
51
+ * No loader URL, no `vendorRev`, no `x-platform-project`. This service holds
52
+ * the operator relationship with the build platform and calls it; a publisher
53
+ * talks to this API and nothing else. The one exception is the presigned
54
+ * upload URL, which points at object storage -- bytes do not travel through
55
+ * here.
56
+ *
57
+ * It is also what makes this publishable by a project token at all. The
58
+ * platform's own verify step publishes a canary to a throwaway project, and a
59
+ * throwaway has no secrets, so the loader has nothing to check a caller
60
+ * against and refuses with a 503 naming a project nobody has heard of. Behind
61
+ * this API the caller is never involved in that exchange.
62
+ */
63
+ "/publish": {
64
+ POST: {
65
+ body: {
66
+ /** The build's own hash. Repeating it returns the same publish. */
67
+ buildHash: string;
68
+ /** Null only for a seed publish. See `publishPlan.ts`. */
69
+ commit: string | null;
70
+ /** The branch content saves commit to. Required when `commit` is set. */
71
+ branch: string | null;
72
+ /** Which dependency layer this was built against, sent or not. */
73
+ layerRev: string | null;
74
+ /**
75
+ * Whether the app links its own CSS.
76
+ *
77
+ * Build metadata the loader needs and no artifact carries, so it has to
78
+ * be declared. Null is a third answer -- "this build did not say" --
79
+ * and is not false.
80
+ */
81
+ linksOwnCss: boolean | null;
82
+ artifacts: {
83
+ /** See `publishPlan.ts` for the namespace. */
84
+ key: string;
85
+ sha256: string;
86
+ bytes: number;
87
+ }[];
88
+ };
89
+ res: {
90
+ publishId: string;
91
+ state: PublishState;
92
+ project: {
93
+ publicProjectId: string;
94
+ /** Null when the project has no site yet -- reported, not refused. */
95
+ siteUrl: string | null;
96
+ };
97
+ /**
98
+ * One per artifact this project does not already hold, and nothing else.
99
+ *
100
+ * So this doubles as the answer to "what is missing": there is no
101
+ * separate field to keep in step with it. Slots expire; a presigned PUT
102
+ * that answers 403 means declare again, not that the publish failed.
103
+ */
104
+ uploads: {
105
+ key: string;
106
+ url: string;
107
+ method: "PUT";
108
+ headers: Record<string, string>;
109
+ /** ISO 8601. */
110
+ expiresAt: string;
111
+ }[];
112
+ /** Keys already held, so a caller can see what it did not have to send. */
113
+ have: string[];
114
+ };
115
+ };
116
+ };
117
+ "/publish/:publishId": {
118
+ GET: {
119
+ res: {
120
+ publishId: string;
121
+ state: PublishState;
122
+ buildHash: string;
123
+ /** Artifact keys still not uploaded. */
124
+ missing: string[];
125
+ problems: PublishProblem[];
126
+ };
127
+ };
128
+ };
129
+ /** Everything asked for has been uploaded. The uploads are checked here. */
130
+ "/publish/:publishId/artifacts": {
131
+ POST: {
132
+ res: {
133
+ state: PublishState;
134
+ problems: PublishProblem[];
135
+ };
136
+ };
137
+ };
138
+ /** A canary build and render, on the build platform, with our credential. */
139
+ "/publish/:publishId/verify": {
140
+ POST: {
141
+ res: {
142
+ state: PublishState;
143
+ ok: boolean;
144
+ /** Where the canary can be looked at, when it rendered. */
145
+ previewUrl: string | null;
146
+ problems: PublishProblem[];
147
+ };
148
+ };
149
+ };
150
+ /**
151
+ * Move the project's pointer to this build.
152
+ *
153
+ * Refused when the commit is no longer the branch head -- which this service
154
+ * is the authority on (see `getGitHead`), so the rule is enforced where the
155
+ * data already is rather than a round trip away.
156
+ */
157
+ "/publish/:publishId/promote": {
158
+ POST: {
159
+ res: {
160
+ state: PublishState;
161
+ url: string | null;
162
+ commit: string | null;
163
+ };
164
+ };
165
+ };
166
+ /** Exchange a personal access token for a short-lived publish token. */
167
+ "/publish-token": {
168
+ POST: {
169
+ res: {
170
+ /** Shown once, here. Nothing can produce it again. */
171
+ token: string;
172
+ /** ISO 8601, because a `Date` does not survive JSON as one. */
173
+ expiresAt: string | null;
174
+ publicProjectId: string;
175
+ productionUrl: string | null;
176
+ };
177
+ };
178
+ };
179
+ };
180
+
181
+ /**
182
+ * Where a publish is. Mirrors `PublishState` in `utils/publishPlan.ts`, which
183
+ * owns the transition table; this is the wire spelling of it.
184
+ */
185
+ export type PublishState =
186
+ | "awaiting-artifacts"
187
+ | "ready"
188
+ | "verified"
189
+ | "live"
190
+ | "failed"
191
+ | "expired";
192
+
193
+ /**
194
+ * Why a publish is not going anywhere.
195
+ *
196
+ * `code` is the stable half -- a pipeline gates on it -- and carries both this
197
+ * API's own codes (`ARTIFACT_MISMATCH`, `POINTER_STALE`, the declaration codes
198
+ * in `publishPlan.ts`) and the build platform's `PLATFORM*` codes passed
199
+ * through from a verify, because rewording those would lose the only sentence
200
+ * that says what to change.
201
+ *
202
+ * `hint` is not decoration. A gate that merely fails is useless in somebody
203
+ * else's pipeline: they get a red build and no idea why.
204
+ */
205
+ export type PublishProblem = {
206
+ code: string;
207
+ message: string;
208
+ hint?: string;
209
+ keys?: string[];
210
+ };