From 8fdd274c695521c7334f1e6f53f5924f5f9a76fa Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 9 Aug 2026 22:16:36 +0800 Subject: [PATCH] fix(release): take the release body from the CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen-release-notes.ts` walked a commit range, and with no preceding tag it fell back to the root commit. That is how v0.3.0's release page came to read "0 commits." โ€” #250 fixed the shallow clone that produced the empty range, but the underlying choice was still to describe a release by its commit subjects. CHANGELOG.md already says what shipped, written for people, grouped by what the changes mean rather than by the verb the commit happened to start with. A list of commit subjects is what you write when nobody wrote anything better. So `--version` makes that entry the release body. The commit walk remains the fallback and announces itself, in the body and on stderr: notes generated because nobody wrote a changelog entry should not look like notes somebody wrote. Repo-relative links are rewritten to absolute URLs pinned at the tag. A release body is not rendered inside the repository, so `docs/file-contract.md` resolves against nothing and 404s; pinning at the tag rather than the default branch also keeps a v0.3.0 link pointing at the v0.3.0 document after the file moves. `[Unreleased]` cannot satisfy the lookup โ€” a release that shipped whatever happened to be sitting under that heading would be lying about its contents. Also passes a scrubbed environment to the git calls, for the reason in #252. It duplicates six lines rather than importing `gitSpawnEnv`, because the release job runs this with `npx tsx` after install but before any build, so core's `dist/` does not exist yet. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 5 +- CHANGELOG.md | 8 ++ docs/RELEASING.md | 16 ++- scripts/gen-release-notes.test.ts | 108 ++++++++++++++++++++- scripts/gen-release-notes.ts | 155 ++++++++++++++++++++++++++---- 5 files changed, 270 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e0c309..1498914 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -408,7 +408,10 @@ jobs: else FROM=$(git rev-list --max-parents=0 HEAD) fi - npx tsx scripts/gen-release-notes.ts "$FROM" HEAD > release-notes.md + # --version makes CHANGELOG.md's entry the release body; the commit + # range is only the fallback when that entry does not exist. + npx tsx scripts/gen-release-notes.ts "$FROM" HEAD \ + --version "${{ needs.validate.outputs.version }}" > release-notes.md # A release page missing an artifact reads as "there is no Mac build" # rather than "it was not produced this time". Say which. if [ "${{ needs.build-mac.result }}" != "success" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 9209054..d2c61b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### ๐Ÿ› Fixed +- **Release notes come from the CHANGELOG.** `gen-release-notes.ts` walked the + commit range, and with no preceding tag it fell back to the root commit โ€” which + is how v0.3.0's release page came to say "0 commits." after #250 fixed the + shallow clone. It now takes the tagged version's CHANGELOG entry, which is + written for humans and groups changes by what they mean rather than by the verb + the commit happened to start with. Repo-relative links are rewritten to + absolute URLs pinned at the tag, since a release body does not render inside + the repository. Falling back to commits still works and says so in the body. - **The desktop sidebar was a second reader of the session directory.** Archive and delete went through Tauri while the protocol served the same threads, and the list did too โ€” `window.deepcode.sessions.list()` had preferred the diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c632c42..5193021 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -112,9 +112,19 @@ The `release.yml` workflow fires on any `v*` tag push. Its validation and public 4. **build-mac** โ€” macOS-14 runner, Rust + Tauri build, calls `scripts/sign-and-notarize.sh` end-to-end. Outputs `DeepCode--arm64.dmg`. -5. **github-release** โ€” generates release notes via - `scripts/gen-release-notes.ts` (groups PRs by label), creates - the GitHub Release, and attaches the DMG and VSIX. +5. **github-release** โ€” builds the release body via + `scripts/gen-release-notes.ts`, creates the GitHub Release, and attaches the + DMG and VSIX. + + **The body is CHANGELOG.md's entry for the tagged version.** Repo-relative + links are rewritten to absolute URLs pinned at the tag โ€” a release body does + not render inside the repository, so a relative link resolves against nothing, + and pinning at the tag keeps it pointing at this release's version of the file + after that file moves. + + With no matching entry it falls back to the commit range and says so, in the + body and on stderr. That fallback is a signal that step 1 of the release + checklist was skipped, not a supported mode. ## Release channels diff --git a/scripts/gen-release-notes.test.ts b/scripts/gen-release-notes.test.ts index 55afc9d..88da23d 100644 --- a/scripts/gen-release-notes.test.ts +++ b/scripts/gen-release-notes.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { bucketCommits, classify, renderMarkdown, strip } from './gen-release-notes.js'; +import { + absoluteLinks, + bucketCommits, + changelogEntry, + classify, + parseArgs, + renderMarkdown, + strip, +} from './gen-release-notes.js'; describe('classify', () => { it.each([ @@ -64,3 +72,101 @@ describe('bucketCommits + renderMarkdown', () => { expect(md).not.toContain('## ๐Ÿ”ง Chore'); }); }); + +const CHANGELOG = `# Changelog + +Preamble that belongs to no release. + +## [Unreleased] + +- something not shipped yet + +## [0.3.1] โ€” 2026-08-09 + +Summary line. + +### Fixed + +- Fixed a thing, see [the docs](docs/file-contract.md) and + [an anchor](docs/x.md#section) and [./relative](./scripts/a.mjs). +- Left alone: [external](https://example.com) and [in-page](#fixed). + +## [0.3.0] โ€” 2026-08-08 + +Older release. +`; + +describe('changelogEntry', () => { + it('returns one version section, heading excluded', () => { + const entry = changelogEntry(CHANGELOG, '0.3.1'); + expect(entry).toContain('Summary line.'); + expect(entry).toContain('### Fixed'); + // The release page already shows the version as its title. + expect(entry).not.toContain('## [0.3.1]'); + // And stops at the next release rather than swallowing it. + expect(entry).not.toContain('Older release'); + expect(entry).not.toContain('not shipped yet'); + }); + + it('is undefined for a version with no entry', () => { + expect(changelogEntry(CHANGELOG, '9.9.9')).toBeUndefined(); + }); + + it('cannot be satisfied by the Unreleased section', () => { + // A release that shipped whatever happened to be sitting under "Unreleased" + // would be lying about its own contents. + expect(changelogEntry(CHANGELOG, 'Unreleased')).toContain('not shipped yet'); + expect(changelogEntry(CHANGELOG, '0.4.0')).toBeUndefined(); + }); + + it('does not match a version mentioned inside prose', () => { + const md = 'Text about ## [0.3.1] inline.\n\n## [0.2.0]\n\nreal\n'; + expect(changelogEntry(md, '0.3.1')).toBeUndefined(); + }); + + it('treats an empty section as absent, so the commit log takes over', () => { + expect(changelogEntry('## [1.0.0]\n\n## [0.9.0]\n\nbody\n', '1.0.0')).toBeUndefined(); + }); +}); + +describe('absoluteLinks', () => { + const out = absoluteLinks(changelogEntry(CHANGELOG, '0.3.1')!, 'oratis/deepcode', 'v0.3.1'); + + it('pins repo-relative links at the tag', () => { + // Release bodies do not render inside the repository, so a relative link + // resolves against nothing. The tag rather than main, so the link keeps + // pointing at this release's version of the file after it moves. + expect(out).toContain( + '](https://github.com/oratis/deepcode/blob/v0.3.1/docs/file-contract.md)', + ); + }); + + it('keeps anchors and strips a leading ./', () => { + expect(out).toContain('/blob/v0.3.1/docs/x.md#section)'); + expect(out).toContain('/blob/v0.3.1/scripts/a.mjs)'); + }); + + it('leaves absolute and in-page links alone', () => { + expect(out).toContain('](https://example.com)'); + expect(out).toContain('](#fixed)'); + }); +}); + +describe('parseArgs', () => { + it('keeps the two positional refs working', () => { + expect(parseArgs(['v0.3.0', 'HEAD'])).toMatchObject({ from: 'v0.3.0', to: 'HEAD' }); + }); + + it('reads flags in any position', () => { + expect(parseArgs(['--version', '1.2.3', 'a', 'b', '--repo', 'o/n'])).toMatchObject({ + from: 'a', + to: 'b', + version: '1.2.3', + repo: 'o/n', + }); + }); + + it('defaults the changelog path', () => { + expect(parseArgs([]).changelog).toBe('CHANGELOG.md'); + }); +}); diff --git a/scripts/gen-release-notes.ts b/scripts/gen-release-notes.ts index 1a3216f..36962f9 100644 --- a/scripts/gen-release-notes.ts +++ b/scripts/gen-release-notes.ts @@ -1,22 +1,39 @@ #!/usr/bin/env node -// gen-release-notes โ€” generate release notes by walking commits between two refs. +// gen-release-notes โ€” the body of a GitHub Release. // Spec: docs/DEVELOPMENT_PLAN.md ยง9 (M9 release pipeline) // // Usage: -// tsx scripts/gen-release-notes.ts # write to stdout -// tsx scripts/gen-release-notes.ts > NOTES.md +// tsx scripts/gen-release-notes.ts +// tsx scripts/gen-release-notes.ts --version 0.3.1 // -// Output buckets commits by conventional-commit type: -// feat: โ†’ โœจ New -// fix: โ†’ ๐Ÿ› Fixes -// perf: โ†’ โšก Performance -// refactor: โ†’ โ™ป๏ธ Refactor -// docs: โ†’ ๐Ÿ“ Docs -// test: โ†’ ๐Ÿงช Tests -// chore: โ†’ ๐Ÿ”ง Chore -// anything else โ†’ ๐Ÿ“ฆ Other +// With `--version`, CHANGELOG.md's entry for that version is the release body. +// It is written deliberately, for humans, and already groups changes by what +// they mean rather than by the verb the commit happened to start with. A list of +// commit subjects is what you write when nobody wrote anything better. +// +// Without a matching entry it falls back to walking the commit range and says +// so in the output, bucketed by conventional-commit type: +// feat: โ†’ โœจ New ยท fix: โ†’ ๐Ÿ› Fixes ยท perf: โ†’ โšก Performance +// refactor: โ†’ โ™ป๏ธ Refactor ยท docs: โ†’ ๐Ÿ“ Docs ยท test: โ†’ ๐Ÿงช Tests +// chore: โ†’ ๐Ÿ”ง Chore ยท anything else โ†’ ๐Ÿ“ฆ Other import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +/** + * Strip inherited `GIT_*` so a leaked `GIT_DIR` cannot point these commands at + * another repository. + * + * Duplicated from `packages/core/src/util/git-env.ts` rather than imported: the + * release job runs this with `npx tsx` after `pnpm install` but before any + * build, so `@deepcode/core`'s `dist/` does not exist yet. Six lines beats + * adding a build step to a job that needs nothing else from the workspace. + */ +function gitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(env)) if (key.startsWith('GIT_')) delete env[key]; + return env; +} interface Commit { hash: string; @@ -50,6 +67,7 @@ function gitLog(fromRef: string, toRef: string): Commit[] { const fmt = `%H${sep}%s${sep}%b${recordSep}`; const r = spawnSync('git', ['log', `--pretty=format:${fmt}`, `${fromRef}..${toRef}`], { encoding: 'utf8', + env: gitEnv(), }); if (r.status !== 0) { process.stderr.write(`git log failed: ${r.stderr}\n`); @@ -115,15 +133,118 @@ function renderMarkdown(fromRef: string, toRef: string, buckets: Record { + if (/^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i.test(target)) return whole; + const [path, anchor] = target.split('#'); + if (!path) return whole; + const clean = path.replace(/^\.\//, ''); + return `](https://github.com/${repo}/blob/${ref}/${clean}${anchor ? `#${anchor}` : ''})`; + }); +} + +/** `owner/name`, from the flag, the Actions environment, or the origin remote. */ +export function resolveRepo(explicit?: string): string | undefined { + if (explicit) return explicit; + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; + const r = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8', env: gitEnv() }); + if (r.status !== 0) return undefined; + const m = /github\.com[:/]([^/]+\/[^/\s]+?)(?:\.git)?\s*$/.exec(r.stdout); + return m?.[1]; +} + +interface Args { + from?: string; + to?: string; + version?: string; + changelog: string; + repo?: string; +} + +export function parseArgs(argv: string[]): Args { + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg.startsWith('--')) flags[arg.slice(2)] = argv[++i] ?? ''; + else positional.push(arg); + } + return { + from: positional[0], + to: positional[1], + version: flags.version, + changelog: flags.changelog ?? 'CHANGELOG.md', + repo: flags.repo, + }; +} + function main(): void { - const [from, to] = process.argv.slice(2); + const args = parseArgs(process.argv.slice(2)); + + if (args.version) { + let changelog: string | undefined; + try { + changelog = readFileSync(args.changelog, 'utf8'); + } catch { + process.stderr.write(`note: ${args.changelog} not readable; falling back to commits\n`); + } + const entry = changelog ? changelogEntry(changelog, args.version) : undefined; + if (entry) { + const repo = resolveRepo(args.repo); + process.stdout.write((repo ? absoluteLinks(entry, repo, `v${args.version}`) : entry) + '\n'); + return; + } + // Loud, on stderr and in the body. A release whose notes were generated + // because nobody wrote a changelog entry should not look like one where + // somebody did. + process.stderr.write( + `warning: ${args.changelog} has no entry for ${args.version}; using the commit log\n`, + ); + } + + const { from, to } = args; if (!from || !to) { - process.stderr.write('Usage: gen-release-notes \n'); + process.stderr.write('Usage: gen-release-notes [--version ]\n'); process.exit(2); } - const commits = gitLog(from, to); - const buckets = bucketCommits(commits); - process.stdout.write(renderMarkdown(from, to, buckets) + '\n'); + const buckets = bucketCommits(gitLog(from, to)); + let body = renderMarkdown(from, to, buckets); + if (args.version) { + body += `\n\n> Generated from the commit log: CHANGELOG.md has no \`[${args.version}]\` entry.`; + } + process.stdout.write(body + '\n'); } // Expose for tests