@akira-tl/forgerelay 0.9.4 → 0.10.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/capabilities/shell-processes/GUIDE.md +1 -1
  4. package/dist/cli/init.js +86 -1
  5. package/dist/cli/shell/setup.js +99 -0
  6. package/dist/cli.js +43 -10
  7. package/dist/mcp/hooks/hooks.js +6 -3
  8. package/dist/mcp/process/process-platform.js +34 -26
  9. package/dist/mcp/process/process-sessions.js +6 -3
  10. package/dist/mcp/server/transport/http-server.js +4 -1
  11. package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +6 -0
  12. package/dist/mcp/server/workspace/runtime/workspace-tools.js +1 -1
  13. package/dist/mcp/server-instructions.js +10 -1
  14. package/dist/runtime/config/config.js +6 -0
  15. package/dist/runtime/instructions/powershell-skill.js +65 -0
  16. package/dist/runtime/instructions/shell-instructions.js +100 -0
  17. package/dist/runtime/security/runtime-privilege.js +120 -0
  18. package/dist/runtime/shell/command-shell-runtime.js +278 -0
  19. package/dist/ui/.vite/manifest.json +276 -276
  20. package/dist/ui/activity-panel-app.html +2 -2
  21. package/dist/ui/assets/{activity-panel-app-Sl1Jro8v.js → activity-panel-app-BkODhDLg.js} +1 -1
  22. package/dist/ui/assets/{activity-panel-app-7Xe0alhm.css → activity-panel-app-DkfJzTZ3.css} +1 -1
  23. package/dist/ui/assets/{angular-html-B_yn91wW.js → angular-html-DjuELMdF.js} +1 -1
  24. package/dist/ui/assets/{angular-ts-DSgtloKz.js → angular-ts-2EXVZIIX.js} +1 -1
  25. package/dist/ui/assets/{apl-NatWtF8X.js → apl-Bb4IeT5V.js} +1 -1
  26. package/dist/ui/assets/{astro-BYFEVBuN.js → astro-Bjavu3nT.js} +1 -1
  27. package/dist/ui/assets/{blade-DoO7yvMR.js → blade-DKW2uHZo.js} +1 -1
  28. package/dist/ui/assets/{c-Rl2hMCcg.js → c-oLeHmzbj.js} +1 -1
  29. package/dist/ui/assets/{cobol-ByC5mWiB.js → cobol-CcqFMr5T.js} +1 -1
  30. package/dist/ui/assets/{coffee-Dez77Qov.js → coffee-BA20zqRF.js} +1 -1
  31. package/dist/ui/assets/{cpp-DtaAQT9h.js → cpp-D750WmDa.js} +1 -1
  32. package/dist/ui/assets/{crystal-Bgmbf8ec.js → crystal-Cv5I6OMI.js} +1 -1
  33. package/dist/ui/assets/{css-C1tjBtVN.js → css-CjPPWPeH.js} +1 -1
  34. package/dist/ui/assets/{edge-DeH6u8Wg.js → edge-aYRuudEZ.js} +1 -1
  35. package/dist/ui/assets/{elixir-_H3KnmWT.js → elixir-ChLitmz7.js} +1 -1
  36. package/dist/ui/assets/{elm-CsCe3U28.js → elm-Fs7L7u36.js} +1 -1
  37. package/dist/ui/assets/{erb-CbM-L2g8.js → erb-B-qXHxN1.js} +1 -1
  38. package/dist/ui/assets/{git-rebase-D0s5pDh9.js → git-rebase-DOVN5OEg.js} +1 -1
  39. package/dist/ui/assets/{glimmer-js-BfOToq_A.js → glimmer-js-BFt_vCTW.js} +1 -1
  40. package/dist/ui/assets/{glimmer-ts-CCUOkL2K.js → glimmer-ts-ufCHlR6C.js} +1 -1
  41. package/dist/ui/assets/{glsl-DH4ec9dE.js → glsl-Ex4JXGuC.js} +1 -1
  42. package/dist/ui/assets/{graphql-DilAMBoI.js → graphql-pQPXtBk5.js} +1 -1
  43. package/dist/ui/assets/{hack-BDkNDZlF.js → hack-B5hDguhd.js} +1 -1
  44. package/dist/ui/assets/{haml-D_QBp6K7.js → haml-BrNeI29U.js} +1 -1
  45. package/dist/ui/assets/{handlebars-DePUrTpC.js → handlebars-DCqTsL9-.js} +1 -1
  46. package/dist/ui/assets/{heavy-payload-DOwZpO6n.js → heavy-payload-DsmmHgvt.js} +3 -3
  47. package/dist/ui/assets/{html-By4dNnM4.js → html-D8k0CC9V.js} +1 -1
  48. package/dist/ui/assets/{html-derivative-DDw9Ef4Q.js → html-derivative-CC8flWmC.js} +1 -1
  49. package/dist/ui/assets/{http-B0QxWc9M.js → http-BYnWqNep.js} +1 -1
  50. package/dist/ui/assets/{hurl-vMQ4lda1.js → hurl-BND-z85s.js} +1 -1
  51. package/dist/ui/assets/{java-DYRe2lWG.js → java-Dug0oB9T.js} +1 -1
  52. package/dist/ui/assets/{javascript-CxyGqwcL.js → javascript-oDJ7WITC.js} +1 -1
  53. package/dist/ui/assets/{jinja-JUwV-fZt.js → jinja-Cv0t_F1U.js} +1 -1
  54. package/dist/ui/assets/{jison-7ZqCEvS8.js → jison-Car3MF55.js} +1 -1
  55. package/dist/ui/assets/{json-dbltgiiA.js → json-CEViCsf4.js} +1 -1
  56. package/dist/ui/assets/{jsx-oDopT7YQ.js → jsx-BfZ0zgSH.js} +1 -1
  57. package/dist/ui/assets/{julia-CkKiNSav.js → julia-CmX0b3Tn.js} +1 -1
  58. package/dist/ui/assets/{just-DuqCAKJ3.js → just-BuugLCVk.js} +1 -1
  59. package/dist/ui/assets/{latex-sudcRDSn.js → latex-FpTXqO03.js} +1 -1
  60. package/dist/ui/assets/{liquid-Di-08Js7.js → liquid-B37OzUFu.js} +1 -1
  61. package/dist/ui/assets/{lua-YlqdMoV0.js → lua-BHjcb8oq.js} +1 -1
  62. package/dist/ui/assets/{marko-CJRNyJDb.js → marko-CApj_fsH.js} +1 -1
  63. package/dist/ui/assets/{mdc-t12CzZHs.js → mdc-Ed5vQWq8.js} +1 -1
  64. package/dist/ui/assets/{nginx-57-b4XwT.js → nginx-u_rVEBox.js} +1 -1
  65. package/dist/ui/assets/{nim-BzrswYQa.js → nim-Ba9EdchT.js} +1 -1
  66. package/dist/ui/assets/{perl-D9JR_iIh.js → perl-BpU_6Wvz.js} +1 -1
  67. package/dist/ui/assets/{php-Py2gFHil.js → php-bKabofky.js} +1 -1
  68. package/dist/ui/assets/{pug-PzLLy0h1.js → pug-C_aLNkqe.js} +1 -1
  69. package/dist/ui/assets/{qml-By8fwgV4.js → qml-D9VisnRy.js} +1 -1
  70. package/dist/ui/assets/{r-CcezTbLq.js → r-CYqB1sLx.js} +1 -1
  71. package/dist/ui/assets/{razor-dG-vdhL_.js → razor-DNJKI8Rs.js} +1 -1
  72. package/dist/ui/assets/{regexp-BlsQ-N_8.js → regexp-DHrTHXkz.js} +1 -1
  73. package/dist/ui/assets/{rst-C2awDCat.js → rst-CbNdSFHv.js} +1 -1
  74. package/dist/ui/assets/{ruby-zA2W_49d.js → ruby-FyKG-pkF.js} +1 -1
  75. package/dist/ui/assets/{sas-D49bhyaN.js → sas-BbSPDl3X.js} +1 -1
  76. package/dist/ui/assets/{scss-BQb-cp01.js → scss-2nX9g6gD.js} +1 -1
  77. package/dist/ui/assets/{shellscript-Dga4H_Ni.js → shellscript-CmON6x2x.js} +1 -1
  78. package/dist/ui/assets/{shellsession-n3-5CqcF.js → shellsession-DtqIGlAX.js} +1 -1
  79. package/dist/ui/assets/{soy-XYBOf_BA.js → soy-BzUJMmJT.js} +1 -1
  80. package/dist/ui/assets/{sql-ET4u5Cpl.js → sql-CgVcFZmb.js} +1 -1
  81. package/dist/ui/assets/{stata-CLHcJHO_.js → stata-CNitlMot.js} +1 -1
  82. package/dist/ui/assets/{surrealql-BdjQ60T4.js → surrealql-BA8-xsRZ.js} +1 -1
  83. package/dist/ui/assets/{svelte-B2RPtKJe.js → svelte-El02yAxv.js} +1 -1
  84. package/dist/ui/assets/{templ-CrByPW_W.js → templ-DeUQ84aR.js} +1 -1
  85. package/dist/ui/assets/{tex-DZBkg363.js → tex-Cu-FqpPS.js} +1 -1
  86. package/dist/ui/assets/{ts-tags-DJR3Gphb.js → ts-tags-X42N6TGA.js} +1 -1
  87. package/dist/ui/assets/{tsx-CajzROyy.js → tsx-C1aPp9rY.js} +1 -1
  88. package/dist/ui/assets/{twig-BZjk5Zda.js → twig-Cj1Q4SQc.js} +1 -1
  89. package/dist/ui/assets/{typescript-B-EWToLj.js → typescript-Du9lVt1G.js} +1 -1
  90. package/dist/ui/assets/{vue-D_rHCCr8.js → vue-_uMGbFv8.js} +1 -1
  91. package/dist/ui/assets/{vue-html-CC4WlvVt.js → vue-html-D61naG-Q.js} +1 -1
  92. package/dist/ui/assets/{vue-vine-BgNMVgpk.js → vue-vine-CkFae4yV.js} +1 -1
  93. package/dist/ui/assets/{xml-BI86-WbH.js → xml-Cbwsc6ws.js} +1 -1
  94. package/dist/ui/assets/{xsl-DXeLZV4J.js → xsl-NyBx72XB.js} +1 -1
  95. package/dist/ui/assets/{yaml-60_PFlWa.js → yaml-Cgb6Uq9u.js} +1 -1
  96. package/dist/workspaces/context.js +32 -5
  97. package/dist/workspaces/presentation/workspace-presentation.js +1 -0
  98. package/dist/workspaces/sessions.js +2 -0
  99. package/dist/workspaces.js +2 -1
  100. package/docs/configuration.md +1 -1
  101. package/docs/gotchas.md +5 -3
  102. package/package.json +3 -2
  103. package/scripts/ci/pwsh-acceptance.mjs +308 -0
  104. package/scripts/ci/verify.mjs +1 -0
  105. package/scripts/release/release-gate.test.mjs +1 -0
  106. package/scripts/release-proof.mjs +85 -9
  107. package/scripts/release-proof.test.mjs +79 -1
@@ -14,6 +14,7 @@ runNpm(["run", "release:check"], "Release metadata");
14
14
  runNpm(["run", "typecheck"], "Typecheck");
15
15
  runNpm(["test"], "Full test suite");
16
16
  runNpm(["run", "build"], "Build");
17
+ if (process.platform === "win32") runNpm(["run", "pwsh:accept"], "PowerShell 7 packaged acceptance");
17
18
  // The traffic audit is an end-to-end wire-budget check, not a platform feature
18
19
  // matrix. Run it once on Linux so regressions are gated without tripling the
19
20
  // 7677/7678 server exercise across every CI operating system.
@@ -21,6 +21,7 @@ test("release tag Hook is a fast repository-state gate for common origin tag pus
21
21
  "git push origin v1.2.3",
22
22
  "git push --atomic origin v1.2.3",
23
23
  "git push origin refs/tags/v1.2.3",
24
+ "git push --delete origin v1.2.3",
24
25
  "git status && git push origin tag v1.2.3 && echo done",
25
26
  ]) {
26
27
  assert.match(command, matcher);
@@ -6,6 +6,8 @@ import { dirname, join } from "node:path";
6
6
 
7
7
  const PROOF_VERSION = 1;
8
8
  const PROOF_RELATIVE_PATH = join("forgerelay", "release-proof.json");
9
+ const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org/";
10
+ const DEFAULT_GITHUB_API_URL = "https://api.github.com/";
9
11
 
10
12
  function fail(message) {
11
13
  console.error(`Release proof failed: ${message}`);
@@ -21,12 +23,16 @@ function git(args, options = {}) {
21
23
  }).trim();
22
24
  }
23
25
 
24
- function packageVersion() {
26
+ function packageMetadata() {
25
27
  const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"));
26
28
  if (typeof pkg.version !== "string" || !pkg.version) {
27
29
  throw new Error("package.json must contain a version string");
28
30
  }
29
- return pkg.version;
31
+ return pkg;
32
+ }
33
+
34
+ function packageVersion() {
35
+ return packageMetadata().version;
30
36
  }
31
37
 
32
38
  function requireDedicatedReleaseNotes(version) {
@@ -132,21 +138,81 @@ function hookTag() {
132
138
  || /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-rc\.\d+)?(?=$|\s)/.test(pushCommand)) {
133
139
  throw new Error("force push is not allowed for release tags");
134
140
  }
135
- if (/(?:^|\s)(?:-d|--delete)(?=$|\s)/.test(pushCommand)) {
136
- throw new Error("deleting a release tag is not allowed");
137
- }
141
+ const deleting = /(?:^|\s)(?:-d|--delete)(?=$|\s)/.test(pushCommand);
138
142
  const tagMatch = /(?:^|\s)(?:tag\s+)?(?:refs\/tags\/)?(v\d+\.\d+\.\d+(?:-rc\.\d+)?)(?=$|\s)/.exec(pushCommand);
139
143
  if (!tagMatch?.[1]) {
140
144
  throw new Error("release Hook payload does not contain a release tag push");
141
145
  }
142
- return tagMatch[1];
146
+ return { tag: tagMatch[1], deleting };
143
147
  }
144
148
 
145
- function checkHookTag() {
149
+ function githubRepositorySlug(repository) {
150
+ const raw = typeof repository === "string" ? repository : repository?.url;
151
+ if (typeof raw !== "string" || !raw.trim()) {
152
+ throw new Error("package.json must contain a GitHub repository URL before rebuilding a release tag");
153
+ }
154
+ const normalized = raw.trim().replace(/^git\+/, "").replace(/\.git$/, "");
155
+ const match = /github\.com(?::|\/)([^/\s]+\/[^/\s]+)$/.exec(normalized);
156
+ if (!match?.[1]) {
157
+ throw new Error(`unable to determine GitHub repository from package.json repository URL: ${raw}`);
158
+ }
159
+ return match[1];
160
+ }
161
+
162
+ function testEndpoint(name, fallback) {
163
+ if (process.env.NODE_ENV === "test" && process.env[name]) return process.env[name];
164
+ return fallback;
165
+ }
166
+
167
+ async function artifactExists(label, url, headers = {}) {
168
+ let response;
169
+ try {
170
+ response = await fetch(url, {
171
+ headers,
172
+ signal: AbortSignal.timeout(8_000),
173
+ });
174
+ } catch (error) {
175
+ const detail = error instanceof Error ? error.message : String(error);
176
+ throw new Error(`unable to verify ${label} before deleting the release tag: ${detail}`);
177
+ }
178
+ if (response.status === 404) return false;
179
+ if (response.ok) return true;
180
+ throw new Error(`unable to verify ${label} before deleting the release tag: HTTP ${response.status}`);
181
+ }
182
+
183
+ async function assertReleaseTagMutable(tag, version) {
184
+ const pkg = packageMetadata();
185
+ if (typeof pkg.name !== "string" || !pkg.name) {
186
+ throw new Error("package.json must contain a package name before rebuilding a release tag");
187
+ }
188
+ const repository = githubRepositorySlug(pkg.repository);
189
+ const npmBase = testEndpoint("FORGERELAY_TEST_NPM_REGISTRY_URL", DEFAULT_NPM_REGISTRY_URL);
190
+ const githubBase = testEndpoint("FORGERELAY_TEST_GITHUB_API_URL", DEFAULT_GITHUB_API_URL);
191
+ const npmUrl = new URL(`${encodeURIComponent(pkg.name)}/${encodeURIComponent(version)}`, npmBase).toString();
192
+ const githubUrl = new URL(
193
+ `repos/${repository}/releases/tags/${encodeURIComponent(tag)}`,
194
+ githubBase,
195
+ ).toString();
196
+ const [npmPublished, githubReleased] = await Promise.all([
197
+ artifactExists(`npm package ${pkg.name}@${version}`, npmUrl),
198
+ artifactExists(`GitHub Release ${tag}`, githubUrl, {
199
+ Accept: "application/vnd.github+json",
200
+ "User-Agent": "ForgeRelay-release-tag-gate",
201
+ }),
202
+ ]);
203
+ if (npmPublished) {
204
+ throw new Error(`npm package ${pkg.name}@${version} already exists; release tag ${tag} is immutable`);
205
+ }
206
+ if (githubReleased) {
207
+ throw new Error(`GitHub Release ${tag} already exists; release tag ${tag} is immutable`);
208
+ }
209
+ }
210
+
211
+ async function checkHookTag() {
146
212
  assertReleaseTreeClean("pushing a release tag");
147
213
  const head = currentHead();
148
214
  const version = packageVersion();
149
- const tag = hookTag();
215
+ const { tag, deleting } = hookTag();
150
216
  const expectedTag = `v${version}`;
151
217
 
152
218
  if (tag !== expectedTag) {
@@ -160,6 +226,16 @@ function checkHookTag() {
160
226
  } catch {
161
227
  throw new Error(`local tag ${tag} does not exist or does not resolve to a commit`);
162
228
  }
229
+
230
+ if (deleting) {
231
+ checkProof();
232
+ await assertReleaseTagMutable(tag, version);
233
+ console.log(
234
+ `Release tag rebuild gate OK: ${tag}; npm package and GitHub Release are absent, so the existing tag may be deleted and rebuilt.`,
235
+ );
236
+ return;
237
+ }
238
+
163
239
  if (tagHead !== head) {
164
240
  throw new Error(`tag ${tag} points to ${tagHead.slice(0, 12)}, but current HEAD is ${head.slice(0, 12)}`);
165
241
  }
@@ -171,7 +247,7 @@ const action = process.argv[2];
171
247
  try {
172
248
  if (action === "write") writeProof();
173
249
  else if (action === "check") checkProof();
174
- else if (action === "check-hook") checkHookTag();
250
+ else if (action === "check-hook") await checkHookTag();
175
251
  else throw new Error("usage: node scripts/release-proof.mjs <write|check|check-hook>");
176
252
  } catch (error) {
177
253
  fail(error instanceof Error ? error.message : String(error));
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFile } from "node:child_process";
3
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import test from "node:test";
@@ -21,10 +22,36 @@ async function runProof(cwd, action, env = {}) {
21
22
  });
22
23
  }
23
24
 
25
+ async function artifactApiEnv(t, { npmStatus = 404, githubStatus = 404 } = {}) {
26
+ const server = createServer((request, response) => {
27
+ if (request.url?.startsWith("/npm/")) response.statusCode = npmStatus;
28
+ else if (request.url?.startsWith("/github/")) response.statusCode = githubStatus;
29
+ else response.statusCode = 500;
30
+ response.end();
31
+ });
32
+ await new Promise((resolve, reject) => {
33
+ server.once("error", reject);
34
+ server.listen(0, "127.0.0.1", resolve);
35
+ });
36
+ t.after(() => new Promise((resolve) => server.close(resolve)));
37
+ const address = server.address();
38
+ assert.ok(address && typeof address === "object");
39
+ const base = `http://127.0.0.1:${address.port}`;
40
+ return {
41
+ NODE_ENV: "test",
42
+ FORGERELAY_TEST_NPM_REGISTRY_URL: `${base}/npm/`,
43
+ FORGERELAY_TEST_GITHUB_API_URL: `${base}/github/`,
44
+ };
45
+ }
46
+
24
47
  test("release tag hook gate uses repository facts instead of requiring a local proof", async (t) => {
25
48
  const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
26
49
  t.after(() => rm(root, { recursive: true, force: true }));
27
- await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.2.3" }) + "\n");
50
+ await writeFile(join(root, "package.json"), JSON.stringify({
51
+ name: "@akira-tl/forgerelay",
52
+ version: "1.2.3",
53
+ repository: { url: "git+https://github.com/Akira-TL/forgerelay.git" },
54
+ }) + "\n");
28
55
  await mkdir(join(root, "docs", "releases"), { recursive: true });
29
56
  await writeFile(join(root, "docs", "releases", "v1.2.3.md"), "# v1.2.3\n\nDedicated notes.\n");
30
57
  await writeFile(join(root, "tracked.txt"), "verified\n");
@@ -93,6 +120,14 @@ test("release tag hook gate uses repository facts instead of requiring a local p
93
120
  await rm(join(root, "untracked.txt"));
94
121
  await git(root, ["add", "tracked.txt"]);
95
122
  await git(root, ["commit", "--allow-empty", "-m", "new head"]);
123
+ await runProof(root, "write");
124
+ const mutableEnv = await artifactApiEnv(t);
125
+ const deletion = await runProof(root, "check-hook", {
126
+ ...mutableEnv,
127
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push --delete origin v1.2.3" }),
128
+ });
129
+ assert.match(deletion.stdout, /Release tag rebuild gate OK: v1\.2\.3/);
130
+
96
131
  await assert.rejects(
97
132
  () => runProof(root, "check-hook", {
98
133
  FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.2.3" }),
@@ -104,6 +139,49 @@ test("release tag hook gate uses repository facts instead of requiring a local p
104
139
  );
105
140
  });
106
141
 
142
+ test("release tag hook gate refuses deletion after an irreversible release artifact exists", async (t) => {
143
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
144
+ t.after(() => rm(root, { recursive: true, force: true }));
145
+ await writeFile(join(root, "package.json"), JSON.stringify({
146
+ name: "@akira-tl/forgerelay",
147
+ version: "1.3.0",
148
+ repository: { url: "git+https://github.com/Akira-TL/forgerelay.git" },
149
+ }) + "\n");
150
+ await mkdir(join(root, "docs", "releases"), { recursive: true });
151
+ await writeFile(join(root, "docs", "releases", "v1.3.0.md"), "# v1.3.0\n\nDedicated notes.\n");
152
+ await git(root, ["init"]);
153
+ await git(root, ["config", "user.email", "proof@example.com"]);
154
+ await git(root, ["config", "user.name", "Release Proof Test"]);
155
+ await git(root, ["add", "."]);
156
+ await git(root, ["commit", "-m", "release 1.3.0"]);
157
+ await git(root, ["tag", "v1.3.0"]);
158
+ await runProof(root, "write");
159
+
160
+ const npmPublishedEnv = await artifactApiEnv(t, { npmStatus: 200, githubStatus: 404 });
161
+ await assert.rejects(
162
+ () => runProof(root, "check-hook", {
163
+ ...npmPublishedEnv,
164
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push --delete origin v1.3.0" }),
165
+ }),
166
+ (error) => {
167
+ assert.match(String(error.stderr ?? error), /npm package .* already exists/);
168
+ return true;
169
+ },
170
+ );
171
+
172
+ const githubPublishedEnv = await artifactApiEnv(t, { npmStatus: 404, githubStatus: 200 });
173
+ await assert.rejects(
174
+ () => runProof(root, "check-hook", {
175
+ ...githubPublishedEnv,
176
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push --delete origin v1.3.0" }),
177
+ }),
178
+ (error) => {
179
+ assert.match(String(error.stderr ?? error), /GitHub Release .* already exists/);
180
+ return true;
181
+ },
182
+ );
183
+ });
184
+
107
185
  test("release tag hook gate rejects a matching tag when dedicated notes are missing", async (t) => {
108
186
  const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
109
187
  t.after(() => rm(root, { recursive: true, force: true }));