fix(release): validate GITHUB_OUTPUT values and fail loudly on empty package lists

Hardens the new GITHUB_OUTPUT emission path so a malformed value can't smuggle
extra `key=value` lines into the workflow's step outputs, and so the workflow's
"Verify publish step emitted version" guard can't be fooled by a publish that
did nothing.

emitGithubOutputs now validates every key/value for `\n`/`\r` BEFORE the
GITHUB_OUTPUT early-return — a malformed value is a caller bug and should fail
loudly even when running locally. A multi-line value would need the heredoc
form, which this helper deliberately does not support.

prerelease.ts and publish-release.ts now fail loud when getPackagesForScope
returns an empty list. Without this, the new GITHUB_OUTPUT emission would make
the workflow's "Verify publish step emitted version" guard pass on a run that
published nothing — previously the missing output made such a run fail. The
guard runs BEFORE the dry-run branch in prerelease.ts. In publish-release.ts,
the inline iteration of getPackagesForScope(scope) is hoisted to a `packages`
const so the same guard fires before the publish loop.

The "no-op when GITHUB_OUTPUT is unset" test now spies on fs.appendFileSync
and asserts it wasn't called (the previous read of the unrelated temp file
was vacuously true). New tests cover newline/CR in value and newline in key.

The prerelease.ts usage string previously advertised `[--suffix <label>]`,
but the script never parses --suffix (suffix handling lives in
bump-prerelease.ts per the header comment). Removed.

Call sites enumerated:
- emitGithubOutputs: prerelease.ts (dry-run + post-publish), publish-release.ts
- getPackagesForScope: prerelease.ts, publish-release.ts (this commit);
  bump-prerelease.ts, prepare-release.ts, versions.ts (not changed — out of
  scope for this hardening)

Verification:
- npx vitest run --config scripts/release/vitest.config.mts → 91 passed
- Red-green for the newline validation: temporarily removed the validation,
  the 3 new newline/CR tests failed (assertion: expected fn to throw); restored,
  back to green.
- E2E: GITHUB_OUTPUT="$OUT" pnpm release:prerelease:dry succeeded and the
  output file contained `version=1.59.5` and `scope=monorepo`.

Note: Fix 2's empty-list guard fires only on a misconfigured scope (no unit
test reachable — prerelease.ts is outside the vitest include glob and the
guard is boundary validation against a misconfigured scope, not a behavior
worth contriving a test harness for).
This commit is contained in:
Maxim
2026-06-10 20:43:46 +02:00
parent afef53de65
commit e8fa74ce45
4 changed files with 52 additions and 4 deletions
+20 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
@@ -22,6 +22,7 @@ afterEach(() => {
process.env.GITHUB_OUTPUT = originalGithubOutput;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("emitGithubOutputs", () => {
@@ -45,8 +46,25 @@ describe("emitGithubOutputs", () => {
it("is a no-op when GITHUB_OUTPUT is unset", () => {
delete process.env.GITHUB_OUTPUT;
const appendSpy = vi.spyOn(fs, "appendFileSync");
expect(() => emitGithubOutputs({ version: "1.2.3" })).not.toThrow();
expect(fs.readFileSync(outputFile, "utf8")).toBe("");
expect(appendSpy).not.toHaveBeenCalled();
});
it("throws when a value contains a newline, naming the offending key", () => {
expect(() =>
emitGithubOutputs({ version: "1.2.3\nmalicious=evil" }),
).toThrow(/version/);
});
it("throws when a value contains a carriage return", () => {
expect(() => emitGithubOutputs({ version: "1.2.3\r" })).toThrow(/version/);
});
it("throws when a key contains a newline", () => {
expect(() => emitGithubOutputs({ "bad\nkey": "value" })).toThrow(
/bad\\nkey/,
);
});
});
+17
View File
@@ -7,8 +7,25 @@ import fs from "fs";
* and the downstream summary/tag steps read `steps.publish.outputs.version`
* (and `scope`), so every publish script must emit these after publishing.
* No-op outside CI (GITHUB_OUTPUT unset), e.g. when running locally.
*
* Keys and values must be single-line: the helper throws if any contains a
* newline or carriage return, since GITHUB_OUTPUT's `key=value` form cannot
* carry newlines (multi-line values would need the heredoc form, which this
* helper deliberately does not support).
*/
export function emitGithubOutputs(outputs: Record<string, string>): void {
for (const [key, value] of Object.entries(outputs)) {
if (/[\n\r]/.test(key)) {
throw new Error(
`emitGithubOutputs: key ${JSON.stringify(key)} contains a newline or carriage return; GITHUB_OUTPUT's key=value form cannot carry newlines (multi-line values would need the heredoc form, which this helper deliberately does not support).`,
);
}
if (/[\n\r]/.test(value)) {
throw new Error(
`emitGithubOutputs: value for key ${JSON.stringify(key)} contains a newline or carriage return; GITHUB_OUTPUT's key=value form cannot carry newlines (multi-line values would need the heredoc form, which this helper deliberately does not support).`,
);
}
}
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) return;
const lines = Object.entries(outputs)
+7 -1
View File
@@ -41,7 +41,7 @@ function main() {
if (!scope || !VALID_SCOPES.includes(scope)) {
console.error(
`Usage: prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--suffix <label>] [--dry-run]`,
`Usage: prerelease.ts --scope <${VALID_SCOPES.join("|")}> [--dry-run]`,
);
process.exit(1);
}
@@ -52,6 +52,12 @@ function main() {
// Read the version from package.json — already bumped by bump-prerelease.ts
// in the CI build job.
const packages = getPackagesForScope(scope);
if (packages.length === 0) {
console.error(
`No packages found for scope "${scope}" — refusing to emit a version for a publish that did nothing.`,
);
process.exit(1);
}
const publishVersion = packages[0]?.pkg.version ?? getCurrentVersion(scope);
console.log(`Scope: ${scope}`);
console.log(`Publishing version: ${publishVersion}`);
+8 -1
View File
@@ -161,9 +161,16 @@ async function main() {
// npm 11 uses GitHub Actions OIDC tokens for auth when id-token: write
// is granted, eliminating the need for long-lived NPM_TOKEN secrets.
// Skips packages already published at this version (idempotent retries).
const packages = getPackagesForScope(scope);
if (packages.length === 0) {
console.error(
`No packages found for scope "${scope}" — refusing to emit a version for a publish that did nothing.`,
);
process.exit(1);
}
console.log("\nPublishing packages...");
let skipped = 0;
for (const p of getPackagesForScope(scope)) {
for (const p of packages) {
const pubVersion = getPublishedVersion(p.name);
if (pubVersion === version) {
console.log(` Skipping ${p.name}@${version} (already published)`);