From ec6dfb85bf07798eb074d330980d68bb2e71a7b6 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:20:56 -0400 Subject: [PATCH] chore: streamline release note drafting (#26) * chore: streamline release note drafting * chore: document release notes flags --- .opencode/command/release-notes.md | 30 +++++ .opencode/opencode.jsonc | 9 ++ MAINTAINERS.md | 50 ++++++++ package.json | 1 + scripts/release-notes.mjs | 199 +++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 .opencode/command/release-notes.md create mode 100644 .opencode/opencode.jsonc create mode 100644 MAINTAINERS.md create mode 100644 scripts/release-notes.mjs diff --git a/.opencode/command/release-notes.md b/.opencode/command/release-notes.md new file mode 100644 index 0000000..0180207 --- /dev/null +++ b/.opencode/command/release-notes.md @@ -0,0 +1,30 @@ +--- +description: draft GitHub release notes for the current branch or a tag range +subtask: true +--- + +Draft user-facing release notes for this repository. + +Rules: + +- Check `package.json` first. If the branch has not already bumped the version, update the `version` field before drafting release notes. +- Treat an existing version change in `package.json` as the source of truth and do not overwrite it. +- Use the generated notes as the source of truth; do not invent features or fixes. +- Keep the tone concise and user-facing. +- Call out missing context if a commit subject is too vague. +- If the user passes arguments, forward them to the generator unchanged. + +## Package version + +@package.json + +## Generated notes + +!`pnpm release:notes -- $ARGUMENTS` + +After reviewing the generated notes: + +- confirm the `package.json` version matches the intended release tag when one is provided +- tighten wording only when it improves clarity +- preserve the existing section structure unless there is a strong reason to merge sections +- suggest a `gh release create` command if the user wants to publish next diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..f79accb --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + }, + }, +} diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..127cc6f --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,50 @@ +# Maintainers + +This document covers maintainer-only workflow notes that do not belong in the public project README. + +## Release updates + +GitHub Releases are the main user-facing update channel for OpenSEO. + +- Ask interested users to watch the repo and enable release notifications. +- Do not treat stars as a contact list; GitHub does not expose a way to message stargazers directly. + +## Release notes workflow + +Generate notes from commits since the latest semver tag: + +```sh +pnpm release:notes +``` + +Useful variants: + +```sh +pnpm release:notes -- --from v0.0.1 --to HEAD +pnpm release:notes -- --draft v0.0.2 +``` + +Supported inputs: + +- `--from `: start changelog generation from a specific tag +- `--to `: end at a specific ref, default is `HEAD` +- `--draft `: create a GitHub draft release for that tag using the generated notes +- `--repo `: override the GitHub repo +- `--help`: show help + +The generator: + +- uses commits since the latest semver tag by default +- filters out maintenance-only commits like `chore:`, `ci:`, `test:`, `build:`, and `release:` +- groups the remaining changes into short user-facing sections +- can create a draft GitHub release when `--draft` is provided + +## OpenCode slash command + +For convenience inside OpenCode, use: + +```text +/release-notes +``` + +The command definition lives at `.opencode/command/release-notes.md` and forwards any extra arguments to the same generator script. diff --git a/package.json b/package.json index 048bc19..0c026df 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "db:migrate:local": "wrangler d1 migrations apply DB --local", "db:migrate:prod": "wrangler d1 migrations apply DB --remote", "knip": "knip", + "release:notes": "node scripts/release-notes.mjs", "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run --reporter=dot", diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs new file mode 100644 index 0000000..54341fb --- /dev/null +++ b/scripts/release-notes.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +// @ts-check + +import { execFileSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { writeFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv; + +const { values } = parseArgs({ + args: normalizedArgv, + options: { + from: { type: "string" }, + to: { type: "string", default: "HEAD" }, + draft: { type: "string" }, + repo: { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: false, +}); + +if (values.help) { + process.stdout.write( + `Usage: pnpm release:notes -- [options]\n\nOptions:\n --from Starting git tag. Defaults to latest semver tag.\n --to Ending git ref. Defaults to HEAD.\n --draft Create a draft GitHub release for the provided tag.\n --repo Override GitHub repo slug for compare links and draft release.\n -h, --help Show this help message.\n`, + ); + process.exit(0); +} + +/** @param {readonly string[]} args */ +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +/** @param {readonly string[]} args */ +function gh(args) { + return execFileSync("gh", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function getLatestSemverTag() { + const tags = git(["tag", "--sort=-version:refname"]) + .split("\n") + .map((tag) => tag.trim()) + .filter(Boolean); + + return tags.find((tag) => + /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag), + ); +} + +function getOriginRepo() { + const remote = git(["remote", "get-url", "origin"]); + const match = remote.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/); + return match?.[1]; +} + +/** @param {readonly string[]} rangeArgs */ +function getCommitSubjects(rangeArgs) { + const output = git([ + "log", + "--no-merges", + "--reverse", + "--format=%s", + ...rangeArgs, + ]); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +/** @param {string} subject */ +function stripPrefix(subject) { + return subject + .replace(/^(?:revert:\s*)?/i, "") + .replace(/^(\w+)(\([^)]+\))?!?:\s*/i, "") + .trim(); +} + +/** @param {string} text */ +function sentenceCase(text) { + if (!text) return text; + return text[0].toUpperCase() + text.slice(1); +} + +/** + * @typedef {{ section: "Added" | "Improved" | "Fixed" | "Changed" | "Docs", text: string }} CommitEntry + */ + +/** @param {string} subject @returns {CommitEntry | null} */ +function classifyCommit(subject) { + const lower = subject.toLowerCase(); + const match = subject.match(/^(\w+)(\([^)]+\))?!?:\s*(.+)$/i); + const type = match?.[1]?.toLowerCase(); + + if (["chore", "ci", "test", "build", "release"].includes(type ?? "")) + return null; + if (lower.startsWith("merge ")) return null; + + const text = sentenceCase(stripPrefix(subject)); + if (!text) return null; + + if (type === "feat") return { section: "Added", text }; + if (type === "fix") return { section: "Fixed", text }; + if (type === "docs") return { section: "Docs", text }; + if (type === "perf" || type === "refactor") + return { section: "Improved", text }; + if (type === "style") return { section: "Changed", text }; + + if (/^(add|introduce|create)\b/i.test(text)) + return { section: "Added", text }; + if (/^(fix|resolve|correct)\b/i.test(text)) return { section: "Fixed", text }; + if (/^(improve|speed up|reduce|refactor|support)\b/i.test(text)) + return { section: "Improved", text }; + if (/^(document|docs|explain)\b/i.test(text)) + return { section: "Docs", text }; + return { section: "Changed", text }; +} + +/** @param {{ from?: string, to: string, repo?: string }} options */ +function buildNotes({ from, to, repo }) { + const rangeArgs = from ? [`${from}..${to}`] : [to]; + const commits = getCommitSubjects(rangeArgs) + .map(classifyCommit) + .filter(/** @returns {entry is CommitEntry} */ (entry) => Boolean(entry)); + + if (commits.length === 0) { + return "No notable changes."; + } + + /** @type {CommitEntry["section"][]} */ + const sections = ["Added", "Improved", "Fixed", "Changed", "Docs"]; + /** @type {Map} */ + const grouped = new Map(sections.map((section) => [section, []])); + + for (const commit of commits) { + const entries = grouped.get(commit.section); + if (entries && !entries.includes(commit.text)) entries.push(commit.text); + } + + const lines = []; + for (const section of sections) { + const entries = grouped.get(section); + if (!entries || entries.length === 0) continue; + lines.push(`## ${section}`); + lines.push(...entries.map((entry) => `- ${entry}`)); + lines.push(""); + } + + if (repo && from) { + const fromTag = from.replace(/^refs\/tags\//, ""); + const toRef = to.replace(/^refs\/heads\//, ""); + lines.push( + `Full Changelog: https://github.com/${repo}/compare/${fromTag}...${toRef}`, + ); + } + + return lines.join("\n").trim(); +} + +const from = values.from ?? getLatestSemverTag(); +const to = values.to; +const repo = values.repo ?? getOriginRepo(); +const notes = buildNotes({ from, to, repo }); + +process.stdout.write(`${notes}\n`); + +if (values.draft) { + const tag = values.draft; + const releaseTitle = tag.startsWith("v") ? tag : `v${tag}`; + const tmpFile = path.join( + os.tmpdir(), + `quick-eagle-release-notes-${Date.now()}.md`, + ); + writeFileSync(tmpFile, notes); + + const args = [ + "release", + "create", + releaseTitle, + "--draft", + "--title", + releaseTitle, + "--notes-file", + tmpFile, + ]; + if (repo) args.push("--repo", repo); + + gh(args); + process.stderr.write( + `Draft release created: ${releaseTitle}${repo ? ` (${repo})` : ""}\n`, + ); +}