@valbuild/cli 0.137.0 → 0.138.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.
@@ -0,0 +1,105 @@
1
+ import { type CiRunReportBody } from "@valbuild/shared/internal";
2
+ import { findAndEvalValConfigFile } from "../utils/evalValConfigFile";
3
+ import { getContentHost, postJson, ContentHostError } from "./contentHost";
4
+ import { resolvePublishCredential } from "./credentials";
5
+
6
+ /**
7
+ * `val ci-report` -- CI saying how its build of a commit went.
8
+ *
9
+ * The last step of a connected project's workflow, run whether the build
10
+ * passed or not. Content learns a build failed from this and nothing else,
11
+ * so it needs no GitHub permission to tell an editor "Published, not on the
12
+ * site yet: the build failed" (valbuild/home, docs/app-mode.md, "Failures":
13
+ * after the seal). The run's address goes with it, for "View run".
14
+ *
15
+ * Everything has a default from GitHub Actions' own environment -- the
16
+ * commit it checked out, the branch it ran for, the run's URL -- so the step
17
+ * is one line. Never fails the job over a report it could not send: the
18
+ * build's own result is what the job's status is, and a report is advice.
19
+ */
20
+ export type CiReport = {
21
+ status: "failed" | "succeeded";
22
+ commit: string;
23
+ branch?: string;
24
+ url?: string;
25
+ };
26
+
27
+ export function ciReportFromEnv(
28
+ options: {
29
+ status: string | undefined;
30
+ commit?: string;
31
+ branch?: string;
32
+ url?: string;
33
+ },
34
+ env: NodeJS.ProcessEnv = process.env,
35
+ ): { status: "ok"; report: CiReport } | { status: "error"; message: string } {
36
+ const status = options.status;
37
+ if (status !== "failed" && status !== "succeeded") {
38
+ return {
39
+ status: "error",
40
+ message: `--status is "failed" or "succeeded"${status === undefined ? "" : `, not "${status}"`}.`,
41
+ };
42
+ }
43
+ const commit = options.commit ?? env.GITHUB_SHA ?? env.VAL_GIT_COMMIT;
44
+ if (!commit) {
45
+ return {
46
+ status: "error",
47
+ message:
48
+ "No commit to report on: pass --commit, or run this in GitHub Actions (GITHUB_SHA).",
49
+ };
50
+ }
51
+ const branch = options.branch ?? env.GITHUB_REF_NAME ?? env.VAL_GIT_BRANCH;
52
+ const url =
53
+ options.url ??
54
+ (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
55
+ ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
56
+ : undefined);
57
+ return {
58
+ status: "ok",
59
+ report: {
60
+ status,
61
+ commit,
62
+ ...(branch ? { branch } : {}),
63
+ ...(url ? { url } : {}),
64
+ },
65
+ };
66
+ }
67
+
68
+ export async function reportCiRun(options: {
69
+ root: string;
70
+ report: CiReport;
71
+ env?: NodeJS.ProcessEnv;
72
+ fetchImpl?: typeof fetch;
73
+ }): Promise<{ status: "reported" } | { status: "error"; message: string }> {
74
+ const env = options.env ?? process.env;
75
+ const config = await findAndEvalValConfigFile(options.root).catch(() => null);
76
+ const credential = await resolvePublishCredential({
77
+ root: options.root,
78
+ project: config?.project ?? env.VAL_PROJECT ?? null,
79
+ env,
80
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
81
+ });
82
+ if (credential.status === "error") {
83
+ return { status: "error", message: credential.message };
84
+ }
85
+ const body: CiRunReportBody = options.report;
86
+ try {
87
+ await postJson({
88
+ url: `${getContentHost(env)}/v1/ci-runs`,
89
+ headers: { Authorization: `Bearer ${credential.credential.token}` },
90
+ body,
91
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
92
+ });
93
+ return { status: "reported" };
94
+ } catch (e) {
95
+ return {
96
+ status: "error",
97
+ message:
98
+ e instanceof ContentHostError
99
+ ? `Content did not take the report (${e.statusCode}): ${e.message}`
100
+ : e instanceof Error
101
+ ? e.message
102
+ : String(e),
103
+ };
104
+ }
105
+ }
@@ -1,13 +1,17 @@
1
1
  import { safeReadGit } from "@valbuild/server";
2
+ import fs from "fs";
2
3
  import path from "path";
3
4
  import { findAndEvalValConfigFile } from "../utils/evalValConfigFile";
4
5
  import {
5
6
  Artifact,
7
+ CollectedArtifacts,
8
+ DEFAULT_ARTIFACTS_DIR,
6
9
  buildHashOf,
7
10
  collectArtifacts,
8
11
  layerRevOf,
9
12
  resolveArtifactsDir,
10
13
  } from "./artifacts";
14
+ import { BuiltArtifacts, buildArtifacts } from "./buildArtifacts";
11
15
  import {
12
16
  PublishClient,
13
17
  UploadError,
@@ -31,7 +35,10 @@ import {
31
35
 
32
36
  export type PublishOptions = {
33
37
  root?: string;
34
- /** The directory whose layout is the artifact namespace. */
38
+ /**
39
+ * The directory whose layout is the artifact namespace: publish a build made
40
+ * elsewhere. Without it (and without a `.val/publish`), the checkout is built.
41
+ */
35
42
  artifacts?: string;
36
43
  commit?: string;
37
44
  branch?: string;
@@ -108,29 +115,33 @@ export async function runPublish(
108
115
  ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
109
116
  const root = options.root ? path.resolve(options.root) : process.cwd();
110
117
 
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) {
118
+ /*
119
+ * Upload-only when there is a build to upload: `--artifacts`, or the
120
+ * directory it defaults to. Otherwise this builds the checkout -- which is
121
+ * what a CI runner has, and `.val/` is never committed, so a fresh checkout
122
+ * always builds.
123
+ */
124
+ const prebuilt =
125
+ options.artifacts !== undefined ||
126
+ fs.existsSync(path.join(root, DEFAULT_ARTIFACTS_DIR));
127
+ if (!prebuilt && !fs.existsSync(path.join(root, "package.json"))) {
123
128
  return {
124
129
  status: "error",
125
- message: `There is nothing in ${dir.dir}. Build the project before publishing it.`,
130
+ message:
131
+ `Nothing to publish: ${root} has no package.json to build, and no --artifacts were given.\n\n` +
132
+ "Run this in the project, or point at a build made elsewhere:\n\n" +
133
+ " npx val publish --artifacts <directory>",
126
134
  };
127
135
  }
128
136
 
129
- const layer = await layerRevOf(collected.artifacts);
130
- if (layer.status === "error") {
131
- return { status: "error", message: layer.message };
137
+ let collected: CollectedArtifacts | null = null;
138
+ let layerRev: string | null = null;
139
+ if (prebuilt) {
140
+ const fromDir = await collectPrebuilt(root, options.artifacts, log);
141
+ if (fromDir.status === "error") return fromDir;
142
+ collected = fromDir.collected;
143
+ layerRev = fromDir.layerRev;
132
144
  }
133
- const layerRev = options.layerRev ?? layer.layerRev;
134
145
 
135
146
  const git = await resolveGit({ root, options, env });
136
147
  if (git.status === "error") {
@@ -138,9 +149,10 @@ export async function runPublish(
138
149
  }
139
150
 
140
151
  const config = await findAndEvalValConfigFile(root).catch(() => null);
152
+ const project = config?.project ?? env.VAL_PROJECT ?? null;
141
153
  const credential = await resolvePublishCredential({
142
154
  root,
143
- project: config?.project ?? env.VAL_PROJECT ?? null,
155
+ project,
144
156
  env,
145
157
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
146
158
  });
@@ -148,13 +160,45 @@ export async function runPublish(
148
160
  return { status: "error", message: credential.message };
149
161
  }
150
162
 
163
+ const contentHost = getContentHost(env);
151
164
  const client = createPublishClient({
152
- host: getContentHost(env),
165
+ host: contentHost,
153
166
  token: credential.credential.token,
154
167
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
155
168
  });
156
169
 
157
- const buildHash = options.buildHash ?? buildHashOf(collected.artifacts);
170
+ let built: Extract<BuiltArtifacts, { status: "built" }> | null = null;
171
+ try {
172
+ if (collected === null) {
173
+ log("Asking content what to build against");
174
+ const target = await client.buildTarget();
175
+ const build = await buildArtifacts({
176
+ root,
177
+ target,
178
+ project: project ?? "",
179
+ contentHost,
180
+ git: { commit: git.commit, branch: git.branch },
181
+ env,
182
+ log,
183
+ });
184
+ if (build.status === "error") {
185
+ return { status: "error", message: build.message };
186
+ }
187
+ built = build;
188
+ for (const warning of build.warnings) log(`warn: ${warning}`);
189
+ const fromBuild = await collectPrebuilt(root, build.dir, log);
190
+ if (fromBuild.status === "error") return fromBuild;
191
+ collected = fromBuild.collected;
192
+ // Named when content already holds it; otherwise it is in the directory.
193
+ layerRev =
194
+ build.layerRev !== undefined ? build.layerRev : fromBuild.layerRev;
195
+ }
196
+ } catch (err) {
197
+ return errorFrom(err);
198
+ }
199
+
200
+ const buildHash =
201
+ options.buildHash ?? built?.buildHash ?? buildHashOf(collected.artifacts);
158
202
  log(
159
203
  `${count(collected.artifacts.length, "artifact")}, ${formatBytes(collected.totalBytes)}` +
160
204
  `, at ${git.commit.slice(0, 7)} on ${git.branch}`,
@@ -171,8 +215,8 @@ export async function runPublish(
171
215
  buildHash,
172
216
  commit: git.commit,
173
217
  branch: git.branch,
174
- layerRev,
175
- linksOwnCss: options.linksOwnCss ?? null,
218
+ layerRev: options.layerRev ?? layerRev,
219
+ linksOwnCss: options.linksOwnCss ?? built?.linksOwnCss ?? null,
176
220
  artifacts: collected.artifacts.map(({ key, sha256, bytes }) => ({
177
221
  key,
178
222
  sha256,
@@ -183,7 +227,45 @@ export async function runPublish(
183
227
  });
184
228
  } catch (err) {
185
229
  return errorFrom(err);
230
+ } finally {
231
+ // Ours to remove: a build is written to a fresh temporary directory.
232
+ if (built !== null) {
233
+ fs.rmSync(built.dir, { recursive: true, force: true });
234
+ }
235
+ }
236
+ }
237
+
238
+ /** A build on disk: its artifacts, and the layer it sends or names. */
239
+ async function collectPrebuilt(
240
+ root: string,
241
+ artifacts: string | undefined,
242
+ log: (line: string) => void,
243
+ ): Promise<
244
+ | { status: "ok"; collected: CollectedArtifacts; layerRev: string | null }
245
+ | { status: "error"; message: string }
246
+ > {
247
+ const dir = resolveArtifactsDir({
248
+ root,
249
+ ...(artifacts ? { dir: artifacts } : {}),
250
+ });
251
+ if (dir.status === "error") {
252
+ return { status: "error", message: dir.message };
253
+ }
254
+ const collected = await collectArtifacts(dir.dir);
255
+ for (const skipped of collected.skipped) {
256
+ log(`Skipped ${skipped}`);
257
+ }
258
+ if (collected.artifacts.length === 0) {
259
+ return {
260
+ status: "error",
261
+ message: `There is nothing in ${dir.dir}. Build the project before publishing it.`,
262
+ };
263
+ }
264
+ const layer = await layerRevOf(collected.artifacts);
265
+ if (layer.status === "error") {
266
+ return { status: "error", message: layer.message };
186
267
  }
268
+ return { status: "ok", collected, layerRev: layer.layerRev };
187
269
  }
188
270
 
189
271
  async function publishDeclaredBuild(args: {