@decohere/ci-tools 0.0.1-dev.126 → 0.0.1-dev.14
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 +3 -18
- package/src/audit/enforce.mjs +5 -7
- package/src/audit/summarize.mjs +32 -10
- package/src/audit/upsert-issue.mjs +74 -25
- package/src/bench/bench.mjs +368 -134
- package/src/bench/trace.mjs +14 -18
- package/src/coverage/enforce-thresholds.mjs +144 -0
- package/src/pages/artifact.mjs +183 -38
- package/src/pages/clear-bench-history.mjs +27 -0
- package/src/pages/keep-prior-bench-badge.mjs +38 -0
- package/src/pages/restore-bench-history.mjs +28 -15
- package/src/release/npm-links.mjs +13 -6
- package/src/release/sync-dependencies.mjs +2 -1
- package/tests/lib/lcov.test.mjs +4 -4
- package/LICENSE +0 -15
- package/src/audit/format.mjs +0 -133
- package/src/bench/file-regressions.mjs +0 -76
- package/src/bench/history.mjs +0 -248
- package/src/bench/nightly.mjs +0 -118
- package/src/bench/regression-issue.mjs +0 -44
- package/src/github/credential-nudge.mjs +0 -226
- package/src/github/npm-bootstrap.mjs +0 -179
- package/src/github/oidc-bootstrap-nudge.mjs +0 -214
- package/src/github/publish-verify.mjs +0 -206
- package/src/github/smoke-create-launcher.mjs +0 -70
- package/src/lib/github-issue.mjs +0 -75
- package/src/lib/workspace-packages.mjs +0 -30
- package/src/pages/bench-page.mjs +0 -323
- package/src/pages/restore-badges.mjs +0 -58
- package/tests/audit/format.test.mjs +0 -115
- package/tests/bench/history.test.mjs +0 -279
- package/tests/bench/regression-issue.test.mjs +0 -42
- package/tests/github/credential-nudge.test.mjs +0 -112
- package/tests/github/npm-bootstrap.test.mjs +0 -125
- package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
- package/tests/github/package-metadata.test.mjs +0 -69
- package/tests/github/publish-verify.test.mjs +0 -115
- package/tests/github/smoke-create-launcher.test.mjs +0 -71
- package/tests/lib/github-issue.test.mjs +0 -85
- package/tests/pages/bench-page.test.mjs +0 -123
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
appendRun,
|
|
4
|
-
assertValid,
|
|
5
|
-
compare,
|
|
6
|
-
contextId,
|
|
7
|
-
compareRounds,
|
|
8
|
-
latestRun,
|
|
9
|
-
pruneStale,
|
|
10
|
-
formatComparison,
|
|
11
|
-
fromVitest,
|
|
12
|
-
getContext,
|
|
13
|
-
normalize,
|
|
14
|
-
parseResults,
|
|
15
|
-
previousRun,
|
|
16
|
-
RESULT_PREFIX,
|
|
17
|
-
} from "../../src/bench/history.mjs";
|
|
18
|
-
|
|
19
|
-
const fakeOs = {
|
|
20
|
-
cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
|
|
21
|
-
platform: () => "linux",
|
|
22
|
-
arch: () => "x64",
|
|
23
|
-
totalmem: () => 16 * 1024 ** 3,
|
|
24
|
-
};
|
|
25
|
-
const ctx = getContext(
|
|
26
|
-
fakeOs,
|
|
27
|
-
{ BENCH_ENV: "gh-actions" },
|
|
28
|
-
"v24.1.0",
|
|
29
|
-
new Date("2026-10-20T03:17:00Z"),
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const result = (id, median, extra = {}) => ({
|
|
33
|
-
id,
|
|
34
|
-
label: id,
|
|
35
|
-
unit: "ms",
|
|
36
|
-
median,
|
|
37
|
-
p90: median * 1.2,
|
|
38
|
-
min: median * 0.9,
|
|
39
|
-
max: median * 1.3,
|
|
40
|
-
samples: 5,
|
|
41
|
-
...extra,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const vitestOutput = {
|
|
45
|
-
files: [
|
|
46
|
-
{
|
|
47
|
-
groups: [
|
|
48
|
-
{
|
|
49
|
-
benchmarks: [
|
|
50
|
-
{
|
|
51
|
-
name: "forge build es6 + types (18 files)",
|
|
52
|
-
mean: 70,
|
|
53
|
-
median: 64,
|
|
54
|
-
min: 60,
|
|
55
|
-
max: 80,
|
|
56
|
-
sampleCount: 5,
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "forge build es6 only (18 files)",
|
|
60
|
-
mean: 10,
|
|
61
|
-
min: 8,
|
|
62
|
-
max: 12,
|
|
63
|
-
sampleCount: 5,
|
|
64
|
-
},
|
|
65
|
-
],
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
},
|
|
69
|
-
],
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
describe("getContext / contextId", () => {
|
|
73
|
-
it("describes the machine and names the CI environment with the Node major", () => {
|
|
74
|
-
expect(ctx).toEqual({
|
|
75
|
-
benchEnv: "gh-actions-node24",
|
|
76
|
-
date: "2026-10-20T03:17:00.000Z",
|
|
77
|
-
os: { platform: "linux", arch: "x64" },
|
|
78
|
-
cpu: { model: "Test CPU", count: 2 },
|
|
79
|
-
memoryGb: 16,
|
|
80
|
-
node: "v24.1.0",
|
|
81
|
-
});
|
|
82
|
-
expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("gives the same id to the same machine regardless of date or env label", () => {
|
|
86
|
-
const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
|
|
87
|
-
expect(contextId(later)).toBe(contextId(ctx));
|
|
88
|
-
expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("parseResults", () => {
|
|
93
|
-
it("reads the last result line among other output", () => {
|
|
94
|
-
const results = [result("flux.load.cold", 250)];
|
|
95
|
-
const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
|
|
96
|
-
expect(parseResults(output, "flux-load")).toEqual(results);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("fails, naming the suite, when there is no result line", () => {
|
|
100
|
-
expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("fromVitest", () => {
|
|
105
|
-
it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
|
|
106
|
-
expect(fromVitest(vitestOutput)).toEqual([
|
|
107
|
-
{
|
|
108
|
-
id: "forge.build.local",
|
|
109
|
-
label: "forge build es6 + types",
|
|
110
|
-
unit: "ms",
|
|
111
|
-
median: 64,
|
|
112
|
-
min: 60,
|
|
113
|
-
max: 80,
|
|
114
|
-
samples: 5,
|
|
115
|
-
files: 18,
|
|
116
|
-
},
|
|
117
|
-
// No median from vitest: the mean stands in.
|
|
118
|
-
expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
|
|
119
|
-
]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
|
|
123
|
-
const output = {
|
|
124
|
-
files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
|
|
125
|
-
};
|
|
126
|
-
expect(() => fromVitest(output)).toThrow(/something new/);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe("assertValid", () => {
|
|
131
|
-
it("passes real results and rejects NaN or empty ones, naming them", () => {
|
|
132
|
-
expect(() => assertValid([result("a", 1)])).not.toThrow();
|
|
133
|
-
expect(() =>
|
|
134
|
-
assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
|
|
135
|
-
).toThrow(/b, c/);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe("normalize", () => {
|
|
140
|
-
it("keeps a version 2 history as it is", () => {
|
|
141
|
-
const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
|
|
142
|
-
expect(normalize(history)).toBe(history);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("converts version 1 forge runs so their trend carries on", () => {
|
|
146
|
-
const v1 = {
|
|
147
|
-
runs: {
|
|
148
|
-
abc: {
|
|
149
|
-
context: ctx,
|
|
150
|
-
runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
|
|
151
|
-
},
|
|
152
|
-
empty: { context: ctx, runs: [] },
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
const history = normalize(v1);
|
|
156
|
-
expect(Object.keys(history.contexts)).toEqual(["abc"]);
|
|
157
|
-
expect(history.contexts.abc.runs[0]).toMatchObject({
|
|
158
|
-
date: "2026-08-12T14:18:10Z",
|
|
159
|
-
sha: null,
|
|
160
|
-
results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("starts empty when there is no history", () => {
|
|
165
|
-
expect(normalize(null)).toEqual({ version: 2, contexts: {} });
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe("appendRun / previousRun", () => {
|
|
170
|
-
const run = (day) => ({
|
|
171
|
-
date: `2026-10-${day}T03:17:00Z`,
|
|
172
|
-
sha: `sha${day}`,
|
|
173
|
-
results: [result("a", Number(day))],
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
|
|
177
|
-
const empty = normalize(null);
|
|
178
|
-
const one = appendRun(empty, ctx, run("18"), 2);
|
|
179
|
-
const two = appendRun(one, ctx, run("19"), 2);
|
|
180
|
-
const three = appendRun(two, ctx, run("20"), 2);
|
|
181
|
-
|
|
182
|
-
expect(empty.contexts).toEqual({});
|
|
183
|
-
expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
|
|
184
|
-
expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
|
|
185
|
-
expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
|
|
186
|
-
expect(previousRun(three, "other-machine")).toBeUndefined();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe("compare / formatComparison", () => {
|
|
191
|
-
it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
|
|
192
|
-
const rows = compare([result("a", 120), result("new", 5)], {
|
|
193
|
-
results: [result("a", 100)],
|
|
194
|
-
});
|
|
195
|
-
expect(rows).toEqual([
|
|
196
|
-
{ id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
|
|
197
|
-
{ id: "new", current: 5 },
|
|
198
|
-
]);
|
|
199
|
-
|
|
200
|
-
const table = formatComparison(rows).split("\n");
|
|
201
|
-
expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
|
|
202
|
-
expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
|
|
203
|
-
expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("compares against nothing on a machine's first run", () => {
|
|
207
|
-
expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
describe("compareRounds", () => {
|
|
212
|
-
const round = (...medians) => medians.map((median, index) => result(`b${index}`, median));
|
|
213
|
-
|
|
214
|
-
it("flags a benchmark slower on head than base in every round, with both rounds' numbers", () => {
|
|
215
|
-
const [regression] = compareRounds([round(130, 50)], [round(100, 50)], {
|
|
216
|
-
threshold: 0.2,
|
|
217
|
-
rounds: 1,
|
|
218
|
-
});
|
|
219
|
-
expect(regression).toMatchObject({
|
|
220
|
-
id: "b0",
|
|
221
|
-
base: 100,
|
|
222
|
-
head: 130,
|
|
223
|
-
rounds: [{ base: 100, head: 130 }],
|
|
224
|
-
});
|
|
225
|
-
expect(regression.percent).toBeCloseTo(30);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("needs every round slower: one slow round is as likely the runner as the code", () => {
|
|
229
|
-
expect(compareRounds([round(130), round(105)], [round(100), round(100)])).toEqual([]);
|
|
230
|
-
expect(compareRounds([round(130), round(126)], [round(100), round(100)])).toHaveLength(1);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("ignores slowdowns within the threshold", () => {
|
|
234
|
-
expect(compareRounds([round(115), round(118)], [round(100), round(100)])).toEqual([]);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("skips benchmarks only one side has, and compares nothing without enough rounds", () => {
|
|
238
|
-
const head = [
|
|
239
|
-
[result("new", 500), result("old", 100)],
|
|
240
|
-
[result("new", 500), result("old", 100)],
|
|
241
|
-
];
|
|
242
|
-
const base = [[result("old", 100)], [result("old", 100)]];
|
|
243
|
-
expect(compareRounds(head, base)).toEqual([]);
|
|
244
|
-
expect(compareRounds([round(200)], [round(100)])).toEqual([]);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe("pruneStale / latestRun", () => {
|
|
249
|
-
const at = (date, sha) => ({ date, sha, results: [] });
|
|
250
|
-
const history = {
|
|
251
|
-
version: 2,
|
|
252
|
-
contexts: {
|
|
253
|
-
old: { context: ctx, runs: [at("2026-08-12T03:17:00Z", "aaa")] },
|
|
254
|
-
recent: {
|
|
255
|
-
context: ctx,
|
|
256
|
-
runs: [at("2026-10-20T03:17:00Z", "ccc"), at("2026-10-19T03:17:00Z", "bbb")],
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
it("drops machine types with no run in the last 30 days", () => {
|
|
262
|
-
expect(
|
|
263
|
-
Object.keys(pruneStale(history, new Date("2026-10-21T00:00:00Z")).contexts),
|
|
264
|
-
).toEqual(["recent"]);
|
|
265
|
-
expect(
|
|
266
|
-
Object.keys(pruneStale(history, new Date("2026-09-01T00:00:00Z")).contexts),
|
|
267
|
-
).toEqual(["old", "recent"]);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("prunes when a run is appended", () => {
|
|
271
|
-
const next = appendRun(history, ctx, at("2026-10-21T03:17:00Z", "ddd"));
|
|
272
|
-
expect(Object.keys(next.contexts)).not.toContain("old");
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("finds the newest run on any machine: the commit the previous nightly measured", () => {
|
|
276
|
-
expect(latestRun(history)?.sha).toBe("ccc");
|
|
277
|
-
expect(latestRun({ version: 2, contexts: {} })).toBeUndefined();
|
|
278
|
-
});
|
|
279
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { issueBody, issueTitle } from "../../src/bench/regression-issue.mjs";
|
|
3
|
-
|
|
4
|
-
const regression = {
|
|
5
|
-
id: "flux.load.cold",
|
|
6
|
-
label: "flux load, first import (cold process)",
|
|
7
|
-
base: 300,
|
|
8
|
-
head: 400,
|
|
9
|
-
percent: 33.3,
|
|
10
|
-
rounds: [
|
|
11
|
-
{ base: 300, head: 395 },
|
|
12
|
-
{ base: 300, head: 405 },
|
|
13
|
-
],
|
|
14
|
-
};
|
|
15
|
-
const context = {
|
|
16
|
-
repoUrl: "https://github.com/typesmiths/decohere",
|
|
17
|
-
runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
|
|
18
|
-
machine: "gh-actions-node24, Test CPU × 4",
|
|
19
|
-
threshold: 0.2,
|
|
20
|
-
base: "aaaaaaaaaaaaaaaa",
|
|
21
|
-
head: "cccccccccccccccc",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
describe("regression issue", () => {
|
|
25
|
-
it("has one stable title per benchmark, so the nightly run finds and updates it", () => {
|
|
26
|
-
expect(issueTitle(regression)).toBe("perf(bench): flux.load.cold is slower");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("gives both rounds, the commit range and how to reproduce", () => {
|
|
30
|
-
const body = issueBody(regression, context);
|
|
31
|
-
expect(body).toContain(
|
|
32
|
-
"is 33% slower on [`ccccccc`](https://github.com/typesmiths/decohere/commit/cccccccccccccccc)",
|
|
33
|
-
);
|
|
34
|
-
expect(body).toContain("| 1 | 300.0 ms | 395.0 ms |");
|
|
35
|
-
expect(body).toContain("| 2 | 300.0 ms | 405.0 ms |");
|
|
36
|
-
expect(body).toContain(
|
|
37
|
-
"(https://github.com/typesmiths/decohere/compare/aaaaaaaaaaaaaaaa...cccccccccccccccc)",
|
|
38
|
-
);
|
|
39
|
-
expect(body).toContain("check out `aaaaaaa`, run `pnpm bench:save`");
|
|
40
|
-
expect(body).toContain(context.runUrl);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
assessTokenLifetime,
|
|
4
|
-
buildDescription,
|
|
5
|
-
CREDENTIALS,
|
|
6
|
-
credentialProfile,
|
|
7
|
-
issueTitle,
|
|
8
|
-
parseExpiresAt,
|
|
9
|
-
rotationCommands,
|
|
10
|
-
runNudge,
|
|
11
|
-
suggestedNextExpiry,
|
|
12
|
-
} from "../../src/github/credential-nudge.mjs";
|
|
13
|
-
|
|
14
|
-
/** Records each upsert and answers like a newly opened issue. */
|
|
15
|
-
function recordUpserts() {
|
|
16
|
-
/** @type {{ title: string, body: string }[]} */
|
|
17
|
-
const upserts = [];
|
|
18
|
-
const upsert = (options) => {
|
|
19
|
-
upserts.push(options);
|
|
20
|
-
return {
|
|
21
|
-
number: 9,
|
|
22
|
-
url: "https://github.com/typesmiths/decohere/issues/9",
|
|
23
|
-
created: true,
|
|
24
|
-
};
|
|
25
|
-
};
|
|
26
|
-
return { upserts, upsert };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function inDays(days) {
|
|
30
|
-
return suggestedNextExpiry(Date.now(), days);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe("credential-nudge", () => {
|
|
34
|
-
it("parses YYYY-MM-DD as a UTC day", () => {
|
|
35
|
-
expect(parseExpiresAt("2026-11-19")).toBe(Date.UTC(2026, 10, 19));
|
|
36
|
-
expect(parseExpiresAt("")).toBeNull();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("is ok more than 14 days out, warns inside the window, expires on the day", () => {
|
|
40
|
-
const expiresAtMs = Date.UTC(2026, 10, 19);
|
|
41
|
-
const now = (day) => Date.UTC(2026, 10, day, 15);
|
|
42
|
-
expect(assessTokenLifetime({ now: now(1), expiresAtMs }).kind).toBe("ok");
|
|
43
|
-
expect(assessTokenLifetime({ now: now(5), expiresAtMs })).toEqual({
|
|
44
|
-
kind: "warn",
|
|
45
|
-
daysLeft: 14,
|
|
46
|
-
});
|
|
47
|
-
expect(assessTokenLifetime({ now: now(18), expiresAtMs }).kind).toBe("warn");
|
|
48
|
-
expect(assessTokenLifetime({ now: now(19), expiresAtMs })).toEqual({
|
|
49
|
-
kind: "expired",
|
|
50
|
-
daysLeft: 0,
|
|
51
|
-
});
|
|
52
|
-
expect(assessTokenLifetime({ now: now(20), expiresAtMs }).kind).toBe("expired");
|
|
53
|
-
expect(assessTokenLifetime({ expiresAtMs: null }).kind).toBe("missing");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("builds the rotation issue for a credential profile", () => {
|
|
57
|
-
const npm = credentialProfile("npm");
|
|
58
|
-
expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
|
|
59
|
-
"gh secret set NODE_AUTH_TOKEN --repo typesmiths/decohere",
|
|
60
|
-
);
|
|
61
|
-
expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
|
|
62
|
-
"gh variable set NODE_AUTH_TOKEN_EXPIRES_AT --repo typesmiths/decohere --body 2027-02-17",
|
|
63
|
-
);
|
|
64
|
-
expect(issueTitle(npm, "2026-11-19")).toBe(
|
|
65
|
-
"chore(security): rotate NODE_AUTH_TOKEN (expires 2026-11-19)",
|
|
66
|
-
);
|
|
67
|
-
const body = buildDescription(npm, {
|
|
68
|
-
kind: "warn",
|
|
69
|
-
expiresYmd: "2026-11-19",
|
|
70
|
-
daysLeft: 14,
|
|
71
|
-
rejected: false,
|
|
72
|
-
});
|
|
73
|
-
expect(body).toContain(CREDENTIALS.npm.failImpact);
|
|
74
|
-
expect(suggestedNextExpiry(Date.UTC(2026, 7, 21), 90)).toBe("2026-11-19");
|
|
75
|
-
expect(() => credentialProfile("ghcr")).toThrow(/Unknown NUDGE_CREDENTIAL/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("files nothing and passes while the token has more than 14 days left", async () => {
|
|
79
|
-
const { upserts, upsert } = recordUpserts();
|
|
80
|
-
const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: inDays(30) };
|
|
81
|
-
expect(await runNudge(env, upsert)).toBe(0);
|
|
82
|
-
expect(upserts).toEqual([]);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("files a rotation issue but lets Publish continue inside the warning window", async () => {
|
|
86
|
-
const { upserts, upsert } = recordUpserts();
|
|
87
|
-
const expires = inDays(10);
|
|
88
|
-
const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: expires };
|
|
89
|
-
expect(await runNudge(env, upsert)).toBe(0);
|
|
90
|
-
expect(upserts).toHaveLength(1);
|
|
91
|
-
expect(upserts[0].title).toBe(
|
|
92
|
-
`chore(security): rotate NODE_AUTH_TOKEN (expires ${expires})`,
|
|
93
|
-
);
|
|
94
|
-
expect(upserts[0].body).toContain("Close this issue, then retry Publish");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("files a rotation issue and stops Publish once the token has expired or was rejected", async () => {
|
|
98
|
-
const expired = recordUpserts();
|
|
99
|
-
const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: inDays(0) };
|
|
100
|
-
expect(await runNudge(env, expired.upsert)).toBe(1);
|
|
101
|
-
expect(expired.upserts).toHaveLength(1);
|
|
102
|
-
|
|
103
|
-
const rejected = recordUpserts();
|
|
104
|
-
const rejectedEnv = {
|
|
105
|
-
...env,
|
|
106
|
-
NODE_AUTH_TOKEN_EXPIRES_AT: inDays(60),
|
|
107
|
-
NUDGE_REJECTED: "1",
|
|
108
|
-
};
|
|
109
|
-
expect(await runNudge(rejectedEnv, rejected.upsert)).toBe(1);
|
|
110
|
-
expect(rejected.upserts[0].body).toContain(CREDENTIALS.npm.rejectedWhy);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { vol } from "memfs";
|
|
2
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { runBootstrap } from "../../src/github/npm-bootstrap.mjs";
|
|
4
|
-
|
|
5
|
-
const root = "/workspace";
|
|
6
|
-
|
|
7
|
-
function seedWorkspace() {
|
|
8
|
-
vol.fromJSON({
|
|
9
|
-
[`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.5" }),
|
|
10
|
-
[`${root}/projects/scribe/scribe/package.json`]: JSON.stringify({
|
|
11
|
-
name: "@decohere/scribe",
|
|
12
|
-
version: "0.0.1-dev.5",
|
|
13
|
-
}),
|
|
14
|
-
[`${root}/projects/scribe/scribe-console/package.json`]: JSON.stringify({
|
|
15
|
-
name: "@decohere/scribe-console",
|
|
16
|
-
version: "0.0.1-dev.5",
|
|
17
|
-
}),
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A fake registry: `published` names exist, and `trusted` names have a Trusted
|
|
23
|
-
* Publisher. Each call is recorded as its arguments, plus the code npm would
|
|
24
|
-
* get through NPM_CONFIG_OTP.
|
|
25
|
-
*/
|
|
26
|
-
function fakeNpm({ published = [], trusted = [], loggedIn = true } = {}) {
|
|
27
|
-
const calls = [];
|
|
28
|
-
const npm = (args, { otp } = {}) => {
|
|
29
|
-
calls.push(`${args.join(" ")}${otp ? ` [otp ${otp}]` : ""}`);
|
|
30
|
-
if (args[0] === "whoami") {
|
|
31
|
-
return { status: loggedIn ? 0 : 1, output: "" };
|
|
32
|
-
}
|
|
33
|
-
if (args[0] === "trust" && args[1] === "list") {
|
|
34
|
-
return trusted.includes(args[2])
|
|
35
|
-
? { status: 0, output: '[{"repository":"typesmiths/decohere","file":"publish.yml"}]' }
|
|
36
|
-
: { status: 0, output: "[]" };
|
|
37
|
-
}
|
|
38
|
-
return { status: 0, output: "" };
|
|
39
|
-
};
|
|
40
|
-
return { npm, calls, nameExists: (name) => published.includes(name) };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function run(argv, registry) {
|
|
44
|
-
const lines = [];
|
|
45
|
-
return runBootstrap({
|
|
46
|
-
argv,
|
|
47
|
-
root,
|
|
48
|
-
io: {
|
|
49
|
-
npm: registry.npm,
|
|
50
|
-
nameExists: registry.nameExists,
|
|
51
|
-
log: (line) => lines.push(line),
|
|
52
|
-
},
|
|
53
|
-
}).then((code) => ({ code, lines }));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
afterEach(() => {
|
|
57
|
-
vol.reset();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe("npm-bootstrap", () => {
|
|
61
|
-
it("first-publishes unpublished names public, then sets the Trusted Publisher", async () => {
|
|
62
|
-
seedWorkspace();
|
|
63
|
-
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
64
|
-
|
|
65
|
-
const { code } = await run(["--otp", "123456"], registry);
|
|
66
|
-
|
|
67
|
-
expect(code).toBe(0);
|
|
68
|
-
expect(registry.calls).toEqual([
|
|
69
|
-
"whoami",
|
|
70
|
-
"publish ./projects/scribe/scribe-console --access public --tag dev [otp 123456]",
|
|
71
|
-
"trust list @decohere/scribe-console --json [otp 123456]",
|
|
72
|
-
"trust github @decohere/scribe-console --file publish.yml --repo typesmiths/decohere --env npm-publish --allow-publish --yes [otp 123456]",
|
|
73
|
-
]);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("skips the steps that are already done", async () => {
|
|
77
|
-
seedWorkspace();
|
|
78
|
-
const registry = fakeNpm({
|
|
79
|
-
published: ["@decohere/scribe-console"],
|
|
80
|
-
trusted: ["@decohere/scribe-console"],
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const { code } = await run(["@decohere/scribe-console"], registry);
|
|
84
|
-
|
|
85
|
-
expect(code).toBe(0);
|
|
86
|
-
expect(registry.calls).toEqual(["whoami", "trust list @decohere/scribe-console --json"]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
/** `npm trust` rejects `--otp`: the code goes through the environment. */
|
|
90
|
-
it("never passes the code as an argument, and lets npm ask when none is given", async () => {
|
|
91
|
-
seedWorkspace();
|
|
92
|
-
const withCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
93
|
-
await run(["--otp", "604381"], withCode);
|
|
94
|
-
expect(withCode.calls.join("\n")).not.toContain("--otp");
|
|
95
|
-
|
|
96
|
-
const withoutCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
97
|
-
expect((await run([], withoutCode)).code).toBe(0);
|
|
98
|
-
expect(withoutCode.calls.join("\n")).not.toContain("otp");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("prints the commands on a dry run, calling npm only to check the login", async () => {
|
|
102
|
-
seedWorkspace();
|
|
103
|
-
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
104
|
-
|
|
105
|
-
const { code, lines } = await run(["--dry-run"], registry);
|
|
106
|
-
|
|
107
|
-
expect(code).toBe(0);
|
|
108
|
-
expect(registry.calls).toEqual(["whoami"]);
|
|
109
|
-
expect(lines).toContain(
|
|
110
|
-
"[dry run] npm publish ./projects/scribe/scribe-console --access public --tag dev",
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("stops when not logged in, or for a name that is not a package", async () => {
|
|
115
|
-
seedWorkspace();
|
|
116
|
-
const loggedOut = fakeNpm({ loggedIn: false });
|
|
117
|
-
expect((await run(["--otp", "1"], loggedOut)).code).toBe(1);
|
|
118
|
-
expect(loggedOut.calls).toEqual(["whoami"]);
|
|
119
|
-
|
|
120
|
-
const typo = fakeNpm();
|
|
121
|
-
const { code, lines } = await run(["@decohere/scribe-consol"], typo);
|
|
122
|
-
expect(code).toBe(1);
|
|
123
|
-
expect(lines).toEqual(["Not a publishable package in projects/: @decohere/scribe-consol"]);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
buildDescription,
|
|
4
|
-
extractGitHubReviewerLogins,
|
|
5
|
-
firstPublishCommands,
|
|
6
|
-
issueTitle,
|
|
7
|
-
needsPrereleaseTag,
|
|
8
|
-
parseUnpublished,
|
|
9
|
-
runNudge,
|
|
10
|
-
} from "../../src/github/oidc-bootstrap-nudge.mjs";
|
|
11
|
-
|
|
12
|
-
describe("oidc-bootstrap-nudge", () => {
|
|
13
|
-
it("parses unpublished names", () => {
|
|
14
|
-
expect(parseUnpublished(" @decohere/scaffold @decohere/cli ")).toEqual([
|
|
15
|
-
"@decohere/scaffold",
|
|
16
|
-
"@decohere/cli",
|
|
17
|
-
]);
|
|
18
|
-
expect(issueTitle()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("extracts GitHub user reviewers and skips teams", () => {
|
|
22
|
-
expect(
|
|
23
|
-
extractGitHubReviewerLogins({
|
|
24
|
-
protection_rules: [
|
|
25
|
-
{ type: "branch_policy", reviewers: [] },
|
|
26
|
-
{
|
|
27
|
-
type: "required_reviewers",
|
|
28
|
-
reviewers: [
|
|
29
|
-
{ type: "User", reviewer: { login: "das974" } },
|
|
30
|
-
{ type: "Team", reviewer: { slug: "publishers", login: "should-skip" } },
|
|
31
|
-
],
|
|
32
|
-
},
|
|
33
|
-
],
|
|
34
|
-
}),
|
|
35
|
-
).toEqual(["das974"]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("puts first-publish commands and Trusted Publisher fields in the task body", () => {
|
|
39
|
-
const pkg = { name: "@decohere/scaffold", dir: "scaffold", version: "0.0.1-dev.18" };
|
|
40
|
-
const body = buildDescription({
|
|
41
|
-
unpublished: ["@decohere/scaffold"],
|
|
42
|
-
packages: [pkg],
|
|
43
|
-
repo: "typesmiths/decohere",
|
|
44
|
-
runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
|
|
45
|
-
channel: "dev",
|
|
46
|
-
});
|
|
47
|
-
expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
|
|
48
|
-
expect(needsPrereleaseTag("1.2.3")).toBe(false);
|
|
49
|
-
expect(firstPublishCommands([pkg], "dev")).toEqual([
|
|
50
|
-
"npm publish ./projects/scaffold --access public --tag dev",
|
|
51
|
-
]);
|
|
52
|
-
expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
|
|
53
|
-
"npm publish ./projects/scaffold --access public",
|
|
54
|
-
]);
|
|
55
|
-
expect(body).toContain("npm publish ./projects/scaffold --access public --tag dev");
|
|
56
|
-
expect(body).toContain("pnpm npm:bootstrap --otp <code> @decohere/scaffold");
|
|
57
|
-
expect(body).not.toMatch(/# @decohere\/scaffold/);
|
|
58
|
-
expect(body).toContain("First-publish each name **public**");
|
|
59
|
-
expect(body).toContain("Workflow filename: `publish.yml`");
|
|
60
|
-
expect(body).toContain("Environment: `npm-publish`");
|
|
61
|
-
expect(body).toContain("gh run rerun --failed");
|
|
62
|
-
expect(body).toContain("https://github.com/typesmiths/decohere/actions/runs/9");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("assigns the bootstrap environment's reviewers to the issue", async () => {
|
|
66
|
-
/** @type {object[]} */
|
|
67
|
-
const upserts = [];
|
|
68
|
-
const code = await runNudge(
|
|
69
|
-
{ UNPUBLISHED: "@decohere/scaffold", GITHUB_REPOSITORY: "typesmiths/decohere" },
|
|
70
|
-
{
|
|
71
|
-
listPackages: () => [{ name: "@decohere/scaffold", dir: "scaffold" }],
|
|
72
|
-
githubReviewers: async () => ["das974"],
|
|
73
|
-
upsert: (options) => {
|
|
74
|
-
upserts.push(options);
|
|
75
|
-
return {
|
|
76
|
-
number: 5,
|
|
77
|
-
url: "https://github.com/typesmiths/decohere/issues/5",
|
|
78
|
-
created: true,
|
|
79
|
-
};
|
|
80
|
-
},
|
|
81
|
-
runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
|
|
82
|
-
},
|
|
83
|
-
);
|
|
84
|
-
expect(code).toBe(0);
|
|
85
|
-
expect(upserts[0].title).toBe(issueTitle());
|
|
86
|
-
expect(upserts[0].assignees).toEqual(["das974"]);
|
|
87
|
-
expect(upserts[0].body).toContain("`@decohere/scaffold`");
|
|
88
|
-
});
|
|
89
|
-
});
|