250 lines
8.3 KiB
JavaScript
250 lines
8.3 KiB
JavaScript
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_prerelease_version() { printf '2026.710.0-%s.0\\n' "$1"; }
|
|
release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; }
|
|
stable_tag_name() { printf 'v%s\\n' "$1"; }
|
|
prerelease_tag_name() { printf '%s/v%s\\n' "$1" "$2"; }
|
|
require_channel_tag_at_head() {
|
|
if [ "\${FAKE_MISSING_CHANNEL_TAG:-}" = "$1" ]; then
|
|
echo "Error: HEAD has no $1/v* tag; this channel only publishes commits that already shipped a $1 release." >&2
|
|
exit 1
|
|
fi
|
|
echo "[fixture] require_channel_tag_at_head $1"
|
|
}
|
|
require_channel_tag_absent_at_head() {
|
|
if [ "\${FAKE_PRESENT_CHANNEL_TAG:-}" = "$1" ]; then
|
|
echo "Error: HEAD already shipped as $1/v2026.710.0-$1.0; delete that tag first if you really want to republish this commit on the $1 channel." >&2
|
|
exit 1
|
|
fi
|
|
echo "[fixture] require_channel_tag_absent_at_head $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, extraEnv = {}) {
|
|
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,
|
|
...extraEnv,
|
|
},
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("nightly dry-run publishes under the nightly identity without release notes", () => {
|
|
const result = runRelease(["nightly", "--skip-verify", "--dry-run"]);
|
|
|
|
assert.equal(result.status, 42);
|
|
assert.match(result.output, /\[fixture\] require_channel_tag_at_head canary/);
|
|
assert.match(result.output, /Nightly version: 2026\.710\.0-nightly\.0/);
|
|
assert.match(result.output, /Dist-tag: nightly/);
|
|
assert.match(result.output, /Git tag: nightly\/v2026\.710\.0-nightly\.0/);
|
|
assert.doesNotMatch(result.output, /stable release notes file is required/);
|
|
assert.match(result.calls, /^pnpm build$/m);
|
|
});
|
|
|
|
test("nightly refuses commits that never shipped a canary", () => {
|
|
const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
|
|
FAKE_MISSING_CHANNEL_TAG: "canary",
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /HEAD has no canary\/v\* tag/);
|
|
assert.doesNotMatch(result.calls, /^pnpm /m);
|
|
});
|
|
|
|
test("nightly refuses commits that already shipped as a nightly", () => {
|
|
const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
|
|
FAKE_PRESENT_CHANNEL_TAG: "nightly",
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /HEAD already shipped as nightly\/v/);
|
|
assert.doesNotMatch(result.calls, /^pnpm /m);
|
|
});
|
|
|
|
test("beta dry-run publishes under the beta identity without release notes", () => {
|
|
const result = runRelease(["beta", "--skip-verify", "--dry-run"]);
|
|
|
|
assert.equal(result.status, 42);
|
|
assert.match(result.output, /\[fixture\] require_channel_tag_at_head nightly/);
|
|
assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/);
|
|
assert.match(result.output, /Dist-tag: beta/);
|
|
assert.match(result.output, /Git tag: beta\/v2026\.710\.0-beta\.0/);
|
|
assert.doesNotMatch(result.output, /stable release notes file is required/);
|
|
assert.match(result.calls, /^pnpm build$/m);
|
|
});
|
|
|
|
test("beta refuses commits that already shipped as a beta", () => {
|
|
const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
|
|
FAKE_PRESENT_CHANNEL_TAG: "beta",
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /HEAD already shipped as beta\/v/);
|
|
assert.doesNotMatch(result.calls, /^pnpm /m);
|
|
});
|
|
|
|
test("beta --from-candidate waives the nightly requirement but keeps the duplicate guard", () => {
|
|
const result = runRelease(["beta", "--from-candidate", "--skip-verify", "--dry-run"], {
|
|
FAKE_MISSING_CHANNEL_TAG: "nightly",
|
|
});
|
|
|
|
assert.equal(result.status, 42);
|
|
assert.doesNotMatch(result.output, /require_channel_tag_at_head nightly/);
|
|
assert.match(result.output, /\[fixture\] require_channel_tag_absent_at_head beta/);
|
|
assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/);
|
|
assert.match(result.calls, /^pnpm build$/m);
|
|
});
|
|
|
|
test("--from-candidate is rejected outside the beta channel", () => {
|
|
const result = runRelease(["nightly", "--from-candidate", "--skip-verify", "--dry-run"]);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /--from-candidate only applies to the beta channel/);
|
|
assert.doesNotMatch(result.calls, /^pnpm /m);
|
|
});
|
|
|
|
test("beta refuses commits that never shipped a nightly", () => {
|
|
const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
|
|
FAKE_MISSING_CHANNEL_TAG: "nightly",
|
|
});
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.output, /HEAD has no nightly\/v\* tag/);
|
|
assert.doesNotMatch(result.calls, /^pnpm /m);
|
|
});
|