@decohere/ci-tools 0.0.1-dev.122 → 0.0.1-dev.124

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.122",
3
+ "version": "0.0.1-dev.124",
4
4
  "description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
5
5
  "keywords": [
6
6
  "typescript",
@@ -10,6 +10,15 @@
10
10
  "github-actions",
11
11
  "decohere"
12
12
  ],
13
+ "homepage": "https://github.com/typesmiths/decohere/tree/main/projects/ci-tools#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/typesmiths/decohere/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/typesmiths/decohere.git",
20
+ "directory": "projects/ci-tools"
21
+ },
13
22
  "license": "ISC",
14
23
  "author": "Typesmiths",
15
24
  "type": "module",
@@ -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 upsert, and fail-closed live below.
11
- * `ghEnv` unset means repo secret / repo variable (not an Actions environment).
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} ${flags}--repo ${repo}`,
120
- `gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body ${next}`,
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
- `${credential.ghEnv ? `${credential.ghEnv} environment` : "repository"} variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
208
- ` gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body YYYY-MM-DD\n`,
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
  }
@@ -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("parameterizes GHCR vs npm rotate commands without duplicating date logic", () => {
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(ghcr, {
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("Docker login/push and the `v*` tag will **fail**");
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("nope")).toThrow(/Unknown NUDGE_CREDENTIAL/);
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: "ghcr", GHCR_TOKEN_EXPIRES_AT: inDays(0) };
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 = { ...env, GHCR_TOKEN_EXPIRES_AT: inDays(60), NUDGE_REJECTED: "1" };
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.ghcr.rejectedWhy);
110
+ expect(rejected.upserts[0].body).toContain(CREDENTIALS.npm.rejectedWhy);
115
111
  });
116
112
  });
@@ -0,0 +1,69 @@
1
+ import { join, relative } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { vol } from "memfs";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { listPublishablePackages } from "../../src/github/publish-verify.mjs";
6
+
7
+ const fs = await vi.importActual("node:fs");
8
+ const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
9
+ const REPO = "https://github.com/typesmiths/decohere";
10
+
11
+ /** The repo's real package.json files, loaded into the in-memory fs the tests run on. */
12
+ function loadRealManifests() {
13
+ /** @type {Record<string, string>} */
14
+ const files = {};
15
+ const walk = (dir) => {
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
17
+ const path = join(dir, entry.name);
18
+ if (entry.isDirectory() && !["node_modules", "dist"].includes(entry.name)) {
19
+ walk(path);
20
+ } else if (entry.name === "package.json") {
21
+ files[join("/repo", relative(REPO_ROOT, path))] = fs.readFileSync(path, "utf8");
22
+ }
23
+ }
24
+ };
25
+ walk(join(REPO_ROOT, "projects"));
26
+ return files;
27
+ }
28
+
29
+ afterEach(() => {
30
+ vol.reset();
31
+ });
32
+
33
+ // npm shows these on each package's page, and Trusted Publishing's provenance is
34
+ // checked against `repository.url`, so a package without it fails to publish.
35
+ describe("published package metadata", () => {
36
+ vol.fromJSON(loadRealManifests());
37
+ const packages = listPublishablePackages("/repo").map((pkg) => ({
38
+ ...pkg,
39
+ json: JSON.parse(String(vol.readFileSync(`/repo/projects/${pkg.dir}/package.json`))),
40
+ }));
41
+ vol.reset();
42
+
43
+ it("finds the publishable packages", () => {
44
+ expect(packages.length).toBeGreaterThan(0);
45
+ });
46
+
47
+ it.each(packages.map((pkg) => [pkg.name, pkg]))(
48
+ "%s links back to the repo",
49
+ (_name, pkg) => {
50
+ expect(pkg.json.repository).toEqual({
51
+ type: "git",
52
+ url: `git+${REPO}.git`,
53
+ directory: `projects/${pkg.dir}`,
54
+ });
55
+ expect(pkg.json.homepage).toBe(`${REPO}/tree/main/projects/${pkg.dir}#readme`);
56
+ expect(pkg.json.bugs).toEqual({ url: `${REPO}/issues` });
57
+ },
58
+ );
59
+
60
+ it.each(packages.map((pkg) => [pkg.name, pkg]))(
61
+ "%s has a description, keywords, license and author",
62
+ (_name, pkg) => {
63
+ expect(pkg.json.description).toBeTruthy();
64
+ expect(pkg.json.keywords?.length).toBeGreaterThan(0);
65
+ expect(pkg.json.license).toBe("ISC");
66
+ expect(pkg.json.author).toBeTruthy();
67
+ },
68
+ );
69
+ });