@bounded-systems/mint 0.4.3

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/adoption.mjs ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // Org-wide mint adoption scanner. Classifies every repo:
3
+ //
4
+ // ADOPTED — has a .release/ intent directory (uses mint)
5
+ // PUBLISHABLE — package.json with a version, not private — SHOULD adopt, hasn't
6
+ // N/A — no versioned package / private (versioning not applicable)
7
+ //
8
+ // node adoption.mjs # human report
9
+ // node adoption.mjs --json # machine-readable to stdout
10
+ // node adoption.mjs --write # open a caller PR for each publishable-not-adopted repo
11
+ //
12
+ // Env: GITHUB_TOKEN (required), ORG (default: bounded-systems), MINT_REF (pin)
13
+ import { writeFileSync } from "node:fs";
14
+
15
+ const jsonOut = process.argv.includes("--json");
16
+ const write = process.argv.includes("--write");
17
+ const token = process.env.GITHUB_TOKEN;
18
+ if (!token) { console.error("adoption: GITHUB_TOKEN required"); process.exit(1); }
19
+ const org = process.env.ORG ?? "bounded-systems";
20
+ const MINT_REF = process.env.MINT_REF ?? "v0.3.1"; // 6656a2c — first tag with release-provenance.yml
21
+
22
+ async function ghReq(path, { method = "GET", body } = {}) {
23
+ const r = await fetch(`https://api.github.com/${path}`, {
24
+ method,
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ "X-GitHub-Api-Version": "2022-11-28",
28
+ Accept: "application/vnd.github+json",
29
+ "User-Agent": "bounded-systems-mint", // GitHub rejects UA-less requests as abuse
30
+ ...(body ? { "Content-Type": "application/json" } : {}),
31
+ },
32
+ ...(body ? { body: JSON.stringify(body) } : {}),
33
+ });
34
+ if (r.status === 404) return null;
35
+ if (!r.ok) { console.warn(` gh ${method} ${path} → HTTP ${r.status}: ${(await r.text()).slice(0, 120)}`); return null; }
36
+ return r.json();
37
+ }
38
+ const gh = (path) => ghReq(path);
39
+
40
+ // Caller files dropped into each adopting repo.
41
+ const b64 = (s) => Buffer.from(s).toString("base64");
42
+ const VERSION_YML = `name: version
43
+
44
+ # Versioning via the bounded-systems mint capability. Validates .release/ intents
45
+ # (fails closed on a malformed one) and previews the next version on every PR.
46
+ # Pinned to an immutable mint commit SHA; bump when mint tags.
47
+ on:
48
+ push:
49
+ branches: [main]
50
+ pull_request:
51
+
52
+ permissions:
53
+ contents: read
54
+
55
+ jobs:
56
+ version:
57
+ uses: bounded-systems/mint/.github/workflows/version.yml@${MINT_REF} # mint
58
+ with:
59
+ ref: ${MINT_REF}
60
+ `;
61
+ const RELEASE_YML = `name: release
62
+
63
+ # Release provenance via the bounded-systems mint capability. On a v<version> tag
64
+ # (cut by \`mint release\`), emits the deterministic in-toto release Statement
65
+ # (tag → version plan → commit), keyless-signs it (cosign/OIDC), and attaches it
66
+ # to the GitHub release. Pinned to an immutable mint commit SHA; bump when mint tags.
67
+ on:
68
+ push:
69
+ tags: ["v*"]
70
+
71
+ permissions:
72
+ contents: write # create / upload to the GitHub release
73
+ id-token: write # OIDC — cosign keyless signing
74
+
75
+ jobs:
76
+ release:
77
+ uses: bounded-systems/mint/.github/workflows/release-provenance.yml@${MINT_REF} # mint
78
+ with:
79
+ ref: ${MINT_REF}
80
+ `;
81
+ const RELEASE_README = `# Release intents
82
+
83
+ This repo uses [@bounded-systems/mint](https://github.com/bounded-systems/mint) for
84
+ versioning. Each PR with a user-facing change drops an intent file here; mint
85
+ resolves the strongest bump and cuts the release deterministically.
86
+
87
+ Format — \`.release/<slug>.md\`:
88
+
89
+ ---
90
+ bump: minor # patch | minor | major
91
+ ---
92
+ short summary of the change (becomes the changelog line)
93
+
94
+ The \`version\` CI job runs \`mint plan\`, which validates every intent and previews
95
+ the next version.
96
+ `;
97
+
98
+ async function putFile(repo, path, content, branch, message) {
99
+ return ghReq(`repos/${org}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, {
100
+ method: "PUT",
101
+ body: { message, content: b64(content), branch },
102
+ });
103
+ }
104
+
105
+ // Open a caller PR adopting mint in one repo. Idempotent-ish: skips if the branch
106
+ // already exists. Returns { repo, pr, url } or null.
107
+ async function openAdoptionPR(repo) {
108
+ const meta = await gh(`repos/${org}/${repo}`);
109
+ const base = meta?.default_branch ?? "main";
110
+ const ref = await gh(`repos/${org}/${repo}/git/ref/heads/${base}`);
111
+ if (!ref?.object?.sha) { console.warn(` ${repo}: no ${base} ref`); return null; }
112
+ const branch = "adopt-mint";
113
+ const made = await ghReq(`repos/${org}/${repo}/git/refs`, { method: "POST", body: { ref: `refs/heads/${branch}`, sha: ref.object.sha } });
114
+ if (!made) { console.warn(` ${repo}: branch exists or create failed — skipping`); return null; }
115
+ await putFile(repo, ".github/workflows/version.yml", VERSION_YML, branch, "chore: adopt @bounded-systems/mint for versioning");
116
+ await putFile(repo, ".github/workflows/release.yml", RELEASE_YML, branch, "chore: adopt @bounded-systems/mint release provenance");
117
+ await putFile(repo, ".release/README.md", RELEASE_README, branch, "chore: add .release intent directory");
118
+ const pr = await ghReq(`repos/${org}/${repo}/pulls`, {
119
+ method: "POST",
120
+ body: { title: "chore: adopt @bounded-systems/mint for versioning", head: branch, base, body: "Adopt [mint](https://github.com/bounded-systems/mint): the reusable version-check (validates `.release/` intents + previews the next bump on every PR) **and** release provenance (on a `v*` tag, emits + keyless-signs the in-toto release Statement and attaches it to the release). Replaces hand-tagging. Org-wide rollout — see bounded-systems/string-audit#43." },
121
+ });
122
+ if (!pr?.number) return null;
123
+ console.log(` ✓ ${repo.padEnd(24)} PR #${pr.number}`);
124
+ return { repo, pr: pr.number, url: pr.html_url, branch };
125
+ }
126
+
127
+ async function fileJson(repo, path) {
128
+ const d = await gh(`repos/${org}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`);
129
+ if (!d?.content) return null;
130
+ try { return JSON.parse(Buffer.from(d.content, "base64").toString("utf8")); } catch { return null; }
131
+ }
132
+
133
+ async function probe(repo) {
134
+ const [release, pkg] = await Promise.all([
135
+ gh(`repos/${org}/${repo}/contents/.release`), // dir listing or null
136
+ fileJson(repo, "package.json"),
137
+ ]);
138
+ const adopted = Array.isArray(release) && release.some((e) => e.name.endsWith(".md"));
139
+ const versioned = pkg != null && typeof pkg.version === "string" && pkg.private !== true;
140
+ if (adopted) return { repo, status: "adopted", version: pkg?.version ?? null };
141
+ if (versioned) return { repo, status: "publishable", version: pkg.version };
142
+ return { repo, status: "na", reason: pkg == null ? "no package.json" : pkg.private ? "private" : "no version" };
143
+ }
144
+
145
+ // Paginate org repos.
146
+ const repos = [];
147
+ for (let page = 1; ; page++) {
148
+ const batch = await gh(`orgs/${org}/repos?per_page=100&page=${page}&sort=full_name`);
149
+ if (!batch?.length) break;
150
+ repos.push(...batch);
151
+ if (batch.length < 100) break;
152
+ }
153
+
154
+ // Probe in batches of 8.
155
+ const probed = [];
156
+ for (let i = 0; i < repos.length; i += 8) {
157
+ probed.push(...await Promise.all(repos.slice(i, i + 8).map((r) => probe(r.name))));
158
+ }
159
+
160
+ const adopted = probed.filter((r) => r.status === "adopted");
161
+ const publishable = probed.filter((r) => r.status === "publishable");
162
+ const na = probed.filter((r) => r.status === "na");
163
+ const report = { org, scanned: repos.length, adopted, publishable, na };
164
+
165
+ // --write: open a caller PR for every publishable-not-adopted repo.
166
+ if (write) {
167
+ console.log(`\n opening caller PRs for ${publishable.length} publishable repo(s) (mint @ ${MINT_REF.slice(0, 8)})\n`);
168
+ const opened = [];
169
+ for (const r of publishable) {
170
+ const pr = await openAdoptionPR(r.repo);
171
+ if (pr) opened.push(pr);
172
+ }
173
+ report.opened = opened;
174
+ console.log(`\n opened ${opened.length}/${publishable.length} PR(s)`);
175
+ }
176
+
177
+ if (jsonOut) {
178
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
179
+ } else {
180
+ const denom = adopted.length + publishable.length;
181
+ const pct = denom ? Math.round((adopted.length / denom) * 100) : 100;
182
+ console.log(`\n mint adoption — ${org}: ${repos.length} repos scanned\n ${"─".repeat(48)}`);
183
+ console.log(` ADOPTED ${adopted.length}`);
184
+ for (const r of adopted) console.log(` ✓ ${r.repo}${r.version ? ` (${r.version})` : ""}`);
185
+ console.log(`\n PUBLISHABLE — should adopt (${publishable.length})`);
186
+ for (const r of publishable) console.log(` · ${r.repo} (${r.version})`);
187
+ console.log(`\n N/A — not a versioned package (${na.length})`);
188
+ console.log(`\n coverage: ${adopted.length}/${denom} versioned packages (${pct}%)\n`);
189
+ }
190
+
191
+ writeFileSync("adoption-report.json", JSON.stringify(report, null, 2));
package/intents.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Intent files: `.release/*.md` — one per change, authored in the PR that makes
2
+ // the change. Format is front-matter + summary body:
3
+ //
4
+ // ---
5
+ // bump: minor
6
+ // ---
7
+ // scan: promote to a verb (CLI + MCP) + shared Zod type contracts
8
+ //
9
+ // Parsed and validated against the Zod `Intent` contract in plan.mjs. No YAML
10
+ // dependency — the front matter is a minimal `key: value` block.
11
+ import { readdir, readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { Intent } from "./plan.mjs";
14
+
15
+ const FRONT_MATTER = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
16
+
17
+ // Parse one intent file's text → validated { bump, summary }. Throws on a missing
18
+ // front-matter block, unknown bump, or empty summary (fail closed — a malformed
19
+ // intent must never silently drop from a release).
20
+ export function parseIntent(text, label = "intent") {
21
+ const m = text.match(FRONT_MATTER);
22
+ if (!m) throw new Error(`${label}: missing front-matter block (--- bump: <kind> ---)`);
23
+ const fields = {};
24
+ for (const line of m[1].split("\n")) {
25
+ const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
26
+ if (kv) fields[kv[1]] = kv[2].trim();
27
+ }
28
+ const summary = m[2].trim();
29
+ return Intent.parse({ bump: fields.bump, summary });
30
+ }
31
+
32
+ // Load every intent in a directory (default `.release/`), skipping README.md and
33
+ // dot/underscore-prefixed templates. Returns [] when the directory is absent.
34
+ export async function loadIntents(dir = ".release") {
35
+ let names;
36
+ try {
37
+ names = await readdir(dir);
38
+ } catch (e) {
39
+ if (e.code === "ENOENT") return [];
40
+ throw e;
41
+ }
42
+ const files = names
43
+ .filter((n) => n.endsWith(".md"))
44
+ .filter((n) => n !== "README.md" && !n.startsWith("_") && !n.startsWith("."))
45
+ .sort();
46
+ const intents = [];
47
+ for (const f of files) {
48
+ const text = await readFile(join(dir, f), "utf8");
49
+ intents.push({ file: join(dir, f), ...parseIntent(text, f) });
50
+ }
51
+ return intents;
52
+ }
package/jsr.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@bounded-systems/mint",
3
+ "version": "0.4.3",
4
+ "license": "PolyForm-Noncommercial-1.0.0",
5
+ "exports": {
6
+ ".": "./plan.mjs",
7
+ "./intents": "./intents.mjs",
8
+ "./release": "./release.mjs"
9
+ },
10
+ "publish": {
11
+ "include": [
12
+ "plan.mjs",
13
+ "intents.mjs",
14
+ "release.mjs",
15
+ "mint.mjs",
16
+ "README.md",
17
+ "CHANGELOG.md",
18
+ "jsr.json"
19
+ ]
20
+ }
21
+ }
package/mint.mjs ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // mint — deterministic versioning CLI. Impurity (clock, filesystem, git) lives
3
+ // here at the edge; plan.mjs stays a pure function.
4
+ //
5
+ // mint plan [--dir .release] [--date YYYY-MM-DD] [--json] preview the next release
6
+ // mint version [--dir .release] [--date YYYY-MM-DD] apply: bump manifest + CHANGELOG, consume intents
7
+ // mint release [--dry-run] [--no-push] [--no-attest] cut the v<version> tag + emit release provenance
8
+ // mint attest [--out <path>] (re)emit the in-toto release Statement (CI signs it)
9
+ //
10
+ // A verbspec-typed CLI/MCP surface (mirroring string-audit's audit.mjs/mcp.mjs)
11
+ // is a follow-up; this skeleton wires the verbs directly against the Zod core.
12
+ import { readFile, writeFile, unlink } from "node:fs/promises";
13
+ import { execFileSync } from "node:child_process";
14
+ import { plan, changelogEntry } from "./plan.mjs";
15
+ import { loadIntents } from "./intents.mjs";
16
+ import { releaseStatement, statementDigest } from "./release.mjs";
17
+
18
+ const git = (...a) => execFileSync("git", a, { encoding: "utf8" }).trim();
19
+ const gitOk = (...a) => { try { execFileSync("git", a, { stdio: "ignore" }); return true; } catch { return false; } };
20
+
21
+ const args = process.argv.slice(2);
22
+ const cmd = args[0];
23
+ const flag = (name) => {
24
+ const i = args.indexOf(name);
25
+ return i >= 0 ? (args[i + 1] ?? "") : null;
26
+ };
27
+ const has = (name) => args.includes(name);
28
+
29
+ const today = () => new Date().toISOString().slice(0, 10); // wall-clock confined to the CLI edge
30
+ const dir = flag("--dir") ?? ".release";
31
+ const date = flag("--date") ?? today();
32
+
33
+ async function readJson(path) {
34
+ return JSON.parse(await readFile(path, "utf8"));
35
+ }
36
+
37
+ async function currentVersion() {
38
+ const pkg = await readJson("package.json");
39
+ if (!pkg.version) throw new Error("package.json has no version field");
40
+ return pkg.version;
41
+ }
42
+
43
+ async function cmdPlan() {
44
+ const intents = await loadIntents(dir);
45
+ const p = plan({ currentVersion: await currentVersion(), intents, date });
46
+ if (has("--json")) {
47
+ process.stdout.write(JSON.stringify(p, null, 2) + "\n");
48
+ return;
49
+ }
50
+ if (p.bump == null) {
51
+ console.log(`mint: no intents in ${dir}/ — nothing to release (stays ${p.currentVersion})`);
52
+ return;
53
+ }
54
+ console.log(`mint plan — ${p.currentVersion} → ${p.nextVersion} (${p.bump}, ${intents.length} intent${intents.length !== 1 ? "s" : ""})\n`);
55
+ console.log(p.entry);
56
+ }
57
+
58
+ async function cmdVersion() {
59
+ const intents = await loadIntents(dir);
60
+ const p = plan({ currentVersion: await currentVersion(), intents, date });
61
+ if (p.bump == null) {
62
+ console.log(`mint: no intents in ${dir}/ — nothing to release`);
63
+ return;
64
+ }
65
+
66
+ // Bump the manifest (+ lockfile if present), preserving 2-space JSON.
67
+ const pkg = await readJson("package.json");
68
+ pkg.version = p.nextVersion;
69
+ await writeFile("package.json", JSON.stringify(pkg, null, 2) + "\n");
70
+ try {
71
+ const lock = await readJson("package-lock.json");
72
+ lock.version = p.nextVersion;
73
+ if (lock.packages?.[""]) lock.packages[""].version = p.nextVersion;
74
+ await writeFile("package-lock.json", JSON.stringify(lock, null, 2) + "\n");
75
+ } catch (e) {
76
+ if (e.code !== "ENOENT") throw e;
77
+ }
78
+ // Keep jsr.json in lockstep (JSR has its own manifest version).
79
+ try {
80
+ const jsr = await readJson("jsr.json");
81
+ jsr.version = p.nextVersion;
82
+ await writeFile("jsr.json", JSON.stringify(jsr, null, 2) + "\n");
83
+ } catch (e) {
84
+ if (e.code !== "ENOENT") throw e;
85
+ }
86
+
87
+ // Prepend the entry to CHANGELOG.md (create with a header if absent).
88
+ let changelog = "";
89
+ try { changelog = await readFile("CHANGELOG.md", "utf8"); }
90
+ catch (e) { if (e.code !== "ENOENT") throw e; changelog = "# Changelog\n"; }
91
+ const head = changelog.startsWith("# ") ? changelog.slice(0, changelog.indexOf("\n") + 1) : "# Changelog\n";
92
+ const rest = changelog.slice(head.length).replace(/^\n+/, "");
93
+ await writeFile("CHANGELOG.md", `${head}\n${p.entry}\n${rest}`.replace(/\n{3,}/g, "\n\n"));
94
+
95
+ // Consume the intents.
96
+ for (const i of intents) if (i.file) await unlink(i.file);
97
+
98
+ console.log(`mint: ${p.currentVersion} → ${p.nextVersion} (${p.bump}). Updated package.json + CHANGELOG.md, consumed ${intents.length} intent(s).`);
99
+ console.log(`Next: commit, then \`mint release\` to cut the tag + emit release provenance.`);
100
+ }
101
+
102
+ // The GitHub Actions OIDC issuer enforced by the bounded-systems keyless flow.
103
+ const OIDC_ISSUER = "https://token.actions.githubusercontent.com";
104
+
105
+ // The CI signing identity, derived from the GitHub Actions environment. Returns
106
+ // null outside CI (no GITHUB_REPOSITORY) — the statement is then UNSIGNED and the
107
+ // release record carries `builder: null`. Mirrors the sites' provenance builder.
108
+ function ciBuilder() {
109
+ const repository = process.env.GITHUB_REPOSITORY;
110
+ if (!repository) return null;
111
+ return {
112
+ repository,
113
+ commit: process.env.GITHUB_SHA ?? "",
114
+ ref: process.env.GITHUB_REF ?? "",
115
+ runId: process.env.GITHUB_RUN_ID ?? "",
116
+ workflowRef: process.env.GITHUB_WORKFLOW_REF ?? "",
117
+ issuer: OIDC_ISSUER,
118
+ };
119
+ }
120
+
121
+ // Assemble the in-toto release Statement from repo state. Impure at the edge
122
+ // (reads package.json + CHANGELOG + git HEAD + CI env); delegates the pure shape
123
+ // to release.mjs. Throws with a CLI-friendly message on a missing changelog entry.
124
+ async function gatherStatement() {
125
+ const pkg = await readJson("package.json");
126
+ const version = pkg.version;
127
+ if (!version) throw new Error("package.json has no version");
128
+ const tag = `v${version}`;
129
+
130
+ let changelog = "";
131
+ try { changelog = await readFile("CHANGELOG.md", "utf8"); } catch { /* none */ }
132
+ const entry = changelogEntry(changelog, version);
133
+ if (!entry) throw new Error(`no CHANGELOG.md entry for ${version} — run \`mint version\` first.`);
134
+
135
+ const commit = git("rev-parse", "HEAD");
136
+ // Producer is mint itself, not the consumer package; pin a ref when CI sets one.
137
+ const producer = `@bounded-systems/mint${process.env.MINT_REF ? `@${process.env.MINT_REF}` : ""}`;
138
+ const stmt = releaseStatement({ version, tag, commit, date, changelog: entry, producer, builder: ciBuilder() });
139
+ return { stmt, tag, entry, version };
140
+ }
141
+
142
+ // (Re)emit the in-toto release Statement to stdout (or --out <path>). Pure +
143
+ // deterministic for a given repo state; CI keyless-signs the emitted file with
144
+ // cosign. Same record `mint release` writes — exposed on its own so release.yml
145
+ // can regenerate it after the tag push and sign it.
146
+ async function cmdAttest() {
147
+ const { stmt, tag } = await gatherStatement();
148
+ const text = JSON.stringify(stmt, null, 2) + "\n";
149
+ const out = flag("--out");
150
+ if (out) {
151
+ await writeFile(out, text);
152
+ console.error(`mint: wrote release statement for ${tag} → ${out} (${statementDigest(stmt).slice(0, 12)})`);
153
+ } else {
154
+ process.stdout.write(text);
155
+ }
156
+ }
157
+
158
+ // Cut the release tag AND emit its provenance. Signs the tag when git signing is
159
+ // configured (else an annotated tag with a warning); the annotation is the
160
+ // changelog entry. Alongside the tag it writes the in-toto release Statement
161
+ // (tag → version plan → commit) to `<tag>.intoto.json` — UNSIGNED locally;
162
+ // keyless-signed by release.yml in CI (cosign, OIDC). --dry-run previews without
163
+ // touching git; --remote sets the push target (default origin); --no-push skips
164
+ // pushing; --no-attest skips the provenance file; --attest <path> overrides it.
165
+ async function cmdRelease() {
166
+ const dryRun = has("--dry-run");
167
+ const noPush = has("--no-push");
168
+ const noAttest = has("--no-attest");
169
+ const remote = flag("--remote") ?? "origin";
170
+
171
+ let gathered;
172
+ try { gathered = await gatherStatement(); }
173
+ catch (e) { console.error(`mint release: ${e.message}`); process.exit(1); }
174
+ const { stmt, tag, entry } = gathered;
175
+ const attestPath = flag("--attest") ?? `${tag}.intoto.json`;
176
+ const stmtText = JSON.stringify(stmt, null, 2) + "\n";
177
+ const signedStatement = stmt.predicate.builder != null; // CI ⇒ keyless-signed downstream
178
+ const signedTag = git("config", "--get", "commit.gpgsign") === "true" || gitOk("config", "--get", "user.signingkey");
179
+ const tagExists = gitOk("rev-parse", tag);
180
+ const dirty = git("status", "--porcelain");
181
+
182
+ // --dry-run is a pure preview: it never touches git, so the mutation guards
183
+ // (clean tree, tag absent) are surfaced as warnings instead of hard failures.
184
+ if (dryRun) {
185
+ console.log(`mint release (dry run): would create ${signedTag ? "signed" : "annotated"} tag ${tag} and ${noPush ? "skip push" : `push to ${remote}`}.`);
186
+ if (!noAttest) console.log(`mint release (dry run): would write release provenance → ${attestPath} (${statementDigest(stmt).slice(0, 12)}, ${signedStatement ? "CI keyless-signs it" : "unsigned — signing is CI-only"}).`);
187
+ if (tagExists) console.log(`mint release (dry run): note — tag ${tag} already exists; a real release would refuse.`);
188
+ if (dirty) console.log("mint release (dry run): note — working tree not clean; a real release would refuse.");
189
+ console.log("");
190
+ console.log(entry.trimEnd());
191
+ if (!noAttest) { console.log("\n--- in-toto release Statement (preview) ---"); process.stdout.write(stmtText); }
192
+ return;
193
+ }
194
+
195
+ // Guards for the real path: tag not already present, clean git tree.
196
+ if (tagExists) { console.error(`mint release: tag ${tag} already exists.`); process.exit(1); }
197
+ if (dirty) { console.error("mint release: working tree not clean — commit the release first."); process.exit(1); }
198
+
199
+ git("tag", signedTag ? "-s" : "-a", tag, "-m", entry);
200
+ console.log(`mint: created ${signedTag ? "signed" : "annotated (unsigned — no git signing key configured)"} tag ${tag}`);
201
+ if (!noAttest) {
202
+ await writeFile(attestPath, stmtText);
203
+ console.log(`mint: wrote release provenance → ${attestPath}${signedStatement ? "" : " (unsigned — keyless signing runs in CI)"}`);
204
+ }
205
+ if (!noPush) { git("push", remote, tag); console.log(`mint: pushed ${tag} to ${remote}`); }
206
+ console.log(`Next: release.yml keyless-signs the statement (cosign, OIDC) + attaches it to the GitHub release. Verify with \`cosign verify-blob\` / \`gh attestation verify\`.`);
207
+ }
208
+
209
+ const COMMANDS = { plan: cmdPlan, version: cmdVersion, release: cmdRelease, attest: cmdAttest };
210
+
211
+ if (!cmd || cmd === "--help" || cmd === "-h" || !COMMANDS[cmd]) {
212
+ const known = Object.keys(COMMANDS).join(" | ");
213
+ console.log(`mint — deterministic versioning\n\n mint <${known}> [--dir .release] [--date YYYY-MM-DD] [--json]\n`);
214
+ process.exit(cmd && !COMMANDS[cmd] ? 1 : 0);
215
+ }
216
+
217
+ await COMMANDS[cmd]();
package/mint.test.mjs ADDED
@@ -0,0 +1,179 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash } from "node:crypto";
4
+ import { plan, resolveBump, renderEntry, changelogEntry } from "./plan.mjs";
5
+ import { parseIntent } from "./intents.mjs";
6
+ import {
7
+ releaseStatement,
8
+ canonicalize,
9
+ dssePAE,
10
+ statementDigest,
11
+ STATEMENT_TYPE,
12
+ RELEASE_PREDICATE_TYPE,
13
+ DSSE_PAYLOAD_TYPE,
14
+ } from "./release.mjs";
15
+
16
+ const CHANGELOG = `# Changelog
17
+
18
+ ## 0.2.0 — 2026-06-24
19
+
20
+ ### Minor
21
+
22
+ - mint release verb + SLSA release workflow
23
+
24
+ ## 0.1.0 — 2026-06-23
25
+
26
+ ### Minor
27
+
28
+ - initial
29
+ `;
30
+
31
+ const date = "2026-06-23";
32
+
33
+ test("bump precedence — the strongest intent wins", () => {
34
+ assert.equal(resolveBump([{ bump: "patch" }, { bump: "minor" }]), "minor");
35
+ assert.equal(resolveBump([{ bump: "minor" }, { bump: "major" }, { bump: "patch" }]), "major");
36
+ assert.equal(resolveBump([{ bump: "patch" }]), "patch");
37
+ assert.equal(resolveBump([]), null);
38
+ });
39
+
40
+ test("semver arithmetic is delegated correctly", () => {
41
+ assert.equal(plan({ currentVersion: "0.6.1", intents: [{ bump: "minor", summary: "x" }], date }).nextVersion, "0.7.0");
42
+ assert.equal(plan({ currentVersion: "0.6.1", intents: [{ bump: "patch", summary: "x" }], date }).nextVersion, "0.6.2");
43
+ assert.equal(plan({ currentVersion: "0.6.1", intents: [{ bump: "major", summary: "x" }], date }).nextVersion, "1.0.0");
44
+ });
45
+
46
+ test("no intents → no release (version unchanged, null bump)", () => {
47
+ const p = plan({ currentVersion: "1.2.3", intents: [], date });
48
+ assert.equal(p.bump, null);
49
+ assert.equal(p.nextVersion, "1.2.3");
50
+ assert.equal(p.entry, null);
51
+ });
52
+
53
+ test("plan is deterministic — same inputs, byte-identical output regardless of intent order", () => {
54
+ const intents = [
55
+ { bump: "patch", summary: "fix b" },
56
+ { bump: "minor", summary: "feat z" },
57
+ { bump: "patch", summary: "fix a" },
58
+ { bump: "minor", summary: "feat a" },
59
+ ];
60
+ const a = plan({ currentVersion: "0.6.1", intents, date });
61
+ const b = plan({ currentVersion: "0.6.1", intents: [...intents].reverse(), date });
62
+ assert.deepEqual(a.entry, b.entry);
63
+ assert.equal(a.nextVersion, b.nextVersion);
64
+ // grouped by kind (major→minor→patch), sorted within group:
65
+ assert.match(a.entry, /### Minor\n\n- feat a\n- feat z\n\n### Patch\n\n- fix a\n- fix b/);
66
+ });
67
+
68
+ test("changelog entry header carries version + injected date", () => {
69
+ const entry = renderEntry({ nextVersion: "0.7.0", date, intents: [{ bump: "minor", summary: "scan verb" }] });
70
+ assert.ok(entry.startsWith("## 0.7.0 — 2026-06-23\n"));
71
+ });
72
+
73
+ test("malformed input fails closed", () => {
74
+ assert.throws(() => plan({ currentVersion: "not-semver", intents: [], date }));
75
+ assert.throws(() => plan({ currentVersion: "1.0.0", intents: [{ bump: "huge", summary: "x" }], date }));
76
+ assert.throws(() => plan({ currentVersion: "1.0.0", intents: [{ bump: "minor", summary: "" }], date }));
77
+ assert.throws(() => plan({ currentVersion: "1.0.0", intents: [], date: "June 23" }));
78
+ });
79
+
80
+ test("changelogEntry extracts one version's section, not the next", () => {
81
+ const e = changelogEntry(CHANGELOG, "0.2.0");
82
+ assert.ok(e.startsWith("## 0.2.0 — 2026-06-24"));
83
+ assert.match(e, /mint release verb/);
84
+ assert.ok(!e.includes("0.1.0"), "must stop before the next heading");
85
+ const old = changelogEntry(CHANGELOG, "0.1.0");
86
+ assert.ok(old.startsWith("## 0.1.0"));
87
+ assert.match(old, /- initial/);
88
+ assert.equal(changelogEntry(CHANGELOG, "9.9.9"), null);
89
+ });
90
+
91
+ test("parseIntent reads front matter + summary, validates the contract", () => {
92
+ const i = parseIntent("---\nbump: minor\n---\nscan: promote to a verb\n");
93
+ assert.deepEqual(i, { bump: "minor", summary: "scan: promote to a verb" });
94
+ assert.throws(() => parseIntent("no front matter here"), /missing front-matter/);
95
+ assert.throws(() => parseIntent("---\nbump: minor\n---\n"), /summary/); // empty body
96
+ });
97
+
98
+ // ── mint release — provenance core (release.mjs) ────────────────────────────
99
+
100
+ const REL = {
101
+ version: "0.3.0",
102
+ tag: "v0.3.0",
103
+ commit: "0123456789abcdef0123456789abcdef01234567",
104
+ date: "2026-06-29",
105
+ changelog: changelogEntry(CHANGELOG, "0.2.0"),
106
+ producer: "@bounded-systems/mint",
107
+ };
108
+
109
+ test("release statement binds tag → version plan → commit (in-toto Statement v1)", () => {
110
+ const s = releaseStatement(REL);
111
+ assert.equal(s._type, STATEMENT_TYPE);
112
+ assert.equal(s._type, "https://in-toto.io/Statement/v1");
113
+ assert.equal(s.predicateType, RELEASE_PREDICATE_TYPE);
114
+ // subject IS the tag, anchored to the commit (in-toto gitCommit digest).
115
+ assert.deepEqual(s.subject, [{ name: "v0.3.0", digest: { gitCommit: REL.commit } }]);
116
+ assert.equal(s.predicate.version, "0.3.0");
117
+ assert.equal(s.predicate.tag, "v0.3.0");
118
+ assert.equal(s.predicate.commit, REL.commit);
119
+ // the version plan is bound by the byte-exact changelog digest.
120
+ assert.equal(s.predicate.plan.changelog, REL.changelog);
121
+ assert.equal(
122
+ s.predicate.plan.digest.sha256,
123
+ createHash("sha256").update(REL.changelog, "utf8").digest("hex"),
124
+ );
125
+ // no builder ⇒ produced locally + unsigned.
126
+ assert.equal(s.predicate.builder, null);
127
+ });
128
+
129
+ test("release statement is deterministic — same inputs, byte-identical record", () => {
130
+ const a = releaseStatement(REL);
131
+ const b = releaseStatement({ ...REL });
132
+ assert.deepEqual(a, b);
133
+ assert.equal(canonicalize(a), canonicalize(b));
134
+ assert.equal(statementDigest(a), statementDigest(b));
135
+ });
136
+
137
+ test("canonicalize is key-order independent (stable digest + DSSE payload)", () => {
138
+ const a = canonicalize({ b: 1, a: { y: 2, x: 3 } });
139
+ const b = canonicalize({ a: { x: 3, y: 2 }, b: 1 });
140
+ assert.equal(a, b);
141
+ assert.equal(a, '{"a":{"x":3,"y":2},"b":1}');
142
+ });
143
+
144
+ test("CI builder is carried through (keyless signing identity)", () => {
145
+ const builder = {
146
+ repository: "bounded-systems/mint",
147
+ commit: REL.commit,
148
+ ref: "refs/tags/v0.3.0",
149
+ runId: "42",
150
+ workflowRef: "bounded-systems/mint/.github/workflows/release.yml@refs/tags/v0.3.0",
151
+ issuer: "https://token.actions.githubusercontent.com",
152
+ };
153
+ const s = releaseStatement({ ...REL, builder });
154
+ assert.deepEqual(s.predicate.builder, builder);
155
+ // local (null builder) and CI (builder) records differ — signing is recorded.
156
+ assert.notEqual(statementDigest(s), statementDigest(releaseStatement(REL)));
157
+ });
158
+
159
+ test("tag format is enforced — only v<semver> is accepted", () => {
160
+ assert.throws(() => releaseStatement({ ...REL, tag: "0.3.0" }), /tag must be v<semver>/);
161
+ assert.throws(() => releaseStatement({ ...REL, tag: "release-0.3.0" }), /tag must be v<semver>/);
162
+ assert.throws(() => releaseStatement({ ...REL, tag: "v0.3" }), /tag must be v<semver>/);
163
+ // prerelease + build metadata are valid semver tags.
164
+ assert.equal(releaseStatement({ ...REL, tag: "v0.3.0-rc.1" }).predicate.tag, "v0.3.0-rc.1");
165
+ });
166
+
167
+ test("release statement fails closed on malformed input", () => {
168
+ assert.throws(() => releaseStatement({ ...REL, commit: "nothex!" }), /git object id/);
169
+ assert.throws(() => releaseStatement({ ...REL, date: "June 29" }), /YYYY-MM-DD/);
170
+ assert.throws(() => releaseStatement({ ...REL, changelog: "" }), /changelog/);
171
+ });
172
+
173
+ test("DSSE pre-authentication encoding wraps the canonical statement", () => {
174
+ const s = releaseStatement(REL);
175
+ const pae = dssePAE(s).toString("utf8");
176
+ const payload = canonicalize(s);
177
+ assert.ok(pae.startsWith(`DSSEv1 ${DSSE_PAYLOAD_TYPE.length} ${DSSE_PAYLOAD_TYPE} ${Buffer.byteLength(payload)} `));
178
+ assert.ok(pae.endsWith(payload));
179
+ });