Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions scripts/deprecate-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -99,22 +99,86 @@ 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) => {
resolve({ status: status ?? 1, stdout, stderr });
});
});
}

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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture E422 output before checking it

When this script is rerun from an interactive terminal, runNpmCommand takes the stdio: "inherit" branch, so result.stdout and result.stderr are empty even though npm printed E422. In that context this check never calls verifyDeprecation, and an already-deprecated target still exits 1, which leaves the new idempotent rerun path working in CI but not for the local/OTP workflow the script supports.

Useful? React with 👍 / 👎.

console.log(
`Verified existing deprecation for ${spec} after npm returned E422.`,
);
process.exit(0);
}
if (
output.includes("E401") ||
output.includes("401 Unauthorized") ||
Expand Down
79 changes: 79 additions & 0 deletions test/scripts/deprecate-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading