@decohere/ci-tools 0.0.1-dev.125 → 0.0.1-dev.127
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 +1 -2
- package/src/bench/bench.mjs +2 -2
- package/src/bench/history.mjs +3 -3
- package/src/bench/nightly.mjs +1 -1
- package/src/bench/regression-issue.mjs +1 -1
- package/src/github/smoke-create-launcher.mjs +3 -15
- package/src/lib/github-issue.mjs +1 -1
- package/src/pages/bench-page.mjs +2 -2
- package/tests/github/npm-bootstrap.test.mjs +1 -1
- package/tests/github/smoke-create-launcher.test.mjs +18 -7
- package/tests/lib/github-issue.test.mjs +2 -2
- package/src/github/credential-nudge.mjs +0 -226
- package/tests/github/credential-nudge.test.mjs +0 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/ci-tools",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.127",
|
|
4
4
|
"description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"./github/ensure-metadata": "./src/github/ensure-metadata.mjs",
|
|
44
44
|
"./github/create-or-update-pr": "./src/github/create-or-update-pr.mjs",
|
|
45
45
|
"./github/publish-verify": "./src/github/publish-verify.mjs",
|
|
46
|
-
"./github/credential-nudge": "./src/github/credential-nudge.mjs",
|
|
47
46
|
"./github/oidc-bootstrap-nudge": "./src/github/oidc-bootstrap-nudge.mjs",
|
|
48
47
|
"./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs",
|
|
49
48
|
"./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
|
package/src/bench/bench.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* bench.mjs — run the benchmark suites and compare against the last saved run
|
|
4
|
+
* bench.mjs — run the benchmark suites and compare against the last saved run.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* pnpm bench — run every suite and compare, do not save
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
} from "./history.mjs";
|
|
42
42
|
|
|
43
43
|
const WORKSPACE_ROOT = process.cwd();
|
|
44
|
-
// BENCH_HISTORY_FILE lets the nightly job save each comparison round to its own file
|
|
44
|
+
// BENCH_HISTORY_FILE lets the nightly job save each comparison round to its own file.
|
|
45
45
|
const HISTORY_FILE =
|
|
46
46
|
process.env.BENCH_HISTORY_FILE ??
|
|
47
47
|
join(WORKSPACE_ROOT, "projects/ci-tools/dist/ci/bench/bench-history.json");
|
package/src/bench/history.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Benchmark history: the results of each `pnpm bench` run, kept per machine so runs are only
|
|
3
|
-
* compared like for like
|
|
3
|
+
* compared like for like. Pure functions over plain data; bench.mjs does the I/O.
|
|
4
4
|
*
|
|
5
5
|
* Shape (version 2):
|
|
6
6
|
* { version: 2, contexts: { [contextId]: { context, runs: [{ date, sha, results }] } } }
|
|
@@ -118,7 +118,7 @@ export function previousRun(history, id) {
|
|
|
118
118
|
return history.contexts[id]?.runs[0];
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/** Machine types with no run for this long are dropped: CI runner types come and go
|
|
121
|
+
/** Machine types with no run for this long are dropped: CI runner types come and go. */
|
|
122
122
|
export const STALE_AFTER_DAYS = 30;
|
|
123
123
|
|
|
124
124
|
/** The history without machine types whose newest run is older than `days` before `now`. */
|
|
@@ -205,7 +205,7 @@ export function formatComparison(rows) {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
* When a benchmark counts as slower
|
|
208
|
+
* When a benchmark counts as slower: the nightly job benchmarks tonight's commit (head)
|
|
209
209
|
* and the one the previous nightly measured (base) on the same runner, alternating head, base,
|
|
210
210
|
* head, base. A benchmark regressed when head's median beats base's by more than `threshold`
|
|
211
211
|
* in every round, so one noisy round files nothing.
|
package/src/bench/nightly.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* nightly.mjs — the nightly benchmark run
|
|
4
|
+
* nightly.mjs — the nightly benchmark run. Benchmarks tonight's commit (head) and the
|
|
5
5
|
* commit the previous nightly measured (base) on this runner, alternating head, base, head,
|
|
6
6
|
* base, so the comparison never mixes CPU models. Then:
|
|
7
7
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The GitHub issue for a benchmark that got slower
|
|
2
|
+
* The GitHub issue for a benchmark that got slower: one open issue per benchmark,
|
|
3
3
|
* found again by its exact title and updated in place while the regression lasts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -9,10 +9,10 @@ import { pathToFileURL } from "node:url";
|
|
|
9
9
|
* Run `npx @decohere/create@version --help` from an empty temp directory.
|
|
10
10
|
* Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
|
|
11
11
|
* `@decohere/create` into the workspace tree and npm's npx misses the bin.
|
|
12
|
+
* No token: the packages are public, so this installs the launcher the way a user does.
|
|
12
13
|
*
|
|
13
14
|
* @param {{
|
|
14
15
|
* createVersion: string,
|
|
15
|
-
* authToken: string,
|
|
16
16
|
* mkdtempSync?: typeof mkdtempSync,
|
|
17
17
|
* writeFileSync?: typeof writeFileSync,
|
|
18
18
|
* spawnSync?: typeof spawnSync,
|
|
@@ -22,13 +22,9 @@ import { pathToFileURL } from "node:url";
|
|
|
22
22
|
*/
|
|
23
23
|
export function runCreateLauncherSmoke(options) {
|
|
24
24
|
const version = options.createVersion?.trim() ?? "";
|
|
25
|
-
const token = options.authToken?.trim() ?? "";
|
|
26
25
|
if (!version) {
|
|
27
26
|
throw new Error("CREATE_VERSION is required");
|
|
28
27
|
}
|
|
29
|
-
if (!token) {
|
|
30
|
-
throw new Error("NODE_AUTH_TOKEN is required");
|
|
31
|
-
}
|
|
32
28
|
|
|
33
29
|
const mkdtemp = options.mkdtempSync ?? mkdtempSync;
|
|
34
30
|
const writeFile = options.writeFileSync ?? writeFileSync;
|
|
@@ -37,18 +33,11 @@ export function runCreateLauncherSmoke(options) {
|
|
|
37
33
|
|
|
38
34
|
const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
|
|
39
35
|
const npmrc = join(workDir, ".npmrc");
|
|
40
|
-
writeFile(
|
|
41
|
-
npmrc,
|
|
42
|
-
`@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`,
|
|
43
|
-
);
|
|
36
|
+
writeFile(npmrc, "@decohere:registry=https://registry.npmjs.org/\n");
|
|
44
37
|
|
|
45
38
|
const result = spawn("npx", ["--yes", `@decohere/create@${version}`, "--help"], {
|
|
46
39
|
cwd: workDir,
|
|
47
|
-
env: {
|
|
48
|
-
...process.env,
|
|
49
|
-
NPM_CONFIG_USERCONFIG: npmrc,
|
|
50
|
-
NODE_AUTH_TOKEN: token,
|
|
51
|
-
},
|
|
40
|
+
env: { ...process.env, NPM_CONFIG_USERCONFIG: npmrc },
|
|
52
41
|
stdio: options.stdio ?? "inherit",
|
|
53
42
|
});
|
|
54
43
|
|
|
@@ -64,7 +53,6 @@ const isCli =
|
|
|
64
53
|
if (isCli) {
|
|
65
54
|
const { status } = runCreateLauncherSmoke({
|
|
66
55
|
createVersion: process.env.CREATE_VERSION ?? "",
|
|
67
|
-
authToken: process.env.NODE_AUTH_TOKEN ?? "",
|
|
68
56
|
});
|
|
69
57
|
process.exit(status);
|
|
70
58
|
}
|
package/src/lib/github-issue.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export function upsertIssue(options, run = gh) {
|
|
|
47
47
|
"--limit",
|
|
48
48
|
"200",
|
|
49
49
|
"--search",
|
|
50
|
-
// Quoted: GitHub's search can't match a title like `security(ci): …` unquoted
|
|
50
|
+
// Quoted: GitHub's search can't match a title like `security(ci): …` unquoted.
|
|
51
51
|
// The exact-title match in pickIssue still decides.
|
|
52
52
|
`"${title.replaceAll('"', "")}" in:title`,
|
|
53
53
|
"--json",
|
package/src/pages/bench-page.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The benchmark page on Pages
|
|
2
|
+
* The benchmark page on Pages: a trend chart per benchmark and a table of the latest
|
|
3
3
|
* results, from the version 2 history (../bench/history.mjs). Static HTML and inline SVG, so the
|
|
4
4
|
* page needs no script and no third-party chart library.
|
|
5
5
|
*/
|
|
@@ -51,7 +51,7 @@ function machineName(context) {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Contexts with a run within STALE_AFTER_DAYS of the newest run anywhere, most recently run first:
|
|
54
|
-
* CI runner types come and go, and one that stopped appearing would otherwise keep a line
|
|
54
|
+
* CI runner types come and go, and one that stopped appearing would otherwise keep a line.
|
|
55
55
|
*/
|
|
56
56
|
function activeContexts(history) {
|
|
57
57
|
const newest = Math.max(
|
|
@@ -86,7 +86,7 @@ describe("npm-bootstrap", () => {
|
|
|
86
86
|
expect(registry.calls).toEqual(["whoami", "trust list @decohere/scribe-console --json"]);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
/**
|
|
89
|
+
/** `npm trust` rejects `--otp`: the code goes through the environment. */
|
|
90
90
|
it("never passes the code as an argument, and lets npm ask when none is given", async () => {
|
|
91
91
|
seedWorkspace();
|
|
92
92
|
const withCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
@@ -15,7 +15,6 @@ describe("runCreateLauncherSmoke", () => {
|
|
|
15
15
|
|
|
16
16
|
const result = runCreateLauncherSmoke({
|
|
17
17
|
createVersion: "0.0.1-dev.80",
|
|
18
|
-
authToken: "tok",
|
|
19
18
|
tmpdir: () => "/tmp",
|
|
20
19
|
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
21
20
|
writeFileSync: (path, body) => {
|
|
@@ -38,18 +37,17 @@ describe("runCreateLauncherSmoke", () => {
|
|
|
38
37
|
expect(written).toEqual([
|
|
39
38
|
{
|
|
40
39
|
path: "/tmp/decohere-create-smoke-abc/.npmrc",
|
|
41
|
-
body: "@decohere:registry=https://registry.npmjs.org/\n
|
|
40
|
+
body: "@decohere:registry=https://registry.npmjs.org/\n",
|
|
42
41
|
},
|
|
43
42
|
]);
|
|
43
|
+
// The packages are public: the smoke installs them the way a user does, with no token.
|
|
44
|
+
expect(calls[0].opts.env.NODE_AUTH_TOKEN).toBeUndefined();
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
it("fails closed without version
|
|
47
|
-
expect(() => runCreateLauncherSmoke({ createVersion: ""
|
|
47
|
+
it("fails closed without a version", () => {
|
|
48
|
+
expect(() => runCreateLauncherSmoke({ createVersion: "" })).toThrow(
|
|
48
49
|
"CREATE_VERSION is required",
|
|
49
50
|
);
|
|
50
|
-
expect(() =>
|
|
51
|
-
runCreateLauncherSmoke({ createVersion: "0.0.1-dev.80", authToken: "" }),
|
|
52
|
-
).toThrow("NODE_AUTH_TOKEN is required");
|
|
53
51
|
});
|
|
54
52
|
});
|
|
55
53
|
|
|
@@ -62,6 +60,19 @@ describe("publish.yml create smoke + tag", () => {
|
|
|
62
60
|
expect(yml).not.toContain("npx --yes");
|
|
63
61
|
});
|
|
64
62
|
|
|
63
|
+
it("reads npm without a token: the packages are public and publishing is OIDC", () => {
|
|
64
|
+
const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
|
|
65
|
+
const files = fs
|
|
66
|
+
.readdirSync(workflows, { recursive: true })
|
|
67
|
+
.filter((file) => /\.ya?ml$/.test(file));
|
|
68
|
+
expect(files.length).toBeGreaterThan(0);
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
|
|
71
|
+
/NODE_AUTH_TOKEN|_authToken/,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
65
76
|
it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
|
|
66
77
|
const tag = yml.slice(yml.indexOf("\n tag:"));
|
|
67
78
|
const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { pickIssue, upsertIssue } from "../../src/lib/github-issue.mjs";
|
|
3
3
|
|
|
4
|
-
const TITLE = "
|
|
4
|
+
const TITLE = "security(ci): high or critical npm audit findings";
|
|
5
5
|
|
|
6
6
|
/** A fake `gh` that answers `issue list` with `issues` and records every call. */
|
|
7
7
|
function fakeGh(issues) {
|
|
@@ -28,7 +28,7 @@ describe("github-issue", () => {
|
|
|
28
28
|
expect(pickIssue([], TITLE)).toBeNull();
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
//
|
|
31
|
+
// GitHub's search can't match `security(ci): …` unquoted, so the lookup found nothing
|
|
32
32
|
// and a new issue was opened on every run.
|
|
33
33
|
it("searches for the title as a quoted phrase", () => {
|
|
34
34
|
const { run, calls } = fakeGh([]);
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { upsertIssue } from "../lib/github-issue.mjs";
|
|
4
|
-
|
|
5
|
-
const WARN_DAYS = 14;
|
|
6
|
-
const MS_PER_DAY = 86_400_000;
|
|
7
|
-
const DEFAULT_REPO = "typesmiths/decohere";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* One profile per credential (repo secret + repo variable). Date math, GitHub issue
|
|
11
|
-
* upsert, and fail-closed live below.
|
|
12
|
-
*
|
|
13
|
-
* @typedef {{
|
|
14
|
-
* id: string,
|
|
15
|
-
* secret: string,
|
|
16
|
-
* expiresVar: string,
|
|
17
|
-
* mint: string,
|
|
18
|
-
* failImpact: string,
|
|
19
|
-
* rejectedWhy: string,
|
|
20
|
-
* revokeUrl: string,
|
|
21
|
-
* }} CredentialProfile
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** @type {Record<string, CredentialProfile>} */
|
|
25
|
-
export const CREDENTIALS = {
|
|
26
|
-
npm: {
|
|
27
|
-
id: "npm",
|
|
28
|
-
secret: "NODE_AUTH_TOKEN",
|
|
29
|
-
expiresVar: "NODE_AUTH_TOKEN_EXPIRES_AT",
|
|
30
|
-
mint: "Create an npm **read** token (granular/classic) at https://www.npmjs.com/settings/~/tokens — Publish PUTs are OIDC; this token is only for private `npm view` and create smoke.",
|
|
31
|
-
failImpact:
|
|
32
|
-
"Verify cannot see private packages, so skipIfPublished and create smoke are unsafe. Publish **fails closed** before OIDC/Docker. After you rotate, re-run the failed Publish jobs.",
|
|
33
|
-
rejectedWhy: "`npm whoami` failed (expired, revoked, or missing `NODE_AUTH_TOKEN`).",
|
|
34
|
-
revokeUrl: "https://www.npmjs.com/settings/~/tokens",
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export function utcDayMs(date) {
|
|
39
|
-
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** @param {string | undefined} raw */
|
|
43
|
-
export function parseExpiresAt(raw) {
|
|
44
|
-
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw ?? "").trim());
|
|
45
|
-
if (!match) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {{ now?: number, expiresAtMs: number | null, warnDays?: number }} options
|
|
53
|
-
* @returns {{ kind: "missing" | "ok" | "warn" | "expired", daysLeft: number | null }}
|
|
54
|
-
*/
|
|
55
|
-
export function assessTokenLifetime(options) {
|
|
56
|
-
const { expiresAtMs, warnDays = WARN_DAYS } = options;
|
|
57
|
-
if (expiresAtMs == null) {
|
|
58
|
-
return { kind: "missing", daysLeft: null };
|
|
59
|
-
}
|
|
60
|
-
const today = utcDayMs(new Date(options.now ?? Date.now()));
|
|
61
|
-
const daysLeft = Math.round((expiresAtMs - today) / MS_PER_DAY);
|
|
62
|
-
if (daysLeft <= 0) {
|
|
63
|
-
return { kind: "expired", daysLeft };
|
|
64
|
-
}
|
|
65
|
-
if (daysLeft <= warnDays) {
|
|
66
|
-
return { kind: "warn", daysLeft };
|
|
67
|
-
}
|
|
68
|
-
return { kind: "ok", daysLeft };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** @param {CredentialProfile} credential */
|
|
72
|
-
export function resolveRepo() {
|
|
73
|
-
return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** @param {string} id */
|
|
77
|
-
export function credentialProfile(id) {
|
|
78
|
-
const credential = CREDENTIALS[id];
|
|
79
|
-
if (!credential) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`Unknown NUDGE_CREDENTIAL=${id}. Expected one of: ${Object.keys(CREDENTIALS).join(", ")}`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
return credential;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** @param {CredentialProfile} credential */
|
|
88
|
-
export function issueTitle(credential, expiresYmd) {
|
|
89
|
-
return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** @param {CredentialProfile} credential */
|
|
93
|
-
export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
|
|
94
|
-
const next = nextExpiresYmd ?? "YYYY-MM-DD";
|
|
95
|
-
return [
|
|
96
|
-
credential.mint,
|
|
97
|
-
"",
|
|
98
|
-
"Then from a trusted machine (do not paste the token into Actions or this issue):",
|
|
99
|
-
"",
|
|
100
|
-
"```bash",
|
|
101
|
-
`gh secret set ${credential.secret} --repo ${repo}`,
|
|
102
|
-
`gh variable set ${credential.expiresVar} --repo ${repo} --body ${next}`,
|
|
103
|
-
"```",
|
|
104
|
-
"",
|
|
105
|
-
`Revoke the old token: ${credential.revokeUrl}`,
|
|
106
|
-
"Close this issue, then retry Publish:",
|
|
107
|
-
"",
|
|
108
|
-
"```bash",
|
|
109
|
-
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
110
|
-
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
111
|
-
"```",
|
|
112
|
-
].join("\n");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function suggestedNextExpiry(fromMs, days = 90) {
|
|
116
|
-
const start = utcDayMs(new Date(fromMs));
|
|
117
|
-
const next = new Date(start + days * MS_PER_DAY);
|
|
118
|
-
const y = next.getUTCFullYear();
|
|
119
|
-
const m = String(next.getUTCMonth() + 1).padStart(2, "0");
|
|
120
|
-
const d = String(next.getUTCDate()).padStart(2, "0");
|
|
121
|
-
return `${y}-${m}-${d}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** @param {CredentialProfile} credential */
|
|
125
|
-
export function buildDescription(credential, options) {
|
|
126
|
-
const { kind, expiresYmd, daysLeft, rejected, runUrl } = options;
|
|
127
|
-
const next = suggestedNextExpiry(Date.now());
|
|
128
|
-
const why = rejected
|
|
129
|
-
? credential.rejectedWhy
|
|
130
|
-
: kind === "expired"
|
|
131
|
-
? `${credential.expiresVar} (${expiresYmd}) is today or in the past. Rotate ${credential.secret} and this date before Publish can continue.`
|
|
132
|
-
: `${credential.secret} expires on ${expiresYmd} (${daysLeft} day(s) left). Rotate before that date so Publish does not fail closed.`;
|
|
133
|
-
return [
|
|
134
|
-
"## Summary",
|
|
135
|
-
"",
|
|
136
|
-
why,
|
|
137
|
-
"",
|
|
138
|
-
"## If this is not done in time",
|
|
139
|
-
"",
|
|
140
|
-
credential.failImpact,
|
|
141
|
-
"",
|
|
142
|
-
"## Commands",
|
|
143
|
-
"",
|
|
144
|
-
rotationCommands(credential, next),
|
|
145
|
-
"",
|
|
146
|
-
runUrl ? `- Workflow run: ${runUrl}` : null,
|
|
147
|
-
]
|
|
148
|
-
.filter((line) => line != null)
|
|
149
|
-
.join("\n");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function runUrl() {
|
|
153
|
-
if (
|
|
154
|
-
process.env.GITHUB_SERVER_URL &&
|
|
155
|
-
process.env.GITHUB_REPOSITORY &&
|
|
156
|
-
process.env.GITHUB_RUN_ID
|
|
157
|
-
) {
|
|
158
|
-
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
159
|
-
}
|
|
160
|
-
return "";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** @param {CredentialProfile} credential */
|
|
164
|
-
export function upsertRotationIssue(credential, options, upsert = upsertIssue) {
|
|
165
|
-
const { expiresYmd, kind, daysLeft, rejected } = options;
|
|
166
|
-
return upsert({
|
|
167
|
-
title: issueTitle(credential, expiresYmd ?? "unknown"),
|
|
168
|
-
body: buildDescription(credential, {
|
|
169
|
-
kind,
|
|
170
|
-
expiresYmd,
|
|
171
|
-
daysLeft,
|
|
172
|
-
rejected,
|
|
173
|
-
runUrl: runUrl(),
|
|
174
|
-
}),
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function runNudge(env = process.env, upsert = upsertIssue) {
|
|
179
|
-
const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
|
|
180
|
-
const rejected = env.NUDGE_REJECTED === "1";
|
|
181
|
-
const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
|
|
182
|
-
const expiresAtMs = parseExpiresAt(expiresYmd);
|
|
183
|
-
const assessment = assessTokenLifetime({ expiresAtMs });
|
|
184
|
-
const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
185
|
-
|
|
186
|
-
if (assessment.kind === "missing" && !rejected) {
|
|
187
|
-
process.stderr.write(
|
|
188
|
-
`repository variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
|
|
189
|
-
` gh variable set ${credential.expiresVar} --repo ${repo} --body YYYY-MM-DD\n`,
|
|
190
|
-
);
|
|
191
|
-
return 1;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
|
|
195
|
-
const result = upsertRotationIssue(
|
|
196
|
-
credential,
|
|
197
|
-
{
|
|
198
|
-
expiresYmd: expiresYmd || "unknown",
|
|
199
|
-
kind: assessment.kind,
|
|
200
|
-
daysLeft: assessment.daysLeft,
|
|
201
|
-
rejected,
|
|
202
|
-
},
|
|
203
|
-
upsert,
|
|
204
|
-
);
|
|
205
|
-
process.stdout.write(
|
|
206
|
-
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (rejected || assessment.kind === "expired" || assessment.kind === "missing") {
|
|
211
|
-
if (assessment.kind === "expired") {
|
|
212
|
-
process.stderr.write(
|
|
213
|
-
`${credential.secret} calendar expiry ${expiresYmd} has been reached — refusing to continue\n`,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
return 1;
|
|
217
|
-
}
|
|
218
|
-
return 0;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const isCli =
|
|
222
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
223
|
-
|
|
224
|
-
if (isCli) {
|
|
225
|
-
process.exit(await runNudge());
|
|
226
|
-
}
|
|
@@ -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
|
-
});
|