diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f51a4c9..75f91ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: run: | git checkout -- package.json manifest.json 2>/dev/null || true bun run scripts/sync-release-manifests.ts - if [[ -z "$(git status --porcelain -- package.json manifest.json)" ]]; then + if [[ -z "$(git status --porcelain -- package.json manifest.json CHANGELOG.md)" ]]; then echo "manifests already match the latest tag" exit 0 fi @@ -58,11 +58,11 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" git checkout -b "${BRANCH}" - git add package.json manifest.json + git add package.json manifest.json CHANGELOG.md git commit -m "chore(release): sync manifests to v${VERSION}" git push -u origin "${BRANCH}" gh pr create \ --title "chore(release): sync manifests to v${VERSION}" \ - --body "Automated post-release manifest alignment. Sets package.json and manifest.json to the released ${VERSION}." \ + --body "Automated post-release manifest alignment. Sets package.json, manifest.json, and CHANGELOG.md to the released ${VERSION}." \ --base main --head "${BRANCH}" gh pr merge --auto --squash --delete-branch || echo "auto-merge unavailable — merge the sync PR after CI is green" diff --git a/CHANGELOG.md b/CHANGELOG.md index d6d6c83..f32cb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- semantic-release now cuts Keep a Changelog `[Unreleased]` into `## [version]` on publish and includes `CHANGELOG.md` in the post-release sync PR. + +### Fixed + +- Catalog GitHub release notes table no longer renders a blank header row above Plugin. +- Credits sentence no longer mentions Command Code wiring or restates MIT copyright. + +## [0.6.0] - 2026-08-28 + +### Changed + +- Renamed the published package and GitHub repository to `@brainervirus/opencode-commandcode`. + +## [0.5.1] - 2026-08-28 + +### Fixed + +- Price Command Code free SKUs at `$0` before models.dev, and fill remaining paid gaps from models.dev. + +## [0.5.0] - 2026-08-28 + +### Added + - GitHub flow: PR-gated `main`, CI matrix (`check (test|typecheck|pack)`), semantic-release on merge, catalog updates as PRs. - Release notes open with a catalog summary table and collapsed lists of fallback / unpriced models. - `manifest.json` describing the bundled catalog (status, cost sources, command-code version). diff --git a/README.md b/README.md index 04af56c..dbe6e4e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Previously published as `@brainervirus/commandcode-go-opencode-provider`. Use th ## Credits -This package is based on **[FanFan4204/opencode-commandcode-provider](https://github.com/FanFan4204/opencode-commandcode-provider)**. That work started from **[brent-weatherall/opencode-commandcode-provider](https://github.com/brent-weatherall/opencode-commandcode-provider)** by **[Brent Weatherall](https://github.com/brent-weatherall)**. Thank you both — FanFan for the OpenCode provider this repo continues, and Brent for the original plugin, catalog extraction, and Command Code wiring. The license remains MIT; copyright stays with Brent Weatherall. +This package is based on **[FanFan4204/opencode-commandcode-provider](https://github.com/FanFan4204/opencode-commandcode-provider)**. That work started from **[brent-weatherall/opencode-commandcode-provider](https://github.com/brent-weatherall/opencode-commandcode-provider)** by **[Brent Weatherall](https://github.com/brent-weatherall)**. Thank you both — FanFan for the OpenCode provider this repo continues, and Brent for the original plugin, catalog extraction. ### What this package adds diff --git a/release.config.cjs b/release.config.cjs index a2606f9..ff984c4 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -4,6 +4,7 @@ module.exports = { "@semantic-release/commit-analyzer", "./scripts/semantic-release-catalog-notes.cjs", "@semantic-release/release-notes-generator", + "./scripts/semantic-release-changelog.cjs", "@semantic-release/npm", "@semantic-release/github", ], diff --git a/scripts/catalog-release-notes.ts b/scripts/catalog-release-notes.ts index 31e97e4..1f06db2 100644 --- a/scripts/catalog-release-notes.ts +++ b/scripts/catalog-release-notes.ts @@ -8,4 +8,5 @@ import type { PricedModel } from "../src/catalog-release-notes.js"; const ROOT = join(import.meta.dir, ".."); const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest; const models = JSON.parse(readFileSync(join(ROOT, "models.json"), "utf-8")) as PricedModel[]; -process.stdout.write(catalogReleaseNotesFromFiles({ manifest, models })); +const pluginVersion = process.argv[2]; +process.stdout.write(catalogReleaseNotesFromFiles({ manifest, models, pluginVersion })); diff --git a/scripts/cut-changelog.ts b/scripts/cut-changelog.ts new file mode 100644 index 0000000..e71effa --- /dev/null +++ b/scripts/cut-changelog.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env bun +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { cutKeepAChangelog } from "../src/cut-changelog.ts"; + +const version = process.argv[2]; +const date = process.argv[3] ?? new Date().toISOString().slice(0, 10); +if (!version) throw new Error("usage: cut-changelog.ts [YYYY-MM-DD]"); +const path = join(import.meta.dir, "..", "CHANGELOG.md"); +writeFileSync(path, cutKeepAChangelog(readFileSync(path, "utf8"), version, date)); diff --git a/scripts/semantic-release-catalog-notes.cjs b/scripts/semantic-release-catalog-notes.cjs index 50f84e5..9f28cc3 100644 --- a/scripts/semantic-release-catalog-notes.cjs +++ b/scripts/semantic-release-catalog-notes.cjs @@ -2,9 +2,9 @@ const { execFileSync } = require("node:child_process"); const { join } = require("node:path"); module.exports = { - generateNotes() { - return execFileSync("bun", [join(__dirname, "catalog-release-notes.ts")], { - encoding: "utf8", - }); + generateNotes(_config, context) { + const args = [join(__dirname, "catalog-release-notes.ts")]; + if (context.nextRelease?.version) args.push(context.nextRelease.version); + return execFileSync("bun", args, { encoding: "utf8" }); }, }; diff --git a/scripts/semantic-release-changelog.cjs b/scripts/semantic-release-changelog.cjs new file mode 100644 index 0000000..590f83f --- /dev/null +++ b/scripts/semantic-release-changelog.cjs @@ -0,0 +1,14 @@ +const { execFileSync } = require("node:child_process"); +const { join } = require("node:path"); + +// Keep a Changelog: promote ## [Unreleased] into ## [version] - date during +// prepare. The post-release sync PR commits CHANGELOG.md (protected main). +module.exports = { + prepare(_config, context) { + const version = context.nextRelease.version; + const date = new Date().toISOString().slice(0, 10); + execFileSync("bun", [join(__dirname, "cut-changelog.ts"), version, date], { + stdio: "inherit", + }); + }, +}; diff --git a/src/catalog-release-notes.ts b/src/catalog-release-notes.ts index f21e550..b580f9b 100644 --- a/src/catalog-release-notes.ts +++ b/src/catalog-release-notes.ts @@ -89,9 +89,8 @@ export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { const sections = [ headline(input.status), "", - "| | |", - "| --- | --- |", `| Plugin | ${input.pluginVersion} |`, + "| --- | --- |", `| Command Code | ${input.commandCodeVersion} |`, `| Models | ${input.modelCount} (${input.reasoningModelCount} with reasoning) |`, `| Prices | ${priceSummary(input.costSources)} |`, @@ -157,9 +156,10 @@ export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { export function catalogReleaseNotesFromFiles(input: { manifest: CatalogManifest; models: PricedModel[]; + pluginVersion?: string; }): string { return renderCatalogReleaseNotes({ - pluginVersion: input.manifest.pluginVersion, + pluginVersion: input.pluginVersion ?? input.manifest.pluginVersion, commandCodeVersion: input.manifest.commandCodeVersion, modelCount: input.manifest.modelCount, reasoningModelCount: input.manifest.reasoningModelCount, diff --git a/src/cut-changelog.ts b/src/cut-changelog.ts new file mode 100644 index 0000000..56ba506 --- /dev/null +++ b/src/cut-changelog.ts @@ -0,0 +1,40 @@ +const UNRELEASED = /^## \[Unreleased\]\s*$/; +const VERSION = /^## \[/; + +export function cutKeepAChangelog(markdown: string, version: string, date: string): string { + if (new RegExp(`^## \\[${escapeRegExp(version)}\\]`, "m").test(markdown)) return markdown; + + const lines = markdown.split(/(?<=\n)/); + let start: number | null = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line !== undefined && UNRELEASED.test(line.replace(/\n$/, ""))) { + start = i; + break; + } + } + if (start === null) throw new Error("CHANGELOG.md has no ## [Unreleased] heading"); + + let end = lines.length; + for (let j = start + 1; j < lines.length; j++) { + const line = lines[j]; + if (line === undefined) continue; + const stripped = line.replace(/\n$/, ""); + if (VERSION.test(stripped) && !UNRELEASED.test(stripped)) { + end = j; + break; + } + } + + const before = lines.slice(0, start + 1).join(""); + const body = lines + .slice(start + 1, end) + .join("") + .replace(/^\n+/, "\n"); + const after = lines.slice(end).join(""); + return `${before}\n## [${version}] - ${date}\n${body}${after}`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/unit/catalog-release-notes.test.ts b/tests/unit/catalog-release-notes.test.ts index d4c1b7f..0b25870 100644 --- a/tests/unit/catalog-release-notes.test.ts +++ b/tests/unit/catalog-release-notes.test.ts @@ -1,9 +1,13 @@ import { expect, test, describe } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; import { renderCatalogReleaseNotes, type CatalogReleaseInput, } from "../../src/catalog-release-notes.ts"; +const ROOT = join(import.meta.dir, "../.."); + function input(partial: Partial = {}): CatalogReleaseInput { return { pluginVersion: "0.5.0", @@ -45,6 +49,12 @@ describe("renderCatalogReleaseNotes", () => { expect(md).toContain("1 no listed price"); }); + test("uses Plugin as the table header so GitHub does not render a blank row", () => { + const md = renderCatalogReleaseNotes(input()); + expect(md).toContain("| Plugin | 0.5.0 |\n| --- | --- |"); + expect(md).not.toContain("| | |"); + }); + test("collapses models.dev, free, and unmatched lists", () => { const md = renderCatalogReleaseNotes(input()); expect(md).toContain("
"); @@ -96,3 +106,14 @@ describe("renderCatalogReleaseNotes", () => { expect(md).toContain('"unmatched": 1'); }); }); + +describe("catalog-release-notes CLI", () => { + test("honors an explicit plugin version so GitHub notes match the tag", () => { + const out = execFileSync("bun", ["scripts/catalog-release-notes.ts", "0.6.0"], { + encoding: "utf8", + cwd: ROOT, + }); + expect(out).toContain("| Plugin | 0.6.0 |"); + expect(out).toContain('"pluginVersion": "0.6.0"'); + }); +}); diff --git a/tests/unit/cut-changelog.test.ts b/tests/unit/cut-changelog.test.ts new file mode 100644 index 0000000..e924b59 --- /dev/null +++ b/tests/unit/cut-changelog.test.ts @@ -0,0 +1,43 @@ +import { expect, test, describe } from "bun:test"; +import { cutKeepAChangelog } from "../../src/cut-changelog.ts"; + +const HEADER = `# Changelog + +All notable changes to this project will be documented in this file. + +`; + +describe("cutKeepAChangelog", () => { + test("moves Unreleased body under the new version and leaves Unreleased empty", () => { + const before = `${HEADER}## [Unreleased] + +### Added + +- New thing. + +## [0.5.0] - 2026-08-01 + +### Fixed + +- Old thing. +`; + const after = cutKeepAChangelog(before, "0.6.0", "2026-08-28"); + expect(after).toContain("## [Unreleased]\n\n## [0.6.0] - 2026-08-28\n"); + expect(after).toContain("### Added\n\n- New thing.\n"); + expect(after.indexOf("## [0.6.0]")).toBeLessThan(after.indexOf("## [0.5.0]")); + expect(after.indexOf("- New thing.")).toBeGreaterThan(after.indexOf("## [0.6.0]")); + expect(after.indexOf("- New thing.")).toBeLessThan(after.indexOf("## [0.5.0]")); + }); + + test("is a no-op when the version heading already exists", () => { + const already = `${HEADER}## [Unreleased] + +## [0.6.0] - 2026-08-28 + +### Added + +- Already cut. +`; + expect(cutKeepAChangelog(already, "0.6.0", "2026-08-29")).toBe(already); + }); +}); diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index fb52428..7666dfc 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -8,6 +8,26 @@ const json = (rel: string) => JSON.parse(read(rel)) as T; const NPMJS = "${{ secrets.NPMJS }}"; +describe("CHANGELOG.md", () => { + test("records shipped versions under Keep a Changelog headings", () => { + const log = read("CHANGELOG.md"); + expect(log).toContain("## [Unreleased]"); + expect(log).toContain("## [0.6.0]"); + expect(log).toContain("## [0.5.1]"); + expect(log).toContain("## [0.5.0]"); + }); +}); + +describe("README credits", () => { + test("does not restate Command Code wiring or MIT copyright in the thanks sentence", () => { + const readme = read("README.md"); + expect(readme).toContain("Brent for the original plugin, catalog extraction."); + expect(readme).not.toContain( + "and Command Code wiring. The license remains MIT; copyright stays with Brent Weatherall.", + ); + }); +}); + describe("package.json publish identity", () => { test("is the scoped public package on BrainerVirus/opencode-commandcode", () => { const pkg = json<{ @@ -92,6 +112,9 @@ describe("release.yml", () => { expect(release?.env?.NODE_AUTH_TOKEN).toBe(NPMJS); const blob = wf.jobs.release.steps.map((s) => s.run ?? "").join("\n"); expect(blob).not.toContain("publish-if-needed"); + const sync = wf.jobs.release.steps.find((s) => s.name === "Sync release manifests to main"); + expect(sync?.run).toContain("CHANGELOG.md"); + expect(sync?.run).toMatch(/git add package\.json manifest\.json CHANGELOG\.md/); }); }); @@ -127,17 +150,29 @@ describe("release.config.cjs", () => { const cfg = read("release.config.cjs"); expect(cfg).toContain("@semantic-release/commit-analyzer"); expect(cfg).toContain("./scripts/semantic-release-catalog-notes.cjs"); + expect(cfg).toContain("./scripts/semantic-release-changelog.cjs"); expect(cfg).toContain("@semantic-release/npm"); expect(cfg).toContain("@semantic-release/github"); expect(cfg.indexOf("semantic-release-catalog-notes.cjs")).toBeLessThan( cfg.indexOf("@semantic-release/release-notes-generator"), ); + expect(cfg.indexOf("semantic-release-changelog.cjs")).toBeLessThan( + cfg.indexOf("@semantic-release/npm"), + ); expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan( cfg.indexOf("@semantic-release/github"), ); }); }); +describe("semantic-release-catalog-notes.cjs", () => { + test("passes nextRelease.version into the catalog notes script", () => { + const src = read("scripts/semantic-release-catalog-notes.cjs"); + expect(src).toContain("nextRelease"); + expect(src).toContain("catalog-release-notes.ts"); + }); +}); + describe("verify-release-candidate.ts", () => { test("is pack-only", () => { const src = read("scripts/verify-release-candidate.ts")