@akira-tl/forgerelay 0.4.5 → 0.4.7

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.
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const PROOF_VERSION = 1;
8
+ const PROOF_RELATIVE_PATH = join("forgerelay", "release-proof.json");
9
+
10
+ function fail(message) {
11
+ console.error(`Release proof failed: ${message}`);
12
+ process.exitCode = 1;
13
+ }
14
+
15
+ function git(args, options = {}) {
16
+ return execFileSync("git", args, {
17
+ cwd: process.cwd(),
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ ...options,
21
+ }).trim();
22
+ }
23
+
24
+ function packageVersion() {
25
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
26
+ if (typeof pkg.version !== "string" || !pkg.version) {
27
+ throw new Error("package.json must contain a version string");
28
+ }
29
+ return pkg.version;
30
+ }
31
+
32
+ function gitDir() {
33
+ const path = git(["rev-parse", "--git-dir"]);
34
+ return path.startsWith("/") ? path : join(process.cwd(), path);
35
+ }
36
+
37
+ function proofPath() {
38
+ return process.env.FORGERELAY_RELEASE_PROOF_PATH ?? join(gitDir(), PROOF_RELATIVE_PATH);
39
+ }
40
+
41
+ function currentHead() {
42
+ return git(["rev-parse", "HEAD"]);
43
+ }
44
+
45
+ function assertReleaseTreeClean() {
46
+ const status = git(["status", "--porcelain", "--untracked-files=all"]);
47
+ if (status) {
48
+ throw new Error("working tree differs from HEAD or contains untracked files; commit or remove every release input before running release:verify");
49
+ }
50
+ }
51
+
52
+ function writeProof() {
53
+ assertReleaseTreeClean();
54
+ const proof = {
55
+ proofVersion: PROOF_VERSION,
56
+ head: currentHead(),
57
+ packageVersion: packageVersion(),
58
+ verifiedAt: new Date().toISOString(),
59
+ };
60
+ const path = proofPath();
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ writeFileSync(path, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
63
+ console.log(`Release verification proof recorded for ${proof.head.slice(0, 12)} (${proof.packageVersion}).`);
64
+ }
65
+
66
+ function readProof() {
67
+ const path = proofPath();
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(readFileSync(path, "utf8"));
71
+ } catch (error) {
72
+ const detail = error instanceof Error ? error.message : String(error);
73
+ throw new Error(`no valid local release proof at ${path}: ${detail}. Run npm run release:verify on the committed release HEAD first`);
74
+ }
75
+ if (
76
+ parsed?.proofVersion !== PROOF_VERSION ||
77
+ typeof parsed?.head !== "string" ||
78
+ typeof parsed?.packageVersion !== "string" ||
79
+ typeof parsed?.verifiedAt !== "string"
80
+ ) {
81
+ throw new Error(`invalid release proof format at ${path}; run npm run release:verify again`);
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ function hookTag() {
87
+ let payload;
88
+ try {
89
+ payload = JSON.parse(process.env.FORGERELAY_HOOK_PAYLOAD ?? "{}");
90
+ } catch {
91
+ throw new Error("FORGERELAY_HOOK_PAYLOAD is not valid JSON");
92
+ }
93
+ const command = typeof payload.command === "string" ? payload.command : "";
94
+ const match = /git\s+push\s+origin\s+(v\d+\.\d+\.\d+)/.exec(command);
95
+ if (!match?.[1]) {
96
+ throw new Error("release Hook payload does not contain a stable tag push");
97
+ }
98
+ return match[1];
99
+ }
100
+
101
+ function checkHookProof() {
102
+ assertReleaseTreeClean();
103
+ const proof = readProof();
104
+ const head = currentHead();
105
+ const version = packageVersion();
106
+ const tag = hookTag();
107
+ const expectedTag = `v${version}`;
108
+
109
+ if (proof.head !== head) {
110
+ throw new Error(`release proof is for ${proof.head.slice(0, 12)}, but current HEAD is ${head.slice(0, 12)}; rerun npm run release:verify`);
111
+ }
112
+ if (proof.packageVersion !== version) {
113
+ throw new Error(`release proof is for package ${proof.packageVersion}, but package.json is ${version}; rerun npm run release:verify`);
114
+ }
115
+ if (tag !== expectedTag) {
116
+ throw new Error(`tag ${tag} does not match package version ${version}; expected ${expectedTag}`);
117
+ }
118
+
119
+ let tagHead;
120
+ try {
121
+ tagHead = git(["rev-parse", `${tag}^{commit}`]);
122
+ } catch {
123
+ throw new Error(`local tag ${tag} does not exist or does not resolve to a commit`);
124
+ }
125
+ if (tagHead !== head) {
126
+ throw new Error(`tag ${tag} points to ${tagHead.slice(0, 12)}, but verified HEAD is ${head.slice(0, 12)}`);
127
+ }
128
+
129
+ console.log(`Release proof OK: ${tag} -> ${head.slice(0, 12)} (${proof.verifiedAt}).`);
130
+ }
131
+
132
+ const action = process.argv[2];
133
+ try {
134
+ if (action === "write") writeProof();
135
+ else if (action === "check-hook") checkHookProof();
136
+ else throw new Error("usage: node scripts/release-proof.mjs <write|check-hook>");
137
+ } catch (error) {
138
+ fail(error instanceof Error ? error.message : String(error));
139
+ }
@@ -0,0 +1,104 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const script = fileURLToPath(new URL("./release-proof.mjs", import.meta.url));
12
+
13
+ async function git(cwd, args) {
14
+ await execFileAsync("git", args, { cwd });
15
+ }
16
+
17
+ async function runProof(cwd, action, env = {}) {
18
+ return execFileAsync(process.execPath, [script, action], {
19
+ cwd,
20
+ env: { ...process.env, ...env },
21
+ });
22
+ }
23
+
24
+ test("release proof binds a successful local verification to the exact tag HEAD", async (t) => {
25
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
26
+ t.after(() => rm(root, { recursive: true, force: true }));
27
+ await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.2.3" }) + "\n");
28
+ await writeFile(join(root, "tracked.txt"), "verified\n");
29
+ await git(root, ["init"]);
30
+ await git(root, ["config", "user.email", "proof@example.com"]);
31
+ await git(root, ["config", "user.name", "Release Proof Test"]);
32
+ await git(root, ["add", "."]);
33
+ await git(root, ["commit", "-m", "release 1.2.3"]);
34
+
35
+ const written = await runProof(root, "write");
36
+ assert.match(written.stdout, /Release verification proof recorded/);
37
+ await git(root, ["tag", "v1.2.3"]);
38
+
39
+ const checked = await runProof(root, "check-hook", {
40
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
41
+ });
42
+ assert.match(checked.stdout, /Release proof OK: v1\.2\.3/);
43
+
44
+ await writeFile(join(root, "tracked.txt"), "changed after verification\n");
45
+ await assert.rejects(
46
+ () => runProof(root, "check-hook", {
47
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
48
+ }),
49
+ (error) => {
50
+ assert.match(String(error.stderr ?? error), /working tree differs from HEAD/);
51
+ return true;
52
+ },
53
+ );
54
+
55
+ await writeFile(join(root, "tracked.txt"), "verified\n");
56
+ await writeFile(join(root, "untracked.txt"), "not in the release commit\n");
57
+ await assert.rejects(
58
+ () => runProof(root, "check-hook", {
59
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
60
+ }),
61
+ (error) => {
62
+ assert.match(String(error.stderr ?? error), /contains untracked files/);
63
+ return true;
64
+ },
65
+ );
66
+ await rm(join(root, "untracked.txt"));
67
+ await git(root, ["add", "tracked.txt"]);
68
+ await git(root, ["commit", "--allow-empty", "-m", "new head"]);
69
+ await assert.rejects(
70
+ () => runProof(root, "check-hook", {
71
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
72
+ }),
73
+ (error) => {
74
+ assert.match(String(error.stderr ?? error), /release proof is for .* current HEAD is/);
75
+ return true;
76
+ },
77
+ );
78
+ });
79
+
80
+ test("release proof rejects a mismatched tag without invoking a remote", async (t) => {
81
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
82
+ t.after(() => rm(root, { recursive: true, force: true }));
83
+ await writeFile(join(root, "package.json"), JSON.stringify({ version: "2.0.0" }) + "\n");
84
+ await git(root, ["init"]);
85
+ await git(root, ["config", "user.email", "proof@example.com"]);
86
+ await git(root, ["config", "user.name", "Release Proof Test"]);
87
+ await git(root, ["add", "."]);
88
+ await git(root, ["commit", "-m", "release 2.0.0"]);
89
+ await runProof(root, "write");
90
+ await git(root, ["tag", "v2.0.0"]);
91
+
92
+ await assert.rejects(
93
+ () => runProof(root, "check-hook", {
94
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v2.0.1" }),
95
+ }),
96
+ (error) => {
97
+ assert.match(String(error.stderr ?? error), /tag v2\.0\.1 does not match package version 2\.0\.0/);
98
+ return true;
99
+ },
100
+ );
101
+
102
+ const proof = JSON.parse(await readFile(join(root, ".git", "forgerelay", "release-proof.json"), "utf8"));
103
+ assert.equal(proof.packageVersion, "2.0.0");
104
+ });
@@ -162,7 +162,7 @@ async function prepareRelease(state, nextVersion, dryRun) {
162
162
 
163
163
  console.log(`${state.pkg.version} -> ${nextVersion}`);
164
164
  console.log("updated package.json, package-lock.json, and CHANGELOG.md");
165
- console.log("next: review the diff, run npm run release:verify, commit, then push the matching vX.Y.Z tag");
165
+ console.log("next: review the diff, commit the release-ready tree, run npm run release:verify on that clean HEAD, then push the matching vX.Y.Z tag");
166
166
  }
167
167
 
168
168
  function promoteUnreleased(changelog, nextVersion) {