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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-13 21:11:07 -05:00 committed by GitHub
parent 6f204605ad
commit 6a36ae47fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 152 additions and 2 deletions

View File

@ -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);
});

View File

@ -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