From 6a36ae47feb77db532844f6f34bfc93f75ce7d2a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:11:07 -0500 Subject: [PATCH] Fix stable release dry-run notes gate (#9334) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The release subsystem uses GitHub Actions dry-run previews so maintainers can validate stable and canary release behavior before publishing. > - Stable release publishing needs a release notes gate so real `latest` publishes never happen without authored notes. > - The same gate was also blocking stable dry-run previews, which made release QA fail before publish-sensitive work could be previewed. > - This pull request narrows the notes requirement to real non-dry-run stable publishes. > - The benefit is that stable dry-run dispatches can preview the release without same-day notes, while real stable publishes remain protected. ## Linked Issues or Issue Description No public GitHub issue exists for this release-QA blocker, so the bug is described inline below. ### What happened? Stable dry-run release previews fail when the same-day release notes file is absent. The release script runs the stable notes-file gate before release preview work even when `--dry-run` is set. ### Expected behavior `./scripts/release.sh stable --dry-run` should preview the stable release without requiring `releases/vYYYY.MDD.P.md`. Real non-dry-run stable publishes must still fail before build/publish work starts when the notes file is missing. ### Steps to reproduce 1. Check out current `master` before this fix. 2. Ensure the computed same-day stable release notes file does not exist under `releases/`. 3. Run `./scripts/release.sh stable --skip-verify --dry-run`. 4. Observe that the script exits with `stable release notes file is required` instead of reaching the release preview plan. ### Paperclip version or commit Reproduced on `master` at `9a1d4b7983dfd50e8eb40ee9770e44999d405f60`. ### Deployment mode Built from source / GitHub Actions release workflow. ## What Changed - Narrowed the stable release notes gate to `channel=stable` and `dry_run=false`. - Updated the release script usage note to say the notes file is required for non-dry-run stable releases. - Added a targeted Node test covering dry-run allowed behavior and non-dry-run blocked behavior. - Stubbed release fixture registry-version checks so the test isolates the notes gate without hitting npm. ## Verification - `node --test scripts/__tests__/release-dry-run-notes.test.mjs` passed with 2/2 subtests. - `bash -n scripts/release.sh` exited 0. ## Risks Low risk. The behavior change only relaxes the notes-file gate for stable dry-runs. The new test verifies real non-dry-run stable publish still fails before build/publish work starts when notes are missing. ## Model Used OpenAI Codex, GPT-5-based coding agent, with shell/tool execution in a local repository workspace. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../__tests__/release-dry-run-notes.test.mjs | 150 ++++++++++++++++++ scripts/release.sh | 4 +- 2 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 scripts/__tests__/release-dry-run-notes.test.mjs 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