@akira-tl/forgerelay 0.6.0 → 0.6.1
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/CHANGELOG.md +19 -0
- package/README.md +9 -12
- package/dist/activity/mcp-query-tools.js +47 -2
- package/dist/cli.js +181 -4
- package/dist/config.js +4 -1
- package/dist/oauth/router.js +21 -1
- package/dist/oauth-provider.js +32 -4
- package/dist/oauth-store.js +9 -0
- package/dist/remote-auth.js +110 -0
- package/dist/remote-transport.js +196 -0
- package/dist/remote-workspace-relay.js +457 -0
- package/dist/server.js +189 -15
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +3 -3
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/release/pack.mjs +36 -0
- package/scripts/release/publish.mjs +157 -0
- package/scripts/release/release-gate.test.mjs +73 -16
- package/scripts/release-parity.mjs +5 -13
- package/scripts/release-proof.mjs +28 -19
- package/scripts/release-proof.test.mjs +32 -11
- package/scripts/release-version.mjs +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const npmCli = process.env.npm_execpath;
|
|
8
|
+
if (!npmCli) {
|
|
9
|
+
throw new Error("release:pack must be launched through npm so npm_execpath is available");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const outputDir = resolve(process.cwd(), process.env.RELEASE_ARTIFACT_DIR ?? ".release-artifacts");
|
|
13
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
14
|
+
mkdirSync(outputDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const result = spawnSync(
|
|
17
|
+
process.execPath,
|
|
18
|
+
[npmCli, "pack", "--pack-destination", outputDir],
|
|
19
|
+
{
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdio: "inherit",
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
shell: false,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
if (result.error) throw result.error;
|
|
28
|
+
if (result.status !== 0) {
|
|
29
|
+
throw new Error(`npm pack failed with exit ${result.status ?? "unknown"}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const packages = readdirSync(outputDir).filter((name) => name.endsWith(".tgz"));
|
|
33
|
+
if (packages.length !== 1) {
|
|
34
|
+
throw new Error(`release:pack expected exactly one .tgz artifact, found ${packages.length}`);
|
|
35
|
+
}
|
|
36
|
+
console.log(`Verified npm artifact: ${packages[0]}`);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
9
|
+
const repository = "Akira-TL/forgerelay";
|
|
10
|
+
const releaseTag = requiredEnv("RELEASE_TAG");
|
|
11
|
+
const packageDir = resolve(repoRoot, process.env.RELEASE_PACKAGE_DIR ?? ".release-package");
|
|
12
|
+
const npmCli = process.env.npm_execpath;
|
|
13
|
+
if (!npmCli) {
|
|
14
|
+
throw new Error("release:publish must be launched through npm so npm_execpath is available");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
|
|
18
|
+
const expectedTag = `v${pkg.version}`;
|
|
19
|
+
if (releaseTag !== expectedTag) {
|
|
20
|
+
throw new Error(`release tag ${releaseTag} does not match package version ${pkg.version}; expected ${expectedTag}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const head = git(["rev-parse", "HEAD"]);
|
|
24
|
+
const tagHead = git(["rev-parse", `${releaseTag}^{commit}`]);
|
|
25
|
+
if (head !== tagHead) {
|
|
26
|
+
throw new Error(`checked-out HEAD ${head.slice(0, 12)} does not match ${releaseTag} at ${tagHead.slice(0, 12)}`);
|
|
27
|
+
}
|
|
28
|
+
if (!gitSucceeds(["merge-base", "--is-ancestor", tagHead, "origin/main"])) {
|
|
29
|
+
throw new Error(`${releaseTag} does not point to a commit contained in origin/main`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const packages = readdirSync(packageDir).filter((name) => name.endsWith(".tgz"));
|
|
33
|
+
if (packages.length !== 1) {
|
|
34
|
+
throw new Error(`release package directory must contain exactly one .tgz artifact, found ${packages.length}`);
|
|
35
|
+
}
|
|
36
|
+
const packagePath = join(packageDir, packages[0]);
|
|
37
|
+
const expectedPackageName = `${pkg.name.replace(/^@/, "").replaceAll("/", "-")}-${pkg.version}.tgz`;
|
|
38
|
+
if (basename(packagePath) !== expectedPackageName) {
|
|
39
|
+
throw new Error(`unexpected release artifact ${basename(packagePath)}; expected ${expectedPackageName}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const npmTag = pkg.version.includes("-rc.") ? "next" : "latest";
|
|
43
|
+
const packageSpec = `${pkg.name}@${pkg.version}`;
|
|
44
|
+
if (npmPackageExists(packageSpec)) {
|
|
45
|
+
console.log(`${packageSpec} is already published; leaving npm unchanged.`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`Publishing verified artifact ${basename(packagePath)} with npm tag ${npmTag}.`);
|
|
48
|
+
const publishEnv = { ...process.env };
|
|
49
|
+
if (!publishEnv.NODE_AUTH_TOKEN) delete publishEnv.NODE_AUTH_TOKEN;
|
|
50
|
+
runNpm(["publish", packagePath, "--access", "public", "--tag", npmTag], "npm publish", publishEnv);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (ghReleaseExists(releaseTag)) {
|
|
54
|
+
console.log(`GitHub Release ${releaseTag} already exists; leaving it unchanged.`);
|
|
55
|
+
} else {
|
|
56
|
+
const notesPath = prepareReleaseNotes(releaseTag);
|
|
57
|
+
const args = [
|
|
58
|
+
"release",
|
|
59
|
+
"create",
|
|
60
|
+
releaseTag,
|
|
61
|
+
"--repo",
|
|
62
|
+
repository,
|
|
63
|
+
"--verify-tag",
|
|
64
|
+
"--title",
|
|
65
|
+
releaseTag,
|
|
66
|
+
"--notes-file",
|
|
67
|
+
notesPath,
|
|
68
|
+
];
|
|
69
|
+
if (npmTag === "next") args.push("--prerelease");
|
|
70
|
+
run("gh", args, "GitHub Release");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requiredEnv(name) {
|
|
74
|
+
const value = process.env[name]?.trim();
|
|
75
|
+
if (!value) throw new Error(`${name} is required`);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function git(args) {
|
|
80
|
+
return execFileSync("git", args, {
|
|
81
|
+
cwd: repoRoot,
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
}).trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function gitSucceeds(args) {
|
|
88
|
+
const result = spawnSync("git", args, {
|
|
89
|
+
cwd: repoRoot,
|
|
90
|
+
stdio: "ignore",
|
|
91
|
+
windowsHide: true,
|
|
92
|
+
shell: false,
|
|
93
|
+
});
|
|
94
|
+
if (result.error) throw result.error;
|
|
95
|
+
return result.status === 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function npmPackageExists(spec) {
|
|
99
|
+
const result = spawnSync(process.execPath, [npmCli, "view", spec, "version", "--json"], {
|
|
100
|
+
cwd: repoRoot,
|
|
101
|
+
env: process.env,
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
shell: false,
|
|
105
|
+
});
|
|
106
|
+
if (result.error) throw result.error;
|
|
107
|
+
if (result.status === 0) return true;
|
|
108
|
+
const detail = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
109
|
+
if (/E404|404 Not Found/i.test(detail)) return false;
|
|
110
|
+
throw new Error(`npm view failed while checking ${spec}: ${detail.trim() || `exit ${result.status ?? "unknown"}`}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runNpm(args, label, env = process.env) {
|
|
114
|
+
run(process.execPath, [npmCli, ...args], label, env);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ghReleaseExists(tag) {
|
|
118
|
+
const result = spawnSync("gh", ["release", "view", tag, "--repo", repository], {
|
|
119
|
+
cwd: repoRoot,
|
|
120
|
+
env: process.env,
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
shell: false,
|
|
124
|
+
});
|
|
125
|
+
if (result.error) throw result.error;
|
|
126
|
+
return result.status === 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function prepareReleaseNotes(tag) {
|
|
130
|
+
const manualNotes = join(repoRoot, "docs", "releases", `${tag}.md`);
|
|
131
|
+
const notes = existsSync(manualNotes)
|
|
132
|
+
? readFileSync(manualNotes, "utf8")
|
|
133
|
+
: execFileSync(process.execPath, ["scripts/release-version.mjs", "notes", tag], {
|
|
134
|
+
cwd: repoRoot,
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
137
|
+
});
|
|
138
|
+
const tempRoot = resolve(process.env.RUNNER_TEMP ?? join(repoRoot, ".forgerelay-debug"));
|
|
139
|
+
mkdirSync(tempRoot, { recursive: true });
|
|
140
|
+
const notesPath = join(tempRoot, `release-notes-${tag}.md`);
|
|
141
|
+
writeFileSync(notesPath, notes.endsWith("\n") ? notes : `${notes}\n`);
|
|
142
|
+
return notesPath;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(command, args, label, env = process.env) {
|
|
146
|
+
const result = spawnSync(command, args, {
|
|
147
|
+
cwd: repoRoot,
|
|
148
|
+
env,
|
|
149
|
+
stdio: "inherit",
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
shell: false,
|
|
152
|
+
});
|
|
153
|
+
if (result.error) throw result.error;
|
|
154
|
+
if (result.status !== 0) {
|
|
155
|
+
throw new Error(`${label} failed with exit ${result.status ?? "unknown"}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -10,14 +10,25 @@ async function readJson(relativePath) {
|
|
|
10
10
|
return JSON.parse(await readFile(resolve(repoRoot, relativePath), "utf8"));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
test("release tag Hook is a fast
|
|
14
|
-
const hook = await readJson(".forgerelay/hooks/release-tag-
|
|
13
|
+
test("release tag Hook is a fast repository-state gate for common origin tag push forms", async () => {
|
|
14
|
+
const hook = await readJson(".forgerelay/hooks/release-tag-gate.json");
|
|
15
15
|
assert.equal(hook.event, "BeforeTool");
|
|
16
16
|
assert.equal(hook.command, "node scripts/release-proof.mjs check-hook");
|
|
17
17
|
assert.ok(hook.timeoutSeconds <= 30);
|
|
18
|
+
|
|
19
|
+
const matcher = new RegExp(hook.matcher.commandRegex);
|
|
20
|
+
for (const command of [
|
|
21
|
+
"git push origin v1.2.3",
|
|
22
|
+
"git push --atomic origin v1.2.3",
|
|
23
|
+
"git push origin refs/tags/v1.2.3",
|
|
24
|
+
"git status && git push origin tag v1.2.3 && echo done",
|
|
25
|
+
]) {
|
|
26
|
+
assert.match(command, matcher);
|
|
27
|
+
}
|
|
28
|
+
assert.doesNotMatch("git push origin main", matcher);
|
|
18
29
|
});
|
|
19
30
|
|
|
20
|
-
test("release:verify records proof only after the cloud-equivalent parity gate", async () => {
|
|
31
|
+
test("optional release:verify records proof only after the cloud-equivalent parity gate", async () => {
|
|
21
32
|
const pkg = await readJson("package.json");
|
|
22
33
|
assert.equal(
|
|
23
34
|
pkg.scripts["release:verify"],
|
|
@@ -25,19 +36,65 @@ test("release:verify records proof only after the cloud-equivalent parity gate",
|
|
|
25
36
|
);
|
|
26
37
|
});
|
|
27
38
|
|
|
28
|
-
test("
|
|
29
|
-
const
|
|
30
|
-
assert.match(
|
|
31
|
-
assert.match(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
test("cross-platform cloud CI delegates to one shell-free verification entrypoint", async () => {
|
|
40
|
+
const workflow = await readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
|
41
|
+
assert.match(workflow, /node-version-file:\s*\.nvmrc/);
|
|
42
|
+
assert.match(workflow, /run:\s*npm ci/);
|
|
43
|
+
assert.match(workflow, /run:\s*npm run ci:verify/);
|
|
44
|
+
assert.doesNotMatch(workflow, /shell:/);
|
|
45
|
+
assert.doesNotMatch(workflow, /run:\s*\|/);
|
|
46
|
+
for (const duplicatedCommand of [
|
|
47
|
+
"npm run release:check",
|
|
48
|
+
"npm run typecheck",
|
|
49
|
+
"npm test",
|
|
50
|
+
"npm run build",
|
|
51
|
+
"npm run lsp:interop",
|
|
52
|
+
"node dist/cli.js doctor",
|
|
40
53
|
]) {
|
|
41
|
-
assert.
|
|
54
|
+
assert.equal(
|
|
55
|
+
workflow.includes(`run: ${duplicatedCommand}`),
|
|
56
|
+
false,
|
|
57
|
+
`cloud CI must delegate ${duplicatedCommand} through ci:verify`,
|
|
58
|
+
);
|
|
42
59
|
}
|
|
43
60
|
});
|
|
61
|
+
|
|
62
|
+
test("release runtime and local parity share the checked-in Node contract", async () => {
|
|
63
|
+
const nodeVersion = (await readFile(resolve(repoRoot, ".nvmrc"), "utf8")).trim();
|
|
64
|
+
assert.equal(nodeVersion, "22.19.0");
|
|
65
|
+
|
|
66
|
+
const pkg = await readJson("package.json");
|
|
67
|
+
assert.equal(pkg.scripts["ci:verify"], "node scripts/ci/verify.mjs");
|
|
68
|
+
|
|
69
|
+
const source = await readFile(resolve(repoRoot, "scripts/release-parity.mjs"), "utf8");
|
|
70
|
+
assert.match(source, /readFileSync\(join\(repoRoot, "\.nvmrc"\), "utf8"\)/);
|
|
71
|
+
assert.match(source, /const NPM_VERSION = "10\.9\.3"/);
|
|
72
|
+
assert.ok(source.includes('["npm", "ci", "--no-audit", "--no-fund"]'));
|
|
73
|
+
assert.ok(source.includes('["npm", "run", "ci:verify"]'));
|
|
74
|
+
assert.ok(source.includes('["npm", "run", "release:pack"]'));
|
|
75
|
+
assert.doesNotMatch(source, /\["npm", "run", "typecheck"\]/);
|
|
76
|
+
assert.doesNotMatch(source, /\["npm", "test"\]/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("cloud verification produces one reusable npm package on Linux", async () => {
|
|
80
|
+
const workflow = await readFile(resolve(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
|
81
|
+
assert.match(workflow, /if:\s*runner\.os == 'Linux'[\s\S]*run:\s*npm run release:pack/);
|
|
82
|
+
assert.match(workflow, /uses:\s*actions\/upload-artifact@v4/);
|
|
83
|
+
assert.match(workflow, /name:\s*npm-package/);
|
|
84
|
+
assert.match(workflow, /include-hidden-files:\s*true/);
|
|
85
|
+
assert.match(workflow, /overwrite:\s*true/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("release workflow is tag-only and promotes the verified npm artifact without rebuilding", async () => {
|
|
89
|
+
const workflow = await readFile(resolve(repoRoot, ".github/workflows/release.yml"), "utf8");
|
|
90
|
+
assert.doesNotMatch(workflow, /workflow_dispatch:/);
|
|
91
|
+
assert.match(workflow, /needs:\s*verify/);
|
|
92
|
+
assert.match(workflow, /uses:\s*actions\/download-artifact@v5/);
|
|
93
|
+
assert.match(workflow, /name:\s*npm-package/);
|
|
94
|
+
assert.match(workflow, /run:\s*npm run release:publish/);
|
|
95
|
+
assert.match(workflow, /npm install --global npm@11\.19\.1/);
|
|
96
|
+
assert.doesNotMatch(workflow, /run:\s*npm ci/);
|
|
97
|
+
assert.doesNotMatch(workflow, /run:\s*npm run build/);
|
|
98
|
+
assert.doesNotMatch(workflow, /run:\s*\|/);
|
|
99
|
+
assert.doesNotMatch(workflow, /shell:\s*bash/);
|
|
100
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
-
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
-
const NODE_VERSION = "22.19.0";
|
|
9
8
|
const NPM_VERSION = "10.9.3";
|
|
10
9
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const NODE_VERSION = readFileSync(join(repoRoot, ".nvmrc"), "utf8").trim();
|
|
11
11
|
const debugRoot = join(repoRoot, ".forgerelay-debug");
|
|
12
12
|
const sandbox = mkdtempSync(join(ensureDirectory(debugRoot), "release-parity-node22-"));
|
|
13
13
|
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
@@ -29,15 +29,11 @@ try {
|
|
|
29
29
|
["npm", "ci", "--no-audit", "--no-fund"],
|
|
30
30
|
`Node ${NODE_VERSION} / npm ${NPM_VERSION} install`,
|
|
31
31
|
);
|
|
32
|
-
runNodeNpm(sandbox, env, ["npm", "run", "
|
|
33
|
-
runNodeNpm(sandbox, env, ["npm", "run", "
|
|
34
|
-
runNodeNpm(sandbox, env, ["npm", "test"], "Full test suite");
|
|
35
|
-
runNodeNpm(sandbox, env, ["npm", "run", "build"], "Build");
|
|
36
|
-
runNodeNpm(sandbox, env, ["npm", "run", "lsp:interop"], "Optional LSP interoperability");
|
|
37
|
-
runNode(sandbox, env, ["dist/cli.js", "doctor"], "Doctor");
|
|
32
|
+
runNodeNpm(sandbox, env, ["npm", "run", "ci:verify"], "Cloud verification entrypoint");
|
|
33
|
+
runNodeNpm(sandbox, env, ["npm", "run", "release:pack"], "Cloud release packaging");
|
|
38
34
|
|
|
39
35
|
console.log(
|
|
40
|
-
`Release parity passed
|
|
36
|
+
`Release parity passed through ci:verify and release:pack on Node ${NODE_VERSION} / npm ${NPM_VERSION}.`,
|
|
41
37
|
);
|
|
42
38
|
} finally {
|
|
43
39
|
rmSync(sandbox, {
|
|
@@ -92,10 +88,6 @@ function runNodeNpm(cwd, env, command, label) {
|
|
|
92
88
|
);
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
function runNode(cwd, env, args, label) {
|
|
96
|
-
run(cwd, env, ["--yes", `node@${NODE_VERSION}`, ...args], label);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
91
|
function run(cwd, env, args, label) {
|
|
100
92
|
console.log(`\n== ${label} ==`);
|
|
101
93
|
const result = spawnSync(npx, args, {
|
|
@@ -42,15 +42,15 @@ function currentHead() {
|
|
|
42
42
|
return git(["rev-parse", "HEAD"]);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function assertReleaseTreeClean() {
|
|
45
|
+
function assertReleaseTreeClean(context) {
|
|
46
46
|
const status = git(["status", "--porcelain", "--untracked-files=all"]);
|
|
47
47
|
if (status) {
|
|
48
|
-
throw new Error(
|
|
48
|
+
throw new Error(`working tree differs from HEAD or contains untracked files; commit or remove every release input before ${context}`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function writeProof() {
|
|
53
|
-
assertReleaseTreeClean();
|
|
53
|
+
assertReleaseTreeClean("running release:verify");
|
|
54
54
|
const proof = {
|
|
55
55
|
proofVersion: PROOF_VERSION,
|
|
56
56
|
head: currentHead(),
|
|
@@ -90,28 +90,37 @@ function hookTag() {
|
|
|
90
90
|
} catch {
|
|
91
91
|
throw new Error("FORGERELAY_HOOK_PAYLOAD is not valid JSON");
|
|
92
92
|
}
|
|
93
|
-
const command = typeof payload.
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const command = typeof payload.originalCommand === "string"
|
|
94
|
+
? payload.originalCommand
|
|
95
|
+
: typeof payload.command === "string"
|
|
96
|
+
? payload.command
|
|
97
|
+
: "";
|
|
98
|
+
const pushMatch = /git\s+push\b([^;&|\n]*)/.exec(command);
|
|
99
|
+
if (!pushMatch?.[0] || !/\borigin\b/.test(pushMatch[0])) {
|
|
100
|
+
throw new Error("release Hook payload does not contain an origin release tag push");
|
|
101
|
+
}
|
|
102
|
+
const pushCommand = pushMatch[0];
|
|
103
|
+
if (/(?:^|\s)(?:-f|--force(?:-with-lease)?)(?:=\S*)?(?=$|\s)/.test(pushCommand)
|
|
104
|
+
|| /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-rc\.\d+)?(?=$|\s)/.test(pushCommand)) {
|
|
105
|
+
throw new Error("force push is not allowed for release tags");
|
|
106
|
+
}
|
|
107
|
+
if (/(?:^|\s)(?:-d|--delete)(?=$|\s)/.test(pushCommand)) {
|
|
108
|
+
throw new Error("deleting a release tag is not allowed");
|
|
109
|
+
}
|
|
110
|
+
const tagMatch = /(?:^|\s)(?:tag\s+)?(?:refs\/tags\/)?(v\d+\.\d+\.\d+(?:-rc\.\d+)?)(?=$|\s)/.exec(pushCommand);
|
|
111
|
+
if (!tagMatch?.[1]) {
|
|
96
112
|
throw new Error("release Hook payload does not contain a release tag push");
|
|
97
113
|
}
|
|
98
|
-
return
|
|
114
|
+
return tagMatch[1];
|
|
99
115
|
}
|
|
100
116
|
|
|
101
|
-
function
|
|
102
|
-
assertReleaseTreeClean();
|
|
103
|
-
const proof = readProof();
|
|
117
|
+
function checkHookTag() {
|
|
118
|
+
assertReleaseTreeClean("pushing a release tag");
|
|
104
119
|
const head = currentHead();
|
|
105
120
|
const version = packageVersion();
|
|
106
121
|
const tag = hookTag();
|
|
107
122
|
const expectedTag = `v${version}`;
|
|
108
123
|
|
|
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
124
|
if (tag !== expectedTag) {
|
|
116
125
|
throw new Error(`tag ${tag} does not match package version ${version}; expected ${expectedTag}`);
|
|
117
126
|
}
|
|
@@ -123,16 +132,16 @@ function checkHookProof() {
|
|
|
123
132
|
throw new Error(`local tag ${tag} does not exist or does not resolve to a commit`);
|
|
124
133
|
}
|
|
125
134
|
if (tagHead !== head) {
|
|
126
|
-
throw new Error(`tag ${tag} points to ${tagHead.slice(0, 12)}, but
|
|
135
|
+
throw new Error(`tag ${tag} points to ${tagHead.slice(0, 12)}, but current HEAD is ${head.slice(0, 12)}`);
|
|
127
136
|
}
|
|
128
137
|
|
|
129
|
-
console.log(`Release
|
|
138
|
+
console.log(`Release tag gate OK: ${tag} -> ${head.slice(0, 12)}; cloud CI will perform release verification.`);
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
const action = process.argv[2];
|
|
133
142
|
try {
|
|
134
143
|
if (action === "write") writeProof();
|
|
135
|
-
else if (action === "check-hook")
|
|
144
|
+
else if (action === "check-hook") checkHookTag();
|
|
136
145
|
else throw new Error("usage: node scripts/release-proof.mjs <write|check-hook>");
|
|
137
146
|
} catch (error) {
|
|
138
147
|
fail(error instanceof Error ? error.message : String(error));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import { mkdtemp,
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -21,7 +21,7 @@ async function runProof(cwd, action, env = {}) {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
test("release
|
|
24
|
+
test("release tag hook gate uses repository facts instead of requiring a local proof", async (t) => {
|
|
25
25
|
const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
|
|
26
26
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
27
27
|
await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.2.3" }) + "\n");
|
|
@@ -34,12 +34,37 @@ test("release proof binds a successful local verification to the exact tag HEAD"
|
|
|
34
34
|
|
|
35
35
|
const written = await runProof(root, "write");
|
|
36
36
|
assert.match(written.stdout, /Release verification proof recorded/);
|
|
37
|
+
await rm(join(root, ".git", "forgerelay", "release-proof.json"));
|
|
37
38
|
await git(root, ["tag", "v1.2.3"]);
|
|
38
39
|
|
|
39
40
|
const checked = await runProof(root, "check-hook", {
|
|
40
41
|
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
|
|
41
42
|
});
|
|
42
|
-
assert.match(checked.stdout, /Release
|
|
43
|
+
assert.match(checked.stdout, /Release tag gate OK: v1\.2\.3/);
|
|
44
|
+
|
|
45
|
+
for (const command of [
|
|
46
|
+
"git push --atomic origin v1.2.3",
|
|
47
|
+
"git push origin refs/tags/v1.2.3",
|
|
48
|
+
"git status && git push origin tag v1.2.3 && echo done",
|
|
49
|
+
]) {
|
|
50
|
+
const alternative = await runProof(root, "check-hook", {
|
|
51
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command, originalCommand: command }),
|
|
52
|
+
});
|
|
53
|
+
assert.match(alternative.stdout, /Release tag gate OK: v1\.2\.3/);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
() => runProof(root, "check-hook", {
|
|
58
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({
|
|
59
|
+
command: "git push origin v1.2.3",
|
|
60
|
+
originalCommand: "git push --force origin v1.2.3",
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
(error) => {
|
|
64
|
+
assert.match(String(error.stderr ?? error), /force push is not allowed for release tags/);
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
);
|
|
43
68
|
|
|
44
69
|
await writeFile(join(root, "tracked.txt"), "changed after verification\n");
|
|
45
70
|
await assert.rejects(
|
|
@@ -71,13 +96,13 @@ test("release proof binds a successful local verification to the exact tag HEAD"
|
|
|
71
96
|
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
|
|
72
97
|
}),
|
|
73
98
|
(error) => {
|
|
74
|
-
assert.match(String(error.stderr ?? error), /
|
|
99
|
+
assert.match(String(error.stderr ?? error), /tag v1\.2\.3 points to .* current HEAD is/);
|
|
75
100
|
return true;
|
|
76
101
|
},
|
|
77
102
|
);
|
|
78
103
|
});
|
|
79
104
|
|
|
80
|
-
test("release
|
|
105
|
+
test("release tag hook gate accepts an rc tag for the package version", async (t) => {
|
|
81
106
|
const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
|
|
82
107
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
83
108
|
await writeFile(join(root, "package.json"), JSON.stringify({ version: "0.6.0-rc.1" }) + "\n");
|
|
@@ -86,16 +111,15 @@ test("release proof accepts an rc tag for the verified package version", async (
|
|
|
86
111
|
await git(root, ["config", "user.name", "Release Proof Test"]);
|
|
87
112
|
await git(root, ["add", "."]);
|
|
88
113
|
await git(root, ["commit", "-m", "release 0.6.0-rc.1"]);
|
|
89
|
-
await runProof(root, "write");
|
|
90
114
|
await git(root, ["tag", "v0.6.0-rc.1"]);
|
|
91
115
|
|
|
92
116
|
const checked = await runProof(root, "check-hook", {
|
|
93
117
|
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v0.6.0-rc.1" }),
|
|
94
118
|
});
|
|
95
|
-
assert.match(checked.stdout, /Release
|
|
119
|
+
assert.match(checked.stdout, /Release tag gate OK: v0\.6\.0-rc\.1/);
|
|
96
120
|
});
|
|
97
121
|
|
|
98
|
-
test("release
|
|
122
|
+
test("release tag hook gate rejects a mismatched tag without invoking a remote", async (t) => {
|
|
99
123
|
const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
|
|
100
124
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
101
125
|
await writeFile(join(root, "package.json"), JSON.stringify({ version: "2.0.0" }) + "\n");
|
|
@@ -104,7 +128,6 @@ test("release proof rejects a mismatched tag without invoking a remote", async (
|
|
|
104
128
|
await git(root, ["config", "user.name", "Release Proof Test"]);
|
|
105
129
|
await git(root, ["add", "."]);
|
|
106
130
|
await git(root, ["commit", "-m", "release 2.0.0"]);
|
|
107
|
-
await runProof(root, "write");
|
|
108
131
|
await git(root, ["tag", "v2.0.0"]);
|
|
109
132
|
|
|
110
133
|
await assert.rejects(
|
|
@@ -117,6 +140,4 @@ test("release proof rejects a mismatched tag without invoking a remote", async (
|
|
|
117
140
|
},
|
|
118
141
|
);
|
|
119
142
|
|
|
120
|
-
const proof = JSON.parse(await readFile(join(root, ".git", "forgerelay", "release-proof.json"), "utf8"));
|
|
121
|
-
assert.equal(proof.packageVersion, "2.0.0");
|
|
122
143
|
});
|
|
@@ -171,7 +171,7 @@ async function prepareRelease(state, nextVersion, dryRun) {
|
|
|
171
171
|
|
|
172
172
|
console.log(`${state.pkg.version} -> ${nextVersion}`);
|
|
173
173
|
console.log("updated package.json, package-lock.json, and CHANGELOG.md");
|
|
174
|
-
console.log("next: review the diff, commit the release-ready tree,
|
|
174
|
+
console.log("next: review the diff, commit and push the release-ready tree, then push the matching release tag; cloud CI is the authoritative release verification");
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
function promoteUnreleased(changelog, nextVersion) {
|