@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/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
|
+
}
|