diff --git a/scripts/__tests__/release-dry-run-notes.test.mjs b/scripts/__tests__/release-dry-run-notes.test.mjs new file mode 100644 index 0000000000..2e82a61a0f --- /dev/null +++ b/scripts/__tests__/release-dry-run-notes.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const repoRoot = new URL("../..", import.meta.url).pathname.replace(/\/$/, ""); + +function writeExecutable(path, body) { + writeFileSync(path, body, { mode: 0o755 }); +} + +function createReleaseFixture() { + const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-dry-run-")); + const scriptsDir = join(fixtureDir, "scripts"); + const binDir = join(fixtureDir, "bin"); + const callLog = join(fixtureDir, "calls.log"); + + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(join(fixtureDir, "releases")); + mkdirSync(binDir); + writeFileSync(callLog, ""); + + copyFileSync(join(repoRoot, "scripts", "release.sh"), join(scriptsDir, "release.sh")); + chmodSync(join(scriptsDir, "release.sh"), 0o755); + + writeFileSync( + join(scriptsDir, "release-lib.sh"), + `#!/usr/bin/env bash +release_info() { echo "$@"; } +release_fail() { echo "Error: $*" >&2; exit 1; } +resolve_release_remote() { printf 'origin\\n'; } +fetch_release_remote() { :; } +git_current_branch() { printf 'master\\n'; } +get_last_stable_tag() { printf 'v2026.709.0\\n'; } +get_current_stable_version() { printf '2026.709.0\\n'; } +utc_date_iso() { printf '2026-07-10\\n'; } +list_public_package_info() { printf 'cli\\tpaperclipai\\t0.0.0\\n'; } +next_stable_version() { printf '2026.710.0\\n'; } +next_canary_version() { printf '2026.710.0-canary.0\\n'; } +release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; } +stable_tag_name() { printf 'v%s\\n' "$1"; } +canary_tag_name() { printf 'canary/v%s\\n' "$1"; } +require_clean_worktree() { :; } +require_npm_publish_auth() { :; } +git_local_tag_exists() { return 1; } +git_remote_tag_exists() { return 1; } +npm_package_version_exists() { return 1; } +set_public_package_version() { :; } +`, + ); + + writeExecutable( + join(scriptsDir, "release-registry-versions.mjs"), + `#!/usr/bin/env node +const [mode] = process.argv.slice(2); +if (mode === "fetch") { + process.stdout.write('{"paperclipai":[]}\\n'); + process.exit(0); +} +if (mode === "assert-absent") { + process.exit(0); +} +process.exit(2); +`, + ); + + writeExecutable( + join(binDir, "git"), + `#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "-C" ]; then + shift 2 +fi +printf 'git %s\\n' "$*" >> "$FAKE_CALL_LOG" +case "$1" in + rev-parse) + if [ "\${2:-}" = "HEAD" ]; then + echo abcdef1234567890 + exit 0 + fi + ;; + diff|ls-files) + exit 0 + ;; + checkout) + exit 0 + ;; +esac +exit 0 +`, + ); + + writeExecutable( + join(binDir, "pnpm"), + `#!/usr/bin/env bash +set -euo pipefail +printf 'pnpm %s\\n' "$*" >> "$FAKE_CALL_LOG" +if [ "$*" = "build" ]; then + echo "fixture stopped at workspace build" + exit 42 +fi +exit 0 +`, + ); + + return { binDir, callLog, fixtureDir, script: join(scriptsDir, "release.sh") }; +} + +function runRelease(args) { + const fixture = createReleaseFixture(); + const result = spawnSync(fixture.script, args, { + cwd: fixture.fixtureDir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${fixture.binDir}:${process.env.PATH}`, + FAKE_CALL_LOG: fixture.callLog, + }, + }); + + const calls = readFileSync(fixture.callLog, "utf8"); + rmSync(fixture.fixtureDir, { recursive: true, force: true }); + + return { + calls, + output: result.stdout + result.stderr, + status: result.status, + }; +} + +test("stable dry-run preview does not require a pre-authored release notes file", () => { + const result = runRelease(["stable", "--skip-verify", "--dry-run"]); + + assert.equal(result.status, 42); + assert.match(result.output, /==> Release plan/); + assert.match(result.output, /==> Step 2\/7: Building workspace artifacts/); + assert.doesNotMatch(result.output, /stable release notes file is required/); + assert.match(result.calls, /^pnpm build$/m); +}); + +test("stable publish still requires release notes before publish work starts", () => { + const result = runRelease(["stable", "--skip-verify"]); + + assert.equal(result.status, 1); + assert.match(result.output, /stable release notes file is required/); + assert.doesNotMatch(result.output, /==> Step 2\/7: Building workspace artifacts/); + assert.doesNotMatch(result.calls, /^pnpm /m); +}); diff --git a/scripts/release.sh b/scripts/release.sh index ed069d92a5..43355c0bcf 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -34,7 +34,7 @@ Notes: "canary" and create the git tag canary/vYYYY.MDD.P-canary.N. - Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and create the git tag vYYYY.MDD.P. - - Stable release notes must already exist at releases/vYYYY.MDD.P.md. + - Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md. - The script rewrites versions temporarily and restores the working tree on exit. Tags always point at the original source commit, not a generated release commit. @@ -166,7 +166,7 @@ NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")" require_clean_worktree require_npm_publish_auth "$dry_run" -if [ "$channel" = "stable" ] && [ ! -f "$NOTES_FILE" ]; then +if [ "$channel" = "stable" ] && [ "$dry_run" = false ] && [ ! -f "$NOTES_FILE" ]; then release_fail "stable release notes file is required at $NOTES_FILE before publishing stable." fi