mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## 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 the moment a beta publishes > - That skeleton was a bare list of commit subjects, so the notes only reached the shipped stable's depth after a later authoring pass during the soak > - Stable release notes are consistently verbose and thorough; the initial draft should start that way too > - This pull request nests each referenced PR's own summary under its subject line at creation time, and states the density bar in the authoring skill > - The benefit is a thorough raw document from day one of the soak, with no LLM tokens in Actions ## Linked Issues or Issue Description **What existing behavior does this improve?** The `draft_stable_notes` skeleton generated at beta publish (`scripts/draft-stable-notes.sh`). **Current behavior** The skeleton groups bare commit subjects by conventional-commit type. All substance arrives later, when a maintainer or agent rewrites it — reviewed maintainer feedback: stable notes are a lot more verbose, and the initial beta notes should be consistent with that. **Proposed behavior** Each subject that references a PR carries that PR's own summary nested beneath it — the PR template's "What Changed" bullets, else the first prose lines — fetched best-effort via `gh` and skipped silently when unavailable. The release-changelog skill now states the density bar explicitly: the beta-keyed draft ships verbatim as the stable's notes and is written at the previous stable's depth from the first pass. **Reason and benefit** The notes author starts from a thorough raw document instead of a commit list, and beta-time notes match the verbosity the stable will ship with. ## What Changed - `scripts/draft-stable-notes.sh`: `enrich_pr` nests PR summaries under subjects; best-effort (`gh` failure or `DRAFT_NOTES_SKIP_PR_ENRICHMENT=1` degrades to today's output); pipefail-safe when a "What Changed" section has no bullets. - `.github/workflows/release.yml`: the `draft_stable_notes` step gets `GH_TOKEN` so `gh` can read PR bodies. - `.agents/skills/release-changelog/SKILL.md`: "write at full stable depth from the first pass" guideline. - `scripts/draft-stable-notes.test.mjs`: three new tests — enrichment rendering via a fake `gh`, silent degradation without one, and the sparse-body case that previously killed the script under `set -o pipefail`. ## Verification - `node --test scripts/draft-stable-notes.test.mjs` — 11 pass. - Live run against the real repository for the current beta (`2026.818.0-beta.1`, 172 commits): exit 0, 439 nested summary lines; spot-checked entries carry the correct PRs' What Changed bullets. - `bash -n` on the script; `release.yml` re-parsed as YAML. ## Risks - Low: the publish path is untouched; enrichment is read-only `gh` calls in the post-publish draft job and degrades to the current skeleton on any failure. Roughly one API call per commit in the range (~170 today) — well inside the token's rate budget, adds a couple of minutes to a job with a 10-minute timeout. ## 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
230 lines
7.3 KiB
JavaScript
230 lines
7.3 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, 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(/\/$/, "");
|
|
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("nests PR summaries under subjects when gh can serve them", () => {
|
|
const dir = makeFixtureRepo();
|
|
commit(dir, "feat: enriched work (#42)");
|
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
|
|
|
const binDir = join(dir, "fake-bin");
|
|
execFileSync("mkdir", ["-p", binDir]);
|
|
writeFileSync(
|
|
join(binDir, "gh"),
|
|
`#!/usr/bin/env bash
|
|
echo "## Thinking Path"
|
|
echo ""
|
|
echo "## What Changed"
|
|
echo ""
|
|
echo "- Adds the enriched thing"
|
|
echo "- Covers it with tests"
|
|
echo ""
|
|
echo "## Risks"
|
|
`,
|
|
{ mode: 0o755 }
|
|
);
|
|
|
|
const out = join(dir, "draft.md");
|
|
execFileSync(
|
|
"bash",
|
|
[script, "2026.100.0-beta.0", "--repo-dir", dir, "--out", out],
|
|
{ encoding: "utf8", env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` } }
|
|
);
|
|
const body = readFileSync(out, "utf8");
|
|
assert.match(body, /- feat: enriched work \(#42\)\n > - Adds the enriched thing\n > - Covers it with tests/);
|
|
});
|
|
|
|
test("survives a PR body whose What Changed section has no bullets", () => {
|
|
const dir = makeFixtureRepo();
|
|
commit(dir, "feat: sparse body (#9)");
|
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
|
|
|
const binDir = join(dir, "fake-bin");
|
|
execFileSync("mkdir", ["-p", binDir]);
|
|
writeFileSync(
|
|
join(binDir, "gh"),
|
|
`#!/usr/bin/env bash
|
|
echo "## What Changed"
|
|
echo ""
|
|
echo "## Risks"
|
|
`,
|
|
{ mode: 0o755 }
|
|
);
|
|
|
|
const out = join(dir, "draft.md");
|
|
execFileSync(
|
|
"bash",
|
|
[script, "2026.100.0-beta.0", "--repo-dir", dir, "--out", out],
|
|
{ encoding: "utf8", env: { ...process.env, PATH: `${binDir}:${process.env.PATH}` } }
|
|
);
|
|
const body = readFileSync(out, "utf8");
|
|
assert.match(body, /- feat: sparse body \(#9\)\n/);
|
|
});
|
|
|
|
test("skeleton stays clean when enrichment is unavailable", () => {
|
|
const dir = makeFixtureRepo();
|
|
commit(dir, "feat: plain work (#7)");
|
|
git(dir, "tag", "beta/v2026.100.0-beta.0");
|
|
|
|
const { body } = runDraft(dir, "2026.100.0-beta.0");
|
|
|
|
assert.match(body, /- feat: plain work \(#7\)\n/);
|
|
assert.doesNotMatch(body, / > /);
|
|
});
|
|
|
|
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",
|
|
})
|
|
);
|
|
});
|