@valbuild/cli 0.137.0 → 0.138.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/CHANGELOG.md +53 -0
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +578 -44
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +578 -44
- package/cli/dist/valbuild-cli-cli.esm.js +578 -45
- package/package.json +29 -7
- package/src/ciReport.ts +39 -0
- package/src/cli.ts +41 -10
- package/src/publish/__fixtures__/buildPublishProbe.ts +238 -0
- package/src/publish/artifacts.ts +4 -4
- package/src/publish/buildArtifacts.ts +365 -0
- package/src/publish/buildPublish.test.ts +118 -0
- package/src/publish/client.ts +8 -0
- package/src/publish/fakeContent.ts +60 -5
- package/src/publish/reportCiRun.test.ts +110 -0
- package/src/publish/reportCiRun.ts +105 -0
- package/src/publish/runPublish.ts +105 -23
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { ciReportFromEnv, reportCiRun } from "./reportCiRun";
|
|
5
|
+
|
|
6
|
+
const actions = {
|
|
7
|
+
GITHUB_SHA: "abc123",
|
|
8
|
+
GITHUB_REF_NAME: "main",
|
|
9
|
+
GITHUB_SERVER_URL: "https://github.com",
|
|
10
|
+
GITHUB_REPOSITORY: "acme/site",
|
|
11
|
+
GITHUB_RUN_ID: "42",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("what a CI report says", () => {
|
|
15
|
+
test("takes the commit, the branch and the run from GitHub Actions", () => {
|
|
16
|
+
expect(ciReportFromEnv({ status: "failed" }, actions)).toEqual({
|
|
17
|
+
status: "ok",
|
|
18
|
+
report: {
|
|
19
|
+
status: "failed",
|
|
20
|
+
commit: "abc123",
|
|
21
|
+
branch: "main",
|
|
22
|
+
url: "https://github.com/acme/site/actions/runs/42",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a flag wins over the environment", () => {
|
|
28
|
+
const read = ciReportFromEnv(
|
|
29
|
+
{ status: "succeeded", commit: "def456", url: "https://ci.example/1" },
|
|
30
|
+
actions,
|
|
31
|
+
);
|
|
32
|
+
expect(read).toMatchObject({
|
|
33
|
+
report: { commit: "def456", url: "https://ci.example/1" },
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a status that is neither, or no commit, is refused by name", () => {
|
|
38
|
+
expect(ciReportFromEnv({ status: "passed" }, actions)).toMatchObject({
|
|
39
|
+
status: "error",
|
|
40
|
+
message: expect.stringContaining('"passed"'),
|
|
41
|
+
});
|
|
42
|
+
expect(ciReportFromEnv({ status: "failed" }, {})).toMatchObject({
|
|
43
|
+
status: "error",
|
|
44
|
+
message: expect.stringContaining("GITHUB_SHA"),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("sending it", () => {
|
|
50
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "val-ci-report-"));
|
|
51
|
+
const report = {
|
|
52
|
+
status: "failed" as const,
|
|
53
|
+
commit: "abc123",
|
|
54
|
+
url: "https://github.com/acme/site/actions/runs/42",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
test("posts the report to content with the project token", async () => {
|
|
58
|
+
const sent: { url: string; auth: string | null; body: unknown }[] = [];
|
|
59
|
+
const fetchImpl: typeof fetch = async (input, init) => {
|
|
60
|
+
const headers = new Headers(init?.headers);
|
|
61
|
+
sent.push({
|
|
62
|
+
url: String(input),
|
|
63
|
+
auth: headers.get("Authorization"),
|
|
64
|
+
body: JSON.parse(String(init?.body)),
|
|
65
|
+
});
|
|
66
|
+
return new Response(JSON.stringify({ recorded: true }), {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const result = await reportCiRun({
|
|
72
|
+
root,
|
|
73
|
+
report,
|
|
74
|
+
env: {
|
|
75
|
+
VAL_PROJECT_TOKEN: "val_pt_secret",
|
|
76
|
+
VAL_CONTENT_URL: "https://content.test",
|
|
77
|
+
},
|
|
78
|
+
fetchImpl,
|
|
79
|
+
});
|
|
80
|
+
expect(result).toEqual({ status: "reported" });
|
|
81
|
+
expect(sent).toEqual([
|
|
82
|
+
{
|
|
83
|
+
url: "https://content.test/v1/ci-runs",
|
|
84
|
+
auth: "Bearer val_pt_secret",
|
|
85
|
+
body: report,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a refusal is said, not thrown", async () => {
|
|
91
|
+
const fetchImpl: typeof fetch = async () =>
|
|
92
|
+
new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
93
|
+
status: 401,
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
});
|
|
96
|
+
const result = await reportCiRun({
|
|
97
|
+
root,
|
|
98
|
+
report,
|
|
99
|
+
env: {
|
|
100
|
+
VAL_PROJECT_TOKEN: "val_pt_revoked",
|
|
101
|
+
VAL_CONTENT_URL: "https://content.test",
|
|
102
|
+
},
|
|
103
|
+
fetchImpl,
|
|
104
|
+
});
|
|
105
|
+
expect(result).toMatchObject({
|
|
106
|
+
status: "error",
|
|
107
|
+
message: expect.stringContaining("401"),
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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:
|
|
165
|
+
host: contentHost,
|
|
153
166
|
token: credential.credential.token,
|
|
154
167
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
155
168
|
});
|
|
156
169
|
|
|
157
|
-
|
|
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: {
|