diff --git a/scripts/deprecate-release.js b/scripts/deprecate-release.js index 622869c1a..fa36f8279 100644 --- a/scripts/deprecate-release.js +++ b/scripts/deprecate-release.js @@ -86,7 +86,7 @@ if (options.dryRun) { process.exit(0); } -function runNpmCommand(command, args) { +function runNpmCommand(command, args, { writeOutput = true } = {}) { return new Promise((resolve, reject) => { const interactiveStdio = process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY; @@ -99,12 +99,16 @@ function runNpmCommand(command, args) { child.stdout?.on("data", (chunk) => { const text = chunk.toString(); stdout += text; - process.stdout.write(text); + if (writeOutput) { + process.stdout.write(text); + } }); child.stderr?.on("data", (chunk) => { const text = chunk.toString(); stderr += text; - process.stderr.write(text); + if (writeOutput) { + process.stderr.write(text); + } }); child.on("error", reject); child.on("close", (status) => { @@ -112,9 +116,69 @@ function runNpmCommand(command, args) { }); }); } + +function hasDeprecationValue(value) { + if (value === null || value === undefined || value === "") { + return false; + } + if (typeof value === "object") { + return Object.values(value).some(hasDeprecationValue); + } + return true; +} + +function countJsonValues(value, predicate = () => true) { + if (Array.isArray(value)) { + return value.filter(predicate).length; + } + if (value && typeof value === "object") { + return Object.values(value).filter(predicate).length; + } + return predicate(value) ? 1 : 0; +} + +async function readNpmJson(command, targetSpec, field) { + const result = await runNpmCommand( + command, + ["view", targetSpec, field, "--json"], + { writeOutput: false }, + ); + if (result.status !== 0) { + return undefined; + } + + const text = result.stdout.trim(); + if (!text) { + return undefined; + } + + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function verifyDeprecation(command, targetSpec) { + const versions = await readNpmJson(command, targetSpec, "version"); + const deprecations = await readNpmJson(command, targetSpec, "deprecated"); + const versionCount = countJsonValues(versions, (value) => Boolean(value)); + if (versionCount === 0) { + return false; + } + + return countJsonValues(deprecations, hasDeprecationValue) >= versionCount; +} + const result = await runNpmCommand(npmCommand, npmArgs); if (result.status !== 0) { const output = `${result.stdout}\n${result.stderr}`; + if (output.includes("E422") && (await verifyDeprecation(npmCommand, spec))) { + console.log( + `Verified existing deprecation for ${spec} after npm returned E422.`, + ); + process.exit(0); + } if ( output.includes("E401") || output.includes("401 Unauthorized") || diff --git a/test/scripts/deprecate-release.test.ts b/test/scripts/deprecate-release.test.ts index ae14a8529..a4e175ac4 100644 --- a/test/scripts/deprecate-release.test.ts +++ b/test/scripts/deprecate-release.test.ts @@ -147,6 +147,85 @@ describe("deprecate-release", () => { } }); + it("treats npm E422 as success when the target is already deprecated", () => { + const tempDir = mkdtempSync(join(tmpdir(), "maestro-deprecate-e422-test-")); + const fakeNpm = join(tempDir, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'if [[ "$1" == "deprecate" ]]; then', + ' echo "npm error code E422" >&2', + ' echo "npm error 422 Unprocessable Entity - PUT https://registry.npmjs.org/@evalops%2fmaestro - Unprocessable Entity" >&2', + " exit 1", + "fi", + 'if [[ "$1" == "view" ]]; then', + ' if [[ "$3" == "version" ]]; then', + ' echo "[\\"0.10.8\\",\\"0.10.9\\"]"', + " else", + ' echo "[\\"Deprecated release. Upgrade to a supported Maestro version.\\",\\"Deprecated release. Upgrade to a supported Maestro version.\\"]"', + " fi", + " exit 0", + "fi", + "exit 2", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runDeprecate({ MAESTRO_NPM_COMMAND: fakeNpm }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "Verified existing deprecation for @evalops/maestro@>=0.10.8 <=0.10.20 after npm returned E422.", + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps npm E422 fatal when only part of a range is deprecated", () => { + const tempDir = mkdtempSync( + join(tmpdir(), "maestro-deprecate-partial-e422-test-"), + ); + const fakeNpm = join(tempDir, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'if [[ "$1" == "deprecate" ]]; then', + ' echo "npm error code E422" >&2', + ' echo "npm error 422 Unprocessable Entity - PUT https://registry.npmjs.org/@evalops%2fmaestro - Unprocessable Entity" >&2', + " exit 1", + "fi", + 'if [[ "$1" == "view" ]]; then', + ' if [[ "$3" == "version" ]]; then', + ' echo "[\\"0.10.8\\",\\"0.10.9\\"]"', + " else", + ' echo "[\\"Deprecated release. Upgrade to a supported Maestro version.\\"]"', + " fi", + " exit 0", + "fi", + "exit 2", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runDeprecate({ MAESTRO_NPM_COMMAND: fakeNpm }); + + expect(result.status).toBe(1); + expect(result.stdout).not.toContain( + "Verified existing deprecation for @evalops/maestro@>=0.10.8 <=0.10.20 after npm returned E422.", + ); + expect(result.stderr).toContain("npm error code E422"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("forwards stdin to npm so local OTP prompts can be answered", () => { const tempDir = mkdtempSync(join(tmpdir(), "maestro-deprecate-otp-test-")); const fakeNpm = join(tempDir, "npm");