Files
PaperClipAI/scripts/draft-stable-notes.test.mjs
T
Devin Foley e434829889 fix(release): draft-notes baseline survives candidate-cut stables (#11647)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release workflow drafts the upcoming stable's notes skeleton at
beta publish, ranging from the last stable to the beta's source commit
> - Stables cut from candidate branches leave their tag off master's
lineage, so the ancestor-only baseline rule skips them and falls back a
whole release too far
> - The first live draft did exactly that: it ranged from v2026.722.0
instead of the just-shipped v2026.817.0 and re-included everything
already released
> - This pull request replaces the baseline rule with a merge-base walk
over stable tags and adds a candidate-branch regression fixture
> - The benefit is a correct draft range for every stable lineage:
master-cut, candidate-cut, and promoted-older-source

## Linked Issues or Issue Description

**What happened?**

The first `draft_stable_notes` run (for beta `2026.818.0-beta.1`)
generated `releases/beta/v2026.818.0-beta.1.md` with the range
`v2026.722.0..664052f8e` — 311-commits-worth of already-shipped
v2026.817.0 content re-included.

**Expected behavior**

The draft ranges from the point the shipped stable's content diverges
from the beta source. For v2026.817.0 (cut from
`candidate/release-2026.817.0`) that is the promoted source commit
`8f7b8b3fd`, giving the 172 commits of genuinely-new work.

**Steps to reproduce**

Ship a stable from a candidate branch (tag lands off master's lineage),
then publish a beta from master and read the generated skeleton's range
line.

**Paperclip version or commit**

master at `664052f8e`.

Related (not duplicates): #11567 introduced the generator; its
ancestor-only rule was itself a review fix for the newest-by-version
rule, and this PR is the second iteration with the lineage case the
first fix missed.

## What Changed

- `scripts/draft-stable-notes.sh`: the range start comes from walking
stable tags newest-first and taking the first whose merge-base with the
beta source is a proper ancestor of the source. Candidate-cut stables
resolve to the promoted commit, master-lineage stables to the tag
itself, and tags containing the source are skipped (an older-source
promotion cannot produce an empty range). The skeleton header prints a
runnable short-sha range with the stable tag as a labeled baseline.
- `scripts/draft-stable-notes.test.mjs`: new regression fixture with the
stable tag on an unmerged candidate branch; the existing older-source
and fallback fixtures still pass unchanged.

## Verification

- `node --test scripts/draft-stable-notes.test.mjs` — 8 pass, including
the new fixture.
- Regenerated the live `2026.818.0-beta.1` draft against the real
repository: range start resolves to `v2026.817.0 (merge-base
8f7b8b3fd)`, 172 commits, shipped-in-stable subjects absent, post-beta
subjects present.
- `bash -n` on the script.

## Risks

- Low risk: the script only produces a draft file; publishes are
untouched. The live skeleton branch for `2026.818.0-beta.1` is
regenerated with the corrected output (machine-owned branch, force-push
by design).

## Model Used

Claude Fable 5 (Claude Code)

## Pre-submission 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
2026-08-18 14:51:58 -07:00

160 lines
5.4 KiB
JavaScript

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } 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(/\/$/, "");
const script = join(repoRoot, "scripts", "draft-stable-notes.sh");
function git(dir, ...args) {
return execFileSync("git", ["-C", dir, ...args], {
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.invalid",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.invalid",
},
});
}
function commit(dir, subject) {
git(dir, "commit", "--allow-empty", "-m", subject);
}
function makeFixtureRepo() {
const dir = mkdtempSync(join(tmpdir(), "draft-stable-notes-"));
git(dir, "init", "-q", "-b", "master");
return dir;
}
function runDraft(dir, betaVersion, extraArgs = []) {
const out = join(dir, "draft.md");
const stdout = execFileSync(
"bash",
[script, betaVersion, "--repo-dir", dir, "--out", out, ...extraArgs],
{ encoding: "utf8" }
);
return { stdout, body: readFileSync(out, "utf8") };
}
test("drafts grouped notes from the newest stable tag to the beta source", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: ancient work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: add widgets (#2)");
commit(dir, "fix(ui): unbreak widget list (#3)");
commit(dir, "chore: bump deps (#4)");
commit(dir, "feat(api)!: breaking widget API (#5)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /^# Paperclip stable draft — from beta 2026\.101\.0-beta\.0/);
assert.match(body, /## Features\n\n- feat\(api\)!: breaking widget API \(#5\)\n- feat: add widgets \(#2\)/);
assert.match(body, /## Fixes\n\n- fix\(ui\): unbreak widget list \(#3\)/);
assert.match(body, /## Other changes\n\n- chore: bump deps \(#4\)/);
assert.doesNotMatch(body, /ancient work/);
});
test("uses the nearest ancestor stable tag, not the newest by version", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: old work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: mid work (#2)");
git(dir, "tag", "beta/v2026.150.0-beta.0");
commit(dir, "feat: new work (#3)");
git(dir, "tag", "v2026.200.0");
// Promoting the older source must draft against its own lineage's last
// stable (v2026.100.0), not the newer v2026.200.0 that already contains
// it — that range would be empty.
const { body } = runDraft(dir, "2026.150.0-beta.0");
assert.match(body, /- feat: mid work \(#2\)/);
assert.doesNotMatch(body, /old work/);
assert.doesNotMatch(body, /new work/);
});
test("starts from the merge-base when the last stable was cut from a candidate branch", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: shipped in the stable (#1)");
git(dir, "checkout", "-q", "-b", "candidate");
commit(dir, "docs: release notes only (#2)");
git(dir, "tag", "v2026.200.0");
git(dir, "checkout", "-q", "master");
commit(dir, "feat: next-release work (#3)");
git(dir, "tag", "beta/v2026.201.0-beta.0");
// The stable tag sits on the unmerged candidate branch, so it is not an
// ancestor of the beta source. The range must start at its merge-base
// with the source (the promoted commit), not fall back past it.
const { body } = runDraft(dir, "2026.201.0-beta.0");
assert.match(body, /- feat: next-release work \(#3\)/);
assert.doesNotMatch(body, /shipped in the stable/);
assert.doesNotMatch(body, /release notes only/);
});
test("falls back to the previous beta tag when no stable tag exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: first-train work (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
commit(dir, "fix: second-train fix (#2)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /- fix: second-train fix \(#2\)/);
assert.doesNotMatch(body, /first-train work/);
});
test("covers full history when no earlier marker exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: the very first commit (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
const { body } = runDraft(dir, "2026.100.0-beta.0");
assert.match(body, /- feat: the very first commit \(#1\)/);
});
test("writes to releases/beta/v<version>.md inside the repo by default", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: default path (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
execFileSync("bash", [script, "2026.100.0-beta.0", "--repo-dir", dir], {
encoding: "utf8",
});
const body = readFileSync(
join(dir, "releases", "beta", "v2026.100.0-beta.0.md"),
"utf8"
);
assert.match(body, /- feat: default path \(#1\)/);
});
test("rejects a malformed beta version", () => {
const dir = makeFixtureRepo();
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});
test("rejects a beta version whose tag does not exist", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: unrelated (#1)");
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0-beta.9", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});