@decohere/ci-tools 0.0.1-dev.123 → 0.0.1-dev.125
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 -1
- package/src/github/credential-nudge.mjs +6 -25
- package/src/github/npm-bootstrap.mjs +4 -4
- package/src/github/oidc-bootstrap-nudge.mjs +2 -2
- package/tests/github/credential-nudge.test.mjs +11 -15
- package/tests/github/npm-bootstrap.test.mjs +3 -3
- package/tests/github/oidc-bootstrap-nudge.test.mjs +4 -5
package/package.json
CHANGED
|
@@ -7,14 +7,13 @@ const MS_PER_DAY = 86_400_000;
|
|
|
7
7
|
const DEFAULT_REPO = "typesmiths/decohere";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* One profile per credential. Date math, GitHub issue
|
|
11
|
-
*
|
|
10
|
+
* One profile per credential (repo secret + repo variable). Date math, GitHub issue
|
|
11
|
+
* upsert, and fail-closed live below.
|
|
12
12
|
*
|
|
13
13
|
* @typedef {{
|
|
14
14
|
* id: string,
|
|
15
15
|
* secret: string,
|
|
16
16
|
* expiresVar: string,
|
|
17
|
-
* ghEnv?: string,
|
|
18
17
|
* mint: string,
|
|
19
18
|
* failImpact: string,
|
|
20
19
|
* rejectedWhy: string,
|
|
@@ -24,17 +23,6 @@ const DEFAULT_REPO = "typesmiths/decohere";
|
|
|
24
23
|
|
|
25
24
|
/** @type {Record<string, CredentialProfile>} */
|
|
26
25
|
export const CREDENTIALS = {
|
|
27
|
-
ghcr: {
|
|
28
|
-
id: "ghcr",
|
|
29
|
-
secret: "GHCR_PUSH_TOKEN",
|
|
30
|
-
expiresVar: "GHCR_TOKEN_EXPIRES_AT",
|
|
31
|
-
ghEnv: "ghcr-publish",
|
|
32
|
-
mint: "Create a **classic** GitHub PAT (fine-grained tokens cannot push to GHCR): https://github.com/settings/tokens/new?scopes=write:packages",
|
|
33
|
-
failImpact:
|
|
34
|
-
"npm may already have published. Docker login/push and the `v*` tag will **fail**. The SHA stays untagged, so the next successful Publish of that tip can finish Docker + tag after you rotate. Images are not pushed with `GITHUB_TOKEN`.",
|
|
35
|
-
rejectedWhy: "GHCR `docker login` returned 401 (expired, revoked, or wrong secret name).",
|
|
36
|
-
revokeUrl: "https://github.com/settings/tokens",
|
|
37
|
-
},
|
|
38
26
|
npm: {
|
|
39
27
|
id: "npm",
|
|
40
28
|
secret: "NODE_AUTH_TOKEN",
|
|
@@ -85,11 +73,6 @@ export function resolveRepo() {
|
|
|
85
73
|
return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
86
74
|
}
|
|
87
75
|
|
|
88
|
-
/** @param {CredentialProfile} credential */
|
|
89
|
-
export function ghScopeFlags(credential) {
|
|
90
|
-
return credential.ghEnv ? `--env ${credential.ghEnv} ` : "";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
76
|
/** @param {string} id */
|
|
94
77
|
export function credentialProfile(id) {
|
|
95
78
|
const credential = CREDENTIALS[id];
|
|
@@ -109,15 +92,14 @@ export function issueTitle(credential, expiresYmd) {
|
|
|
109
92
|
/** @param {CredentialProfile} credential */
|
|
110
93
|
export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
|
|
111
94
|
const next = nextExpiresYmd ?? "YYYY-MM-DD";
|
|
112
|
-
const flags = ghScopeFlags(credential);
|
|
113
95
|
return [
|
|
114
96
|
credential.mint,
|
|
115
97
|
"",
|
|
116
98
|
"Then from a trusted machine (do not paste the token into Actions or this issue):",
|
|
117
99
|
"",
|
|
118
100
|
"```bash",
|
|
119
|
-
`gh secret set ${credential.secret}
|
|
120
|
-
`gh variable set ${credential.expiresVar}
|
|
101
|
+
`gh secret set ${credential.secret} --repo ${repo}`,
|
|
102
|
+
`gh variable set ${credential.expiresVar} --repo ${repo} --body ${next}`,
|
|
121
103
|
"```",
|
|
122
104
|
"",
|
|
123
105
|
`Revoke the old token: ${credential.revokeUrl}`,
|
|
@@ -199,13 +181,12 @@ export async function runNudge(env = process.env, upsert = upsertIssue) {
|
|
|
199
181
|
const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
|
|
200
182
|
const expiresAtMs = parseExpiresAt(expiresYmd);
|
|
201
183
|
const assessment = assessTokenLifetime({ expiresAtMs });
|
|
202
|
-
const flags = ghScopeFlags(credential);
|
|
203
184
|
const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
204
185
|
|
|
205
186
|
if (assessment.kind === "missing" && !rejected) {
|
|
206
187
|
process.stderr.write(
|
|
207
|
-
|
|
208
|
-
` gh variable set ${credential.expiresVar}
|
|
188
|
+
`repository variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
|
|
189
|
+
` gh variable set ${credential.expiresVar} --repo ${repo} --body YYYY-MM-DD\n`,
|
|
209
190
|
);
|
|
210
191
|
return 1;
|
|
211
192
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-publish a new package name (
|
|
2
|
+
* First-publish a new package name (public) and give it its Trusted Publisher,
|
|
3
3
|
* so `publish.yml` can release it over OIDC from then on.
|
|
4
4
|
*
|
|
5
5
|
* pnpm npm:bootstrap [--otp <code>] [--dry-run] [--channel dev] [<package-name>...]
|
|
@@ -31,15 +31,15 @@ export function needsPrereleaseTag(version) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* npm arguments for a name-only first publish.
|
|
35
|
-
* so
|
|
34
|
+
* npm arguments for a name-only first publish. Scoped names default to restricted,
|
|
35
|
+
* so `--access public`. `./` stops npm reading the path as a GitHub `owner/repo`.
|
|
36
36
|
*
|
|
37
37
|
* @param {{ dir: string, version?: string }} pkg
|
|
38
38
|
* @param {string} channel
|
|
39
39
|
*/
|
|
40
40
|
export function publishArgs(pkg, channel) {
|
|
41
41
|
const tag = needsPrereleaseTag(pkg.version) ? ["--tag", channel] : [];
|
|
42
|
-
return ["publish", `./projects/${pkg.dir}`, ...tag];
|
|
42
|
+
return ["publish", `./projects/${pkg.dir}`, "--access", "public", ...tag];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** @param {string} name */
|
|
@@ -33,7 +33,7 @@ export function firstPublishCommands(packages, channel = "dev") {
|
|
|
33
33
|
}
|
|
34
34
|
return packages.map((pkg) => {
|
|
35
35
|
const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
|
|
36
|
-
return `npm publish ./projects/${pkg.dir}${tag}`;
|
|
36
|
+
return `npm publish ./projects/${pkg.dir} --access public${tag}`;
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -95,7 +95,7 @@ export function buildDescription(options) {
|
|
|
95
95
|
"",
|
|
96
96
|
"That does steps 1 and 2 below, skipping what is already done. To do them by hand instead:",
|
|
97
97
|
"",
|
|
98
|
-
"1. First-publish each name **
|
|
98
|
+
"1. First-publish each name **public** (`--access public`: scoped names default to restricted). From a trusted machine, `npm login` to the org, then from the **repo root**. Use `./` so npm does not treat the path as a GitHub `owner/repo`. `--tag` is required for prerelease versions (`x.y.z-dev.N`). Missing `bin`/`es6` warnings are OK for this name-only bootstrap; CI `forge publish` ships the built tarball later:",
|
|
99
99
|
"",
|
|
100
100
|
"```bash",
|
|
101
101
|
...firstPublishCommands(packages, channel),
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
buildDescription,
|
|
5
5
|
CREDENTIALS,
|
|
6
6
|
credentialProfile,
|
|
7
|
-
ghScopeFlags,
|
|
8
7
|
issueTitle,
|
|
9
8
|
parseExpiresAt,
|
|
10
9
|
rotationCommands,
|
|
@@ -54,14 +53,8 @@ describe("credential-nudge", () => {
|
|
|
54
53
|
expect(assessTokenLifetime({ expiresAtMs: null }).kind).toBe("missing");
|
|
55
54
|
});
|
|
56
55
|
|
|
57
|
-
it("
|
|
58
|
-
const ghcr = credentialProfile("ghcr");
|
|
56
|
+
it("builds the rotation issue for a credential profile", () => {
|
|
59
57
|
const npm = credentialProfile("npm");
|
|
60
|
-
expect(ghScopeFlags(ghcr)).toBe("--env ghcr-publish ");
|
|
61
|
-
expect(ghScopeFlags(npm)).toBe("");
|
|
62
|
-
expect(rotationCommands(ghcr, "2027-02-17", "typesmiths/decohere")).toContain(
|
|
63
|
-
"gh secret set GHCR_PUSH_TOKEN --env ghcr-publish --repo typesmiths/decohere",
|
|
64
|
-
);
|
|
65
58
|
expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
|
|
66
59
|
"gh secret set NODE_AUTH_TOKEN --repo typesmiths/decohere",
|
|
67
60
|
);
|
|
@@ -71,16 +64,15 @@ describe("credential-nudge", () => {
|
|
|
71
64
|
expect(issueTitle(npm, "2026-11-19")).toBe(
|
|
72
65
|
"chore(security): rotate NODE_AUTH_TOKEN (expires 2026-11-19)",
|
|
73
66
|
);
|
|
74
|
-
const body = buildDescription(
|
|
67
|
+
const body = buildDescription(npm, {
|
|
75
68
|
kind: "warn",
|
|
76
69
|
expiresYmd: "2026-11-19",
|
|
77
70
|
daysLeft: 14,
|
|
78
71
|
rejected: false,
|
|
79
72
|
});
|
|
80
|
-
expect(body).toContain(
|
|
81
|
-
expect(CREDENTIALS.npm.secret).toBe("NODE_AUTH_TOKEN");
|
|
73
|
+
expect(body).toContain(CREDENTIALS.npm.failImpact);
|
|
82
74
|
expect(suggestedNextExpiry(Date.UTC(2026, 7, 21), 90)).toBe("2026-11-19");
|
|
83
|
-
expect(() => credentialProfile("
|
|
75
|
+
expect(() => credentialProfile("ghcr")).toThrow(/Unknown NUDGE_CREDENTIAL/);
|
|
84
76
|
});
|
|
85
77
|
|
|
86
78
|
it("files nothing and passes while the token has more than 14 days left", async () => {
|
|
@@ -104,13 +96,17 @@ describe("credential-nudge", () => {
|
|
|
104
96
|
|
|
105
97
|
it("files a rotation issue and stops Publish once the token has expired or was rejected", async () => {
|
|
106
98
|
const expired = recordUpserts();
|
|
107
|
-
const env = { NUDGE_CREDENTIAL: "
|
|
99
|
+
const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: inDays(0) };
|
|
108
100
|
expect(await runNudge(env, expired.upsert)).toBe(1);
|
|
109
101
|
expect(expired.upserts).toHaveLength(1);
|
|
110
102
|
|
|
111
103
|
const rejected = recordUpserts();
|
|
112
|
-
const rejectedEnv = {
|
|
104
|
+
const rejectedEnv = {
|
|
105
|
+
...env,
|
|
106
|
+
NODE_AUTH_TOKEN_EXPIRES_AT: inDays(60),
|
|
107
|
+
NUDGE_REJECTED: "1",
|
|
108
|
+
};
|
|
113
109
|
expect(await runNudge(rejectedEnv, rejected.upsert)).toBe(1);
|
|
114
|
-
expect(rejected.upserts[0].body).toContain(CREDENTIALS.
|
|
110
|
+
expect(rejected.upserts[0].body).toContain(CREDENTIALS.npm.rejectedWhy);
|
|
115
111
|
});
|
|
116
112
|
});
|
|
@@ -58,7 +58,7 @@ afterEach(() => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
describe("npm-bootstrap", () => {
|
|
61
|
-
it("first-publishes unpublished names
|
|
61
|
+
it("first-publishes unpublished names public, then sets the Trusted Publisher", async () => {
|
|
62
62
|
seedWorkspace();
|
|
63
63
|
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
64
64
|
|
|
@@ -67,7 +67,7 @@ describe("npm-bootstrap", () => {
|
|
|
67
67
|
expect(code).toBe(0);
|
|
68
68
|
expect(registry.calls).toEqual([
|
|
69
69
|
"whoami",
|
|
70
|
-
"publish ./projects/scribe/scribe-console --tag dev [otp 123456]",
|
|
70
|
+
"publish ./projects/scribe/scribe-console --access public --tag dev [otp 123456]",
|
|
71
71
|
"trust list @decohere/scribe-console --json [otp 123456]",
|
|
72
72
|
"trust github @decohere/scribe-console --file publish.yml --repo typesmiths/decohere --env npm-publish --allow-publish --yes [otp 123456]",
|
|
73
73
|
]);
|
|
@@ -107,7 +107,7 @@ describe("npm-bootstrap", () => {
|
|
|
107
107
|
expect(code).toBe(0);
|
|
108
108
|
expect(registry.calls).toEqual(["whoami"]);
|
|
109
109
|
expect(lines).toContain(
|
|
110
|
-
"[dry run] npm publish ./projects/scribe/scribe-console --tag dev",
|
|
110
|
+
"[dry run] npm publish ./projects/scribe/scribe-console --access public --tag dev",
|
|
111
111
|
);
|
|
112
112
|
});
|
|
113
113
|
|
|
@@ -47,16 +47,15 @@ describe("oidc-bootstrap-nudge", () => {
|
|
|
47
47
|
expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
|
|
48
48
|
expect(needsPrereleaseTag("1.2.3")).toBe(false);
|
|
49
49
|
expect(firstPublishCommands([pkg], "dev")).toEqual([
|
|
50
|
-
"npm publish ./projects/scaffold --tag dev",
|
|
50
|
+
"npm publish ./projects/scaffold --access public --tag dev",
|
|
51
51
|
]);
|
|
52
52
|
expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
|
|
53
|
-
"npm publish ./projects/scaffold",
|
|
53
|
+
"npm publish ./projects/scaffold --access public",
|
|
54
54
|
]);
|
|
55
|
-
expect(body).toContain("npm publish ./projects/scaffold --tag dev");
|
|
55
|
+
expect(body).toContain("npm publish ./projects/scaffold --access public --tag dev");
|
|
56
56
|
expect(body).toContain("pnpm npm:bootstrap --otp <code> @decohere/scaffold");
|
|
57
57
|
expect(body).not.toMatch(/# @decohere\/scaffold/);
|
|
58
|
-
expect(body).toContain("
|
|
59
|
-
expect(firstPublishCommands([pkg], "dev").join("\n")).not.toContain("--access public");
|
|
58
|
+
expect(body).toContain("First-publish each name **public**");
|
|
60
59
|
expect(body).toContain("Workflow filename: `publish.yml`");
|
|
61
60
|
expect(body).toContain("Environment: `npm-publish`");
|
|
62
61
|
expect(body).toContain("gh run rerun --failed");
|