From 2446f4145d82d020a6bd19903b362d977856f42a Mon Sep 17 00:00:00 2001 From: Adam Poulemanos Date: Sat, 5 Sep 2026 13:55:33 -0400 Subject: [PATCH] fix(openapi-mcp): avoid release adapter startup deadlock --- .../openapi-mcp-release-startup.fixture.mjs | 50 +++++++++++++++++++ scripts/openapi-mcp-release.mjs | 2 +- scripts/openapi-mcp-release.test.ts | 22 ++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 scripts/openapi-mcp-release-startup.fixture.mjs diff --git a/scripts/openapi-mcp-release-startup.fixture.mjs b/scripts/openapi-mcp-release-startup.fixture.mjs new file mode 100644 index 0000000..dffe7e6 --- /dev/null +++ b/scripts/openapi-mcp-release-startup.fixture.mjs @@ -0,0 +1,50 @@ +// Replace only the release operation. Keep the entrypoint and installed plugin +// normalization/loading real, including dynamic imports of configured plugins. +import { registerHooks } from "node:module"; + +const pluginsUrl = new URL( + "./lib/plugins/index.js", + import.meta.resolve("semantic-release"), +).href; +const boundaryUrl = "test:semantic-release-operation"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "semantic-release") { + return { url: boundaryUrl, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url !== boundaryUrl) return nextLoad(url, context); + return { + format: "module", + shortCircuit: true, + source: ` +import assert from "node:assert/strict"; +import normalizePlugins from ${JSON.stringify(pluginsUrl)}; + +export default async function release(options, context) { + // Select the configured npm adapter without executing unrelated release hooks. + const adapterEntries = options.plugins.filter(entry => Array.isArray(entry) && entry[1]?.npmPublish === true); + assert.equal(adapterEntries.length, 1); + const logger = { log() {}, success() {}, warn() {}, error() {}, scope() { return this; } }; + const input = { ...context, env: {}, options: { ...options, plugins: adapterEntries, dryRun: false }, logger }; + const plugins = await normalizePlugins(input, {}); + const expected = { + verifyConditions: "Protected npmrelease GitHub OIDC context and observed publisher readiness are required", + prepare: "Protected npmrelease GitHub OIDC context and observed publisher readiness are required", + publish: "No tested tarball is available for publication", + }; + for (const [hook, message] of Object.entries(expected)) { + await assert.rejects(() => plugins[hook](input), error => { + const errors = error.errors ?? [error]; + return errors.length === 1 && errors[0].message === message; + }); + } + console.log("adapter guards reached: verifyConditions, prepare, publish"); +} +`, + }; + }, +}); diff --git a/scripts/openapi-mcp-release.mjs b/scripts/openapi-mcp-release.mjs index ab9e23b..492c36e 100644 --- a/scripts/openapi-mcp-release.mjs +++ b/scripts/openapi-mcp-release.mjs @@ -413,7 +413,7 @@ if ( ); const plugins = manifest.release.plugins.map((entry) => Array.isArray(entry) && entry[0] === "@semantic-release/npm" - ? [fileURLToPath(import.meta.url), entry[1]] + ? [adapter, entry[1]] : entry, ); const { default: semanticRelease } = await import("semantic-release"); diff --git a/scripts/openapi-mcp-release.test.ts b/scripts/openapi-mcp-release.test.ts index e10467b..2bec3aa 100644 --- a/scripts/openapi-mcp-release.test.ts +++ b/scripts/openapi-mcp-release.test.ts @@ -1,13 +1,35 @@ import { expect, spyOn, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import * as release from "./openapi-mcp-release.mjs"; import { createReleaseAdapter, verifyAuditBinding, } from "./openapi-mcp-release.mjs"; +test("release CLI initializes its configured adapter through the installed plugin loader", () => { + const result = spawnSync( + "node", + [ + "--import", + fileURLToPath( + new URL("./openapi-mcp-release-startup.fixture.mjs", import.meta.url), + ), + fileURLToPath(new URL("./openapi-mcp-release.mjs", import.meta.url)), + ], + { encoding: "utf8", timeout: 15_000, env: { PATH: process.env.PATH } }, + ); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe( + "adapter guards reached: verifyConditions, prepare, publish", + ); +}); + test("registry readiness retries packument visibility and accepts only the exact release", async () => { expect(typeof release.waitForRegistryVersion).toBe("function"); const responses = [