Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-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

Expand Down
108 changes: 107 additions & 1 deletion scripts/gen-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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');
});
});
155 changes: 138 additions & 17 deletions scripts/gen-release-notes.ts
Original file line number Diff line number Diff line change
@@ -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 <from-ref> <to-ref> # write to stdout
// tsx scripts/gen-release-notes.ts <from-ref> <to-ref> > NOTES.md
// tsx scripts/gen-release-notes.ts <from-ref> <to-ref>
// tsx scripts/gen-release-notes.ts <from-ref> <to-ref> --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;
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -115,15 +133,118 @@ function renderMarkdown(fromRef: string, toRef: string, buckets: Record<string,
return lines.join('\n');
}

/**
* The body of one CHANGELOG version section, heading excluded.
*
* The heading is dropped because the release page already carries the version
* as its title, and repeating it reads as a mistake. `[Unreleased]` can never
* match: the pattern requires the exact version, and a release that shipped
* whatever happened to be sitting under "Unreleased" would be lying about its
* own contents.
*/
export function changelogEntry(changelog: string, version: string): string | undefined {
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// `## [0.3.1]` optionally followed by a date. Anchored at line start so a
// version mentioned inside prose cannot be mistaken for a section.
const start = new RegExp(`^## \\[${escaped}\\][^\\n]*$`, 'm').exec(changelog);
if (!start) return undefined;

const after = changelog.slice(start.index + start[0].length);
const next = /^## /m.exec(after);
const body = (next ? after.slice(0, next.index) : after).trim();
return body === '' ? undefined : body;
}

/**
* Rewrite repo-relative links to absolute URLs pinned at `ref`.
*
* 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 means a link in the v0.3.0 notes keeps pointing at the v0.3.0
* document after the file moves.
*
* Absolute URLs, in-page anchors, and mail links are left alone.
*/
export function absoluteLinks(markdown: string, repo: string, ref: string): string {
return markdown.replace(/\]\(([^)\s]+)\)/g, (whole, target: string) => {
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<string, string> = {};
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 <from-ref> <to-ref>\n');
process.stderr.write('Usage: gen-release-notes <from-ref> <to-ref> [--version <x.y.z>]\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
Expand Down
Loading