@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/.github/workflows/ci.yml +20 -0
- package/.github/workflows/release-provenance.yml +81 -0
- package/.github/workflows/release.yml +125 -0
- package/.github/workflows/version.yml +48 -0
- package/.release/README.md +18 -0
- package/CHANGELOG.md +55 -0
- package/README.md +176 -0
- package/adoption.mjs +191 -0
- package/intents.mjs +52 -0
- package/jsr.json +21 -0
- package/mint.mjs +217 -0
- package/mint.test.mjs +179 -0
- package/package.json +26 -0
- package/plan.mjs +90 -0
- package/release.mjs +130 -0
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
|
+
});
|