@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/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@bounded-systems/mint",
3
+ "version": "0.4.3",
4
+ "description": "Deterministic versioning capability — intent files in, signed release out. A seam over semver: own the flow, delegate the arithmetic.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mint": "mint.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./plan.mjs",
11
+ "./intents": "./intents.mjs",
12
+ "./release": "./release.mjs"
13
+ },
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "plan": "node mint.mjs plan"
17
+ },
18
+ "license": "PolyForm-Noncommercial-1.0.0",
19
+ "dependencies": {
20
+ "semver": "^7.6.0",
21
+ "zod": "^4.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0"
25
+ }
26
+ }
package/plan.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // The deterministic core of mint.
2
+ //
3
+ // plan() is a PURE function of (currentVersion, intents, date): no filesystem,
4
+ // no Date.now(), no randomness. The same inputs always produce the same next
5
+ // version and the same changelog entry — sha in → sha out. Version arithmetic is
6
+ // delegated to `semver` (the proven core); mint owns the assembly + rendering.
7
+ import semver from "semver";
8
+ import { z } from "zod";
9
+
10
+ // Bump precedence — the strongest intent wins. Opinionated and total: every
11
+ // release resolves to exactly one bump kind, no configuration.
12
+ const BUMP_RANK = { patch: 0, minor: 1, major: 2 };
13
+ const RANK_BUMP = ["patch", "minor", "major"];
14
+
15
+ export const Bump = z.enum(["patch", "minor", "major"]);
16
+ export const Intent = z.object({
17
+ bump: Bump,
18
+ summary: z.string().trim().min(1, "intent summary must not be empty"),
19
+ });
20
+
21
+ const PlanInput = z.object({
22
+ currentVersion: z.string().refine((v) => semver.valid(v) != null, "currentVersion must be valid semver"),
23
+ intents: z.array(Intent),
24
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD (injected, never wall-clock)"),
25
+ });
26
+
27
+ // Resolve the set of intents to a single bump kind (the max), deterministically.
28
+ export function resolveBump(intents) {
29
+ let rank = -1;
30
+ for (const { bump } of intents) rank = Math.max(rank, BUMP_RANK[bump]);
31
+ return rank < 0 ? null : RANK_BUMP[rank];
32
+ }
33
+
34
+ // Render a deterministic changelog entry. Intents are grouped by kind (major →
35
+ // minor → patch) and sorted alphabetically within each group, so file read order
36
+ // never affects the output.
37
+ export function renderEntry({ nextVersion, date, intents }) {
38
+ const SECTIONS = [
39
+ ["major", "Major"],
40
+ ["minor", "Minor"],
41
+ ["patch", "Patch"],
42
+ ];
43
+ const lines = [`## ${nextVersion} — ${date}`, ""];
44
+ for (const [kind, heading] of SECTIONS) {
45
+ const summaries = intents
46
+ .filter((i) => i.bump === kind)
47
+ .map((i) => i.summary.trim())
48
+ .sort();
49
+ if (!summaries.length) continue;
50
+ lines.push(`### ${heading}`, "");
51
+ for (const s of summaries) lines.push(`- ${s}`);
52
+ lines.push("");
53
+ }
54
+ return lines.join("\n").trimEnd() + "\n";
55
+ }
56
+
57
+ // Extract the changelog section for a version: the `## <version> …` heading and
58
+ // everything up to the next `## ` heading (or EOF). Pure; returns null if absent.
59
+ // Used by `mint release` to derive the signed tag's annotation from the changelog.
60
+ export function changelogEntry(changelog, version) {
61
+ const lines = changelog.split("\n");
62
+ const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const head = new RegExp(`^## ${esc}(\\s|$)`);
64
+ const start = lines.findIndex((l) => head.test(l));
65
+ if (start < 0) return null;
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i++) {
68
+ if (lines[i].startsWith("## ")) { end = i; break; }
69
+ }
70
+ return lines.slice(start, end).join("\n").trim() + "\n";
71
+ }
72
+
73
+ // Pure plan: (currentVersion, intents, date) → release plan. Throws (via Zod) on
74
+ // malformed input; returns null bump when there are no intents (nothing to release).
75
+ export function plan(input) {
76
+ const { currentVersion, intents, date } = PlanInput.parse(input);
77
+ const bump = resolveBump(intents);
78
+ if (bump == null) {
79
+ return { currentVersion, nextVersion: currentVersion, bump: null, date, entry: null, intents: [] };
80
+ }
81
+ const nextVersion = semver.inc(currentVersion, bump);
82
+ return {
83
+ currentVersion,
84
+ nextVersion,
85
+ bump,
86
+ date,
87
+ entry: renderEntry({ nextVersion, date, intents }),
88
+ intents,
89
+ };
90
+ }
package/release.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // release.mjs — the pure provenance core of `mint release`.
2
+ //
3
+ // releaseStatement() is a PURE function of (version, tag, commit, date,
4
+ // changelog, producer, builder): no filesystem, no clock, no randomness. The
5
+ // same inputs always produce a byte-identical in-toto Statement — sha in → sha
6
+ // out, like plan(). It binds three things into one signable record:
7
+ //
8
+ // tag → version plan → commit
9
+ //
10
+ // i.e. "this annotated tag names exactly this version, whose changelog is the
11
+ // byte-exact output mint's deterministic plan() rendered, cut at this commit."
12
+ //
13
+ // SHAPE: an in-toto Statement v1 + DSSE pre-authentication encoding — the SAME
14
+ // Statement/DSSE/keyless-Sigstore shape the bounded-systems sites and
15
+ // @bounded-systems/verify already produce and verify, so a mint release record
16
+ // verifies with the SAME tooling (`cosign verify-blob`, sigstore-js). The
17
+ // constants below are deliberately the ones @bounded-systems/anchored-chain's
18
+ // in-toto module pins, so the artifacts are interoperable.
19
+ //
20
+ // WHY NOT depend on @bounded-systems/anchored-chain directly: it HAS an in-toto
21
+ // module (src/in-toto.ts) but it is Phase-0 and deliberately NOT re-exported
22
+ // from its public surface (pinned by import-surface.test.ts); it is bun/
23
+ // TypeScript with a `tsc` build step and an @bounded-systems/cas dependency; and
24
+ // its predicate models a *derivation chain*, not a release. mint stays a
25
+ // zero-build node ESM package, so it mirrors the shape here and can adopt
26
+ // anchored-chain's Signer/Verifier once that surface goes public — no churn at
27
+ // the boundary, because the bytes already match.
28
+ import { createHash } from "node:crypto";
29
+ import { z } from "zod";
30
+
31
+ /** Standard in-toto Statement v1 type URI (matches anchored-chain). */
32
+ export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
33
+ /** mint's release predicate type URI. */
34
+ export const RELEASE_PREDICATE_TYPE = "https://bounded.tools/mint/Release/v0.1";
35
+ /** DSSE payload type for in-toto Statement JSON (matches anchored-chain). */
36
+ export const DSSE_PAYLOAD_TYPE = "application/vnd.in-toto+json";
37
+
38
+ const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");
39
+
40
+ // Canonical JSON: object keys sorted recursively, no insignificant whitespace —
41
+ // the same value serialises to the same bytes regardless of key insertion order,
42
+ // so the digest and the DSSE payload are stable. (Mirrors anchored-chain's
43
+ // canonicalJson contract.)
44
+ export function canonicalize(value) {
45
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
46
+ if (value && typeof value === "object") {
47
+ const keys = Object.keys(value).sort();
48
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(",")}}`;
49
+ }
50
+ return JSON.stringify(value ?? null);
51
+ }
52
+
53
+ // v<semver> — the only tag shape mint cuts. Kept in lockstep with `v${version}`
54
+ // in mint.mjs so a malformed version can never become a malformed tag silently.
55
+ const Tag = z
56
+ .string()
57
+ .regex(/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, "tag must be v<semver>");
58
+ // A git object id (sha1 today, sha256 under SHA-256 repos), or its short form.
59
+ const GitObjectId = z.string().regex(/^[0-9a-f]{7,64}$/, "commit must be a hex git object id");
60
+
61
+ // The CI signing identity (keyless OIDC). Present only in CI; null locally.
62
+ // Mirrors the `builder` block the bounded-systems sites publish in provenance.json.
63
+ export const Builder = z.object({
64
+ repository: z.string(),
65
+ commit: z.string(),
66
+ ref: z.string(),
67
+ runId: z.string(),
68
+ workflowRef: z.string(),
69
+ issuer: z.string(),
70
+ });
71
+
72
+ export const ReleaseInput = z.object({
73
+ version: z.string().min(1),
74
+ tag: Tag,
75
+ commit: GitObjectId,
76
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD (injected, never wall-clock)"),
77
+ changelog: z.string().min(1, "changelog entry must not be empty (run `mint version` first)"),
78
+ producer: z.string().min(1),
79
+ builder: Builder.nullable().default(null),
80
+ });
81
+
82
+ /**
83
+ * Pure: build the in-toto Statement v1 binding tag → version plan → commit.
84
+ * Throws (via Zod) on any malformed input — a release record is fail-closed.
85
+ */
86
+ export function releaseStatement(input) {
87
+ const { version, tag, commit, date, changelog, producer, builder } = ReleaseInput.parse(input);
88
+ return {
89
+ _type: STATEMENT_TYPE,
90
+ // The subject IS the tag, anchored to the commit it points at (in-toto's
91
+ // standard `gitCommit` digest algorithm).
92
+ subject: [{ name: tag, digest: { gitCommit: commit } }],
93
+ predicateType: RELEASE_PREDICATE_TYPE,
94
+ predicate: {
95
+ version,
96
+ tag,
97
+ commit,
98
+ date,
99
+ producer,
100
+ // The version plan, bound by the byte-exact changelog entry mint rendered.
101
+ // Re-deriving plan() over the same intents reproduces this digest — that is
102
+ // the "version plan → tag" link, machine-checkable offline.
103
+ plan: {
104
+ changelog,
105
+ digest: { sha256: sha256hex(changelog) },
106
+ },
107
+ // null ⇒ produced locally and UNSIGNED; signing is CI-only (keyless OIDC).
108
+ builder: builder ?? null,
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * DSSE pre-authentication encoding (PAE) over the canonical Statement — the exact
115
+ * bytes a DSSE/Sigstore signer signs. Same scheme as anchored-chain, so a mint
116
+ * release envelope is a well-formed DSSE envelope.
117
+ */
118
+ export function dssePAE(statement) {
119
+ const payload = Buffer.from(canonicalize(statement), "utf8");
120
+ const t = DSSE_PAYLOAD_TYPE;
121
+ return Buffer.concat([
122
+ Buffer.from(`DSSEv1 ${t.length} ${t} ${payload.length} `, "utf8"),
123
+ payload,
124
+ ]);
125
+ }
126
+
127
+ /** sha256 (hex) of the canonical Statement — a stable id for the release record. */
128
+ export function statementDigest(statement) {
129
+ return sha256hex(canonicalize(statement));
130
+ }