From 4d9f9d9edfef08d18355ce77edbdf414643f2f0b Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 14 Jun 2026 23:36:19 +0000 Subject: [PATCH] fix(ci): aggregated release notes across silent intervening tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci-release-notes.ts previously extracted only the single target-version section, so changelog entries finalized under tags pushed without a GitHub Release (e.g. v15.12.5 and v15.12.6 — collateral from the pre-#2564 release-cancellation bug) were stranded out of the next published release body. The generator now walks the range (latest-published-release, target], resolved via 'gh release list', and merges every in-range '## [X.Y.Z]' section per package — grouped by '### ' with bullet-level dedup so post-release changelog flattening cannot surface the same entry twice. Versions iterate newest-first so newer phrasing wins on dup resolution, and categories are sorted into the canonical Breaking/Added/Changed/Fixed/Removed order regardless of source order. Falls back to legacy single-version extraction when 'gh' is unavailable or no prior published release resolves (safe no-op); 'OMP_RELEASE_NOTES_FLOOR=v15.12.4' overrides the lookup for manual re-runs (empty string forces legacy mode). Adds scripts/ci-release-notes.test.ts covering: range inclusion above floor, target-inclusive boundary, dedup of bullets flattened forward into multiple versions, canonical category ordering, the null-floor legacy fallback, empty version sections skipped, and no empty-category emission when dedup drains a bucket. Wired into 'bun run test:scripts'. Fixes #2596 --- package.json | 2 +- packages/coding-agent/CHANGELOG.md | 1 + scripts/ci-release-notes.test.ts | 135 +++++++++++++ scripts/ci-release-notes.ts | 294 +++++++++++++++++++++++------ 4 files changed, 378 insertions(+), 54 deletions(-) create mode 100644 scripts/ci-release-notes.test.ts mode change 100644 => 100755 scripts/ci-release-notes.ts diff --git a/package.json b/package.json index ee8717c72..c1e36e5bb 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "build:native": "bun --cwd=packages/natives run build", "test": "bun run --parallel test:ts test:rs", "test:ts": "GITHUB_ACTIONS= bun run --workspaces --if-present test -- --only-failures && bun run test:scripts", - "test:scripts": "bun test scripts/ci-concurrency.test.ts", + "test:scripts": "bun test scripts/ci-concurrency.test.ts scripts/ci-release-notes.test.ts", "test:rs": "bun scripts/run-rs-task.ts test:rs", "check": "bun run --parallel check:ts check:rs", "check:ts": "bun run check:tools && bun run --workspaces --if-present check", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 033956242..06d11d2e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -166,6 +166,7 @@ ### Fixed - Fixed Kokoro TTS setup loading the workspace/global `@huggingface/transformers` runtime before the side-installed Kokoro runtime, which could leave `onnxruntime-node@1.26.0` bound to an older `libonnxruntime.so.1` and fail with `VERS_1.26.0` missing ([#2591](https://github.com/can1357/oh-my-pi/issues/2591)). +- Fixed `scripts/ci-release-notes.ts` stranding curated changelog entries from intervening *silent* tags (a `vX.Y.Z` tag pushed without a GitHub Release, e.g. the `v15.12.5`/`v15.12.6` casualties of the pre-#2564 release-cancellation bug). The generator now walks `(latest-published-release, target]` — resolved via `gh release list` from the `release_github` CI job — and merges every in-range `## [X.Y.Z]` section per package, grouped by `### ` with bullet-level dedupe so post-release changelog flattening cannot duplicate entries. Falls back to the legacy single-version extraction when no prior published release resolves, and `OMP_RELEASE_NOTES_FLOOR=v15.12.4` overrides the lookup for manual rebuilds ([#2596](https://github.com/can1357/oh-my-pi/issues/2596)). - Fixed `Test & smoke (TS)` CI timeouts caused by parallel test files racing on the process-global Settings singleton. `CustomEditor` now accepts a `magicKeywordsEnabledOverride` injection point so the shimmer-gate test can assert behaviour without calling `resetSettingsForTest()` / `Settings.init()`; the "streaming tool call preview height" describe drops its gratuitous Settings reset+init. Production wiring is unchanged ([#2582](https://github.com/can1357/oh-my-pi/issues/2582)) - Fixed MCP OAuth fallback rendering to show a short terminal hyperlink and keep the raw authorization URL on one unwrapped copy line ([#2121](https://github.com/can1357/oh-my-pi/issues/2121)). - Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts diff --git a/scripts/ci-release-notes.test.ts b/scripts/ci-release-notes.test.ts new file mode 100644 index 000000000..3f6a77a76 --- /dev/null +++ b/scripts/ci-release-notes.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; +import { + compareVersions, + enumerateChangelogVersions, + mergePackageSection, +} from "./ci-release-notes"; + +const FIXTURE = [ + "# Changelog", + "", + "## [Unreleased]", + "", + "### Added", + "", + "- Unreleased entry not in any tag yet.", + "", + "## [15.13.0] - 2026-06-14", + "", + "### Fixed", + "", + "- Fixed unknown `--`-prefixed flags being silently consumed as prompt text.", + "- Fixed something only in 15.13.0.", + "", + "### Removed", + "", + "- Removed a deprecated thing in 15.13.0.", + "", + "## [15.12.6] - 2026-06-14", + "", + "### Breaking Changes", + "", + "- Removed `writeLine`/`writeLineSync` from the public SessionStorageWriter contract.", + "", + "### Added", + "", + "- Added package-level exports for session context.", + "", + "## [15.12.5] - 2026-06-13", + "", + "### Changed", + "", + "- Changed terminal resize handling to paint only the visible viewport.", + "", + "### Fixed", + "", + "- Fixed unknown `--`-prefixed flags being silently consumed as prompt text.", + "", + "## [15.12.4] - 2026-06-13", + "", + "### Added", + "", + "- Predates the silent-tag window; must not appear when floor=15.12.4.", + "", +].join("\n"); + +describe("compareVersions", () => { + it("orders semver tags numerically across all components", () => { + expect(compareVersions("15.12.5", "15.13.0") < 0).toBe(true); + expect(compareVersions("v15.13.0", "15.12.6") > 0).toBe(true); + expect(compareVersions("15.12.6", "15.12.6") === 0).toBe(true); + // Numeric (not lexicographic) — 15.2.0 < 15.13.0. + expect(compareVersions("15.2.0", "15.13.0") < 0).toBe(true); + }); +}); + +describe("enumerateChangelogVersions", () => { + it("returns every semver heading in document order, skipping Unreleased", () => { + const spans = enumerateChangelogVersions(FIXTURE); + expect(spans.map(s => s.version)).toEqual(["15.13.0", "15.12.6", "15.12.5", "15.12.4"]); + }); + + it("bounds each span by the next `## [` heading (Unreleased included as boundary)", () => { + const spans = enumerateChangelogVersions(FIXTURE); + const lines = FIXTURE.split("\n"); + for (const span of spans) { + expect(lines[span.start]).toMatch(/^## \[\d+\.\d+\.\d+\]/); + // Body never bleeds into the next heading. + for (let i = span.start + 1; i < span.end; i++) { + expect(lines[i].startsWith("## [")).toBe(false); + } + } + }); +}); + +describe("mergePackageSection", () => { + it("includes every silent-tag section above floor up to target inclusive", () => { + const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0"); + // 15.12.6 and 15.12.5 unique fingerprints must land. + expect(merged).toContain( + "Removed `writeLine`/`writeLineSync` from the public SessionStorageWriter contract.", + ); + expect(merged).toContain("Added package-level exports for session context."); + expect(merged).toContain("Changed terminal resize handling to paint only the visible viewport."); + // 15.12.4 entry stays excluded — it is the floor. + expect(merged).not.toContain("Predates the silent-tag window"); + // Unreleased never leaks. + expect(merged).not.toContain("Unreleased entry"); + }); + + it("dedupes bullets flattened forward into multiple versions", () => { + const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0"); + const dupRegex = /Fixed unknown `--`-prefixed flags being silently consumed as prompt text\./g; + expect(merged.match(dupRegex)?.length).toBe(1); + }); + + it("groups bullets under the canonical category order regardless of source-version order", () => { + const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0"); + // Expected canonical order: Breaking Changes → Added → Changed → Fixed → Removed. + const headings = [...merged.matchAll(/^### (.+)$/gm)].map(m => m[1]); + expect(headings).toEqual(["Breaking Changes", "Added", "Changed", "Fixed", "Removed"]); + }); + + it("floor=null reproduces single-version (legacy) extraction for the target", () => { + const merged = mergePackageSection(FIXTURE, null, "15.13.0"); + expect(merged).toContain("Fixed something only in 15.13.0"); + expect(merged).toContain("Removed a deprecated thing in 15.13.0"); + // Anything below the target stays out when no floor is set. + expect(merged).not.toContain("writeLine"); + expect(merged).not.toContain("Added package-level exports"); + }); + + it("returns empty string when no version in the requested range carries body content", () => { + const empty = ["# Changelog", "", "## [15.13.0] - 2026-06-14", "", "## [15.12.6] - 2026-06-14"] + .join("\n"); + expect(mergePackageSection(empty, "15.12.5", "15.13.0")).toBe(""); + }); + + it("never emits a category with only blank/whitespace bullets after dedup", () => { + // If 15.13.0 already pulled the only Fixed bullet, an older section + // contributing the identical bullet must not produce an empty + // `### Fixed` heading by itself. + const merged = mergePackageSection(FIXTURE, "15.12.4", "15.13.0"); + expect(merged).not.toMatch(/### Fixed\s*\n\s*(### |$)/); + }); +}); diff --git a/scripts/ci-release-notes.ts b/scripts/ci-release-notes.ts old mode 100644 new mode 100755 index cecf099be..8e69ab781 --- a/scripts/ci-release-notes.ts +++ b/scripts/ci-release-notes.ts @@ -2,49 +2,179 @@ /** * Generate aggregated release notes from per-package CHANGELOG.md files. * - * Reads the version from `GITHUB_REF_NAME` (or the first CLI arg, with or - * without a leading `v`), then collects the `## [version]` section of every - * `packages/*\/CHANGELOG.md` and emits a single markdown document grouped by - * `package.json` `name`. Sections without entries are skipped. + * Walks the version range `(latest-published-release, target]` so changelog + * sections finalized under intervening *silent* tags (a `vX.Y.Z` tag that + * exists on the remote but has no GitHub Release — most often because a CI + * concurrency-cancel killed the publish job, #2596 / #2564) are rolled into + * the next published release body. Sections are grouped by `package.json` + * `name`, then merged per `### ` bullet bucket. Bullet lines are + * deduplicated by exact trimmed text so post-release changelog flattening + * (`fix-changelogs`) does not surface the same entry twice. Sections without + * entries are skipped. * * Usage: * bun scripts/ci-release-notes.ts # writes release-notes.md * bun scripts/ci-release-notes.ts v15.4.3 # explicit tag/version * bun scripts/ci-release-notes.ts 15.4.3 notes.md # custom output path * + * The lower bound is resolved by `gh release list`. Set + * `OMP_RELEASE_NOTES_FLOOR=v15.12.4` to override (empty string forces + * single-version mode, matching the pre-#2596 behavior). `OMP_REPO` / + * `GITHUB_REPOSITORY` control the queried repo. + * * Intended for the `release_github` CI job: the output is passed to * `softprops/action-gh-release` via `body_path:`. The action's * `generate_release_notes: true` still appends the auto-generated PR list - * underneath, so this only adds curated context — it does not replace it. + * underneath; this only adds curated context. */ -import { Glob } from "bun"; +import { $, Glob } from "bun"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); +const REPO = process.env.OMP_REPO ?? process.env.GITHUB_REPOSITORY ?? "can1357/oh-my-pi"; -function stripVPrefix(tag: string): string { - return tag.replace(/^v/, "").trim(); +// Canonical ordering used by `fix-changelogs`; unknown categories sort +// alphabetically after these. +const CATEGORY_ORDER = ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"] as const; + +/** Compare two `X.Y.Z` (or `vX.Y.Z`) version strings; non-semver returns 0. */ +export function compareVersions(a: string, b: string): number { + const am = a.replace(/^v/, "").trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + const bm = b.replace(/^v/, "").trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!am || !bm) return 0; + if (am[1] !== bm[1]) return Number(am[1]) - Number(bm[1]); + if (am[2] !== bm[2]) return Number(am[2]) - Number(bm[2]); + return Number(am[3]) - Number(bm[3]); } -function extractVersionSection(content: string, version: string): string { +export interface ChangelogVersionSpan { + version: string; + /** 0-indexed line of the `## [X.Y.Z]` heading. */ + start: number; + /** 0-indexed line just past the last line of this version's body (exclusive). */ + end: number; +} + +/** + * Locate every `## [X.Y.Z]` heading in a changelog and compute the line span + * up to (but not including) the next `## [` heading. `## [Unreleased]` and + * other non-semver `## [...]` headings are ignored, but they still act as + * span boundaries for the preceding version. + */ +export function enumerateChangelogVersions(content: string): ChangelogVersionSpan[] { const lines = content.split("\n"); - const headingPrefix = `## [${version}]`; - let start = -1; + const spans: ChangelogVersionSpan[] = []; + // Indexes of *any* `## [` heading (including Unreleased) so a version's + // span ends at the next heading of any kind. + const headingIdx: number[] = []; for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith(headingPrefix)) { - start = i + 1; - break; - } + if (lines[i].startsWith("## [")) headingIdx.push(i); } - if (start < 0) return ""; - let end = lines.length; - for (let i = start; i < lines.length; i++) { - if (lines[i].startsWith("## [")) { - end = i; - break; - } + for (const idx of headingIdx) { + const m = lines[idx].match(/^## \[(\d+\.\d+\.\d+)\]/); + if (!m) continue; + const nextIdx = headingIdx.find(j => j > idx) ?? lines.length; + spans.push({ version: m[1], start: idx, end: nextIdx }); } - return lines.slice(start, end).join("\n").trim(); + return spans; +} + +/** + * Merge `(floorExclusive, targetInclusive]` version sections from a single + * package's changelog into one combined body, grouped by `### `. + * + * Versions iterate newest → oldest so newer phrasing wins when a bullet was + * flattened forward by `fix-changelogs` and ends up in both sections. + * `floorExclusive === null` → take only the target version (legacy behavior). + * Returns "" when no in-range version contributes any bullet. + */ +export function mergePackageSection( + content: string, + floorExclusive: string | null, + targetInclusive: string, +): string { + const spans = enumerateChangelogVersions(content) + .filter(v => { + if (compareVersions(v.version, targetInclusive) > 0) return false; + if (floorExclusive === null) return compareVersions(v.version, targetInclusive) === 0; + return compareVersions(v.version, floorExclusive) > 0; + }) + .sort((a, b) => compareVersions(b.version, a.version)); + if (spans.length === 0) return ""; + + const lines = content.split("\n"); + const seenCategories: string[] = []; // first-seen order + const buckets = new Map(); + const seenLines = new Set(); + + for (const span of spans) { + let currentCat: string | null = null; + let buf: string[] = []; + const flushCurrent = () => { + if (currentCat === null || buf.length === 0) return; + let bucket = buckets.get(currentCat); + if (!bucket) { + bucket = []; + buckets.set(currentCat, bucket); + seenCategories.push(currentCat); + } + for (const line of buf) { + const key = line.trim(); + if (key.length === 0) continue; + if (seenLines.has(key)) continue; + seenLines.add(key); + bucket.push(line); + } + }; + // Skip the `## [X.Y.Z]` heading line itself. + for (let i = span.start + 1; i < span.end; i++) { + const line = lines[i]; + const catMatch = line.match(/^### (.+?)\s*$/); + if (catMatch) { + flushCurrent(); + currentCat = catMatch[1]; + buf = []; + continue; + } + // Pre-category prose (rare; usually blank padding) is dropped — there + // is no surrounding `###` to attribute it to in the merged output. + if (currentCat === null) continue; + buf.push(line); + } + flushCurrent(); + } + + if (seenCategories.length === 0) return ""; + + seenCategories.sort((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a as (typeof CATEGORY_ORDER)[number]); + const bi = CATEGORY_ORDER.indexOf(b as (typeof CATEGORY_ORDER)[number]); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return a.localeCompare(b); + }); + + const out: string[] = []; + for (const cat of seenCategories) { + const bucket = buckets.get(cat) ?? []; + // Collapse runs of blank lines and strip trailing blanks per bucket. + const collapsed: string[] = []; + let prevBlank = false; + for (const line of bucket) { + const blank = line.trim().length === 0; + if (blank && prevBlank) continue; + collapsed.push(line); + prevBlank = blank; + } + while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim().length === 0) { + collapsed.pop(); + } + if (collapsed.length === 0) continue; + out.push(`### ${cat}`, "", ...collapsed, ""); + } + while (out.length > 0 && out[out.length - 1] === "") out.pop(); + return out.join("\n"); } async function loadPackageName(pkgDir: string): Promise { @@ -56,37 +186,95 @@ async function loadPackageName(pkgDir: string): Promise { } } -const tagInput = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ""; -if (!tagInput) { - console.error( - "Error: version not provided. Pass as argv (e.g. `v15.4.3`) or set GITHUB_REF_NAME.", +/** + * Resolve the highest published, non-prerelease, non-draft semver tag strictly + * below `targetVersion`. Falls back to `null` on error or no candidate, which + * downgrades the script to legacy single-version extraction. + * + * `OMP_RELEASE_NOTES_FLOOR` env override: + * - unset → query `gh` (CI default) + * - `vX.Y.Z` → use as-is (manual rerun: "I know the floor") + * - empty string → force single-version mode (legacy escape hatch) + */ +async function resolvePublishedFloorTag(targetVersion: string): Promise { + const override = process.env.OMP_RELEASE_NOTES_FLOOR; + if (override !== undefined) { + const stripped = override.replace(/^v/, "").trim(); + return stripped.length === 0 ? null : stripped; + } + const res = + await $`gh release list --repo ${REPO} --limit 200 --exclude-drafts --exclude-pre-releases --json tagName,isDraft,isPrerelease` + .quiet() + .nothrow(); + if (res.exitCode !== 0) { + console.warn( + `Warning: gh release list failed (exit ${res.exitCode}); falling back to single-version notes. stderr: ${res.stderr.toString().trim()}`, + ); + return null; + } + let raw: unknown; + try { + raw = JSON.parse(res.stdout.toString()); + } catch (err) { + console.warn( + `Warning: failed to parse gh release list output: ${(err as Error).message}; falling back to single-version notes.`, + ); + return null; + } + if (!Array.isArray(raw)) return null; + const candidates = (raw as Array<{ tagName?: unknown; isDraft?: unknown; isPrerelease?: unknown }>) + .filter(t => t.isDraft !== true && t.isPrerelease !== true) + .map(t => (typeof t.tagName === "string" ? t.tagName : "")) + .filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)) + .filter(tag => compareVersions(tag, targetVersion) < 0) + .sort((a, b) => compareVersions(b, a)); + return candidates[0]?.replace(/^v/, "") ?? null; +} + +async function main(): Promise { + const tagInput = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ""; + if (!tagInput) { + console.error( + "Error: version not provided. Pass as argv (e.g. `v15.4.3`) or set GITHUB_REF_NAME.", + ); + process.exit(1); + } + const version = tagInput.replace(/^v/, "").trim(); + const outputPath = process.argv[3] ?? "release-notes.md"; + const floor = await resolvePublishedFloorTag(version); + if (floor) { + console.log(`Aggregating CHANGELOG sections in (${floor}, ${version}].`); + } else { + console.log(`No prior published release resolved; emitting only ## [${version}] sections.`); + } + + const sections: string[] = []; + const changelogPaths = await Array.fromAsync(changelogGlob.scan(".")); + changelogPaths.sort(); + for (const changelogPath of changelogPaths) { + const content = await Bun.file(changelogPath).text(); + const merged = mergePackageSection(content, floor, version); + if (merged === "") continue; + const pkgDir = changelogPath.replace(/\/CHANGELOG\.md$/, ""); + const name = await loadPackageName(pkgDir); + sections.push(`## ${name}\n\n${merged}`); + } + + if (sections.length === 0) { + console.warn( + `No CHANGELOG entries found for version ${version}; writing empty release notes to ${outputPath}.`, + ); + await Bun.write(outputPath, ""); + process.exit(0); + } + + const body = `${sections.join("\n\n")}\n`; + await Bun.write(outputPath, body); + console.log( + `Wrote ${sections.length} package section(s) to ${outputPath} (version ${version}${floor ? `, floor ${floor}` : ""}).`, ); - process.exit(1); -} -const version = stripVPrefix(tagInput); -const outputPath = process.argv[3] ?? "release-notes.md"; - -const sections: string[] = []; -const changelogPaths = await Array.fromAsync(changelogGlob.scan(".")); -changelogPaths.sort(); - -for (const changelogPath of changelogPaths) { - const content = await Bun.file(changelogPath).text(); - const section = extractVersionSection(content, version); - if (!section) continue; - const pkgDir = changelogPath.replace(/\/CHANGELOG\.md$/, ""); - const name = await loadPackageName(pkgDir); - sections.push(`## ${name}\n\n${section}`); } -if (sections.length === 0) { - console.warn( - `No CHANGELOG entries found for version ${version}; writing empty release notes to ${outputPath}.`, - ); - await Bun.write(outputPath, ""); - process.exit(0); +if (import.meta.main) { + await main(); } - -const body = `${sections.join("\n\n")}\n`; -await Bun.write(outputPath, body); -console.log(`Wrote ${sections.length} package section(s) to ${outputPath} (version ${version}).`);