@decohere/ci-tools 0.0.1-dev.126 → 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
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"
|
|
@@ -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
|
}
|
|
@@ -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) {
|
|
@@ -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
|
-
});
|