build: enforce changelog workflow in CI

This commit is contained in:
2026-03-08 16:10:37 -07:00
parent e4aa8ff907
commit f10e905dbd
12 changed files with 928 additions and 53 deletions

3
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,3 @@
## Checklist
- [ ] Added a changelog fragment in `changes/`, or this PR is labeled `skip-changelog`

View File

@@ -13,6 +13,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup Bun
@@ -39,6 +40,13 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint changelog fragments
run: bun run changelog:lint
- name: Enforce pull request changelog fragments (`skip-changelog` label bypass)
if: github.event_name == 'pull_request'
run: bun run changelog:pr-check --base-ref "origin/${{ github.base_ref }}" --head-ref "HEAD" --labels "${{ join(github.event.pull_request.labels.*.name, ',') }}"
- name: Build (TypeScript check)
# Keep explicit typecheck for fast fail before full build/bundle.
run: bun run typecheck

View File

@@ -281,23 +281,11 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
else
COMMIT_COUNT=$(git rev-list --count HEAD)
if [ "$COMMIT_COUNT" -gt 10 ]; then
CHANGES=$(git log --pretty=format:"- %s" HEAD~10..HEAD)
else
CHANGES=$(git log --pretty=format:"- %s")
fi
fi
echo "CHANGES<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
- name: Generate release notes from changelog
run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Publish Release
env:
@@ -305,46 +293,15 @@ jobs:
run: |
set -euo pipefail
cat > release-body.md <<'EOF'
## Changes
${{ steps.changelog.outputs.CHANGES }}
## Installation
### AppImage (Recommended)
1. Download the AppImage below
2. Make it executable: `chmod +x SubMiner.AppImage`
3. Run: `./SubMiner.AppImage`
### macOS
1. Download `subminer-*.dmg`
2. Open the DMG and drag `SubMiner.app` into `/Applications`
3. If needed, use the ZIP artifact as an alternative
### Manual Installation
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
### Optional Assets (config example + mpv plugin + rofi theme)
1. Download `subminer-assets.tar.gz`
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
5. Copy `assets/themes/subminer.rasi` to:
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
EOF
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
# Do not pass the prerelease flag here; gh defaults to a normal release.
gh release edit "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md
--notes-file release/release-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release-body.md
--notes-file release/release-notes.md
fi
shopt -s nullglob

1
.gitignore vendored
View File

@@ -37,4 +37,3 @@ tests/*
.worktrees/
.codex/*
.agents/*
docs/*

View File

@@ -1,3 +1,60 @@
# AGENTS.MD
## PR Feedback
- Active PR: `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`.
- PR comments: `gh pr view …` + `gh api …/comments --paginate`.
- Replies: cite fix + file/line; resolve threads only after fix lands.
- When merging a PR: thank the contributor in `CHANGELOG.md`.
## Changelog
- User-visible PRs: add one fragment in `changes/*.md`.
- Fragment format:
`type: added|changed|fixed|docs|internal`
`area: <short-area>`
blank line
`- bullet`
- `changes/README.md`: instructions only; generator ignores it.
- No release-note entry wanted: use PR label `skip-changelog`.
- CI runs `bun run changelog:lint` + `bun run changelog:pr-check` on PRs.
- Release prep: `bun run changelog:build`, review `CHANGELOG.md` + `release/release-notes.md`, commit generated changelog + fragment deletions, then tag.
- Release CI expects committed changelog entry already present; do not rely on tag job to invent notes.
## Flow & Runtime
- Use repos package manager/runtime; no swaps w/o approval.
- Use Codex background for long jobs; tmux only for interactive/persistent (debugger/server).
## Build / Test
- Before handoff: run full gate (lint/typecheck/tests/docs).
- CI red: `gh run list/view`, rerun, fix, push, repeat til green.
- Keep it observable (logs, panes, tails, MCP/browser tools).
- Release: read `docs/RELEASING.md`
## Git
- Safe by default: `git status/diff/log`. Push only when user asks.
- `git checkout` ok for PR review / explicit request.
- Branch changes require user consent.
- Destructive ops forbidden unless explicit (`reset --hard`, `clean`, `restore`, `rm`, …).
- Dont delete/rename unexpected stuff; stop + ask.
- No repo-wide S/R scripts; keep edits small/reviewable.
- Avoid manual `git stash`; if Git auto-stashes during pull/rebase, thats fine (hint, not hard guardrail).
- If user types a command (“pull and push”), thats consent for that command.
- No amend unless asked.
- Big review: `git --no-pager diff --color=never`.
- Multi-agent: check `git status/diff` before edits; ship small commits.
## Language/Stack Notes
- Swift: use workspace helper/daemon; validate `swift build` + tests; keep concurrency attrs right.
- TypeScript: use repo PM; keep files small; follow existing patterns.
## macOS Permissions / Signing (TCC)
- Never re-sign / ad-hoc sign / change bundle ID as “debug” without explicit ok (can mess TCC).
<!-- BACKLOG.MD MCP GUIDELINES START -->
@@ -17,6 +74,7 @@ This project uses Backlog.md MCP for all task and project management activities.
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
## v0.3.0 (2026-03-05)
- Added keyboard-driven Yomitan navigation and popup controls, including optional auto-pause.
- Added subtitle/jump keyboard handling fixes for smoother subtitle playback control.
- Improved Anki/Yomitan reliability with stronger Yomitan proxy syncing and safer extension refresh logic.
- Added Subsync `replace` option and deterministic retime naming for subtitle workflows.
- Moved aniskip resolution to launcher-script options for better control.
- Tuned tokenizer frequency highlighting filters for improved term visibility.
- Added release build quality-of-life for CLI publish (`gh`-based clobber upload).
- Removed docs Plausible integration and cleaned associated tracker settings.
## v0.2.3 (2026-03-02)
- Added performance and tokenization optimizations (faster warmup, persistent MeCab usage, reduced enrichment lookups).
- Added subtitle controls for no-jump delay shifts.
- Improved subtitle highlight logic with priority and reliability fixes.
- Fixed plugin loading behavior to keep OSD visible during startup.
- Fixed Jellyfin remote resume behavior and improved autoplay/tokenization interaction.
- Updated startup flow to load dictionaries asynchronously and unblock first tokenization sooner.
## v0.2.2 (2026-03-01)
- Improved subtitle highlighting reliability for frequency modes.
- Fixed Jellyfin misc info formatting cleanup.
- Version bump maintenance for 0.2.2.
## v0.2.1 (2026-03-01)
- Delivered Jellyfin and Subsync fixes from release patch cycle.
- Version bump maintenance for 0.2.1.
## v0.2.0 (2026-03-01)
- Added task-related release work for the overlay 2.0 cycle.
- Introduced Overlay 2.0.
- Improved release automation reliability.
## v0.1.2 (2026-02-24)
- Added encrypted AniList token handling and default GNOME keyring support.
- Added launcher passthrough for password-store flows (Jellyfin path).
- Updated docs for auth and integration behavior.
- Version bump maintenance for 0.1.2.
## v0.1.1 (2026-02-23)
- Fixed overlay modal focus handling (`grab input`) behavior.
- Version bump maintenance for 0.1.1.
## v0.1.0 (2026-02-23)
- Bootstrapped Electron runtime, services, and composition model.
- Added runtime asset packaging and dependency vendoring.
- Added project docs baseline, setup guides, architecture notes, and submodule/runtime assets.
- Added CI release job dependency ordering fixes before launcher build.

21
changes/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog Fragments
Add one `.md` file per user-visible PR in this directory.
Use this format:
```md
type: added
area: overlay
- Added keyboard navigation for Yomitan popups.
- Added auto-pause toggle when opening the popup.
```
Rules:
- `type` required: `added`, `changed`, `fixed`, `docs`, or `internal`
- `area` required: short product area like `overlay`, `launcher`, `release`
- each non-empty body line becomes a bullet
- `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment

View File

@@ -14,6 +14,11 @@
"build:yomitan": "cd vendor/subminer-yomitan && bun install --frozen-lockfile && bun run build -- --target chrome && rm -rf ../../build/yomitan && mkdir -p ../../build/yomitan && unzip -qo builds/yomitan-chrome.zip -d ../../build/yomitan",
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:lint": "bun run scripts/build-changelog.ts lint",
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format:src": "bash scripts/prettier-scope.sh --write",
@@ -23,7 +28,7 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
@@ -44,7 +49,7 @@
"test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts && tsc -p tsconfig.json && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts && tsc -p tsconfig.json && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run build && bun dist/generate-config-example.js",
"start": "bun run build && electron . --start",
"dev": "bun run build && electron . --start --dev",

View File

@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
async function loadModule() {
return import('./build-changelog');
}
function createWorkspace(name: string): string {
const baseDir = path.join(process.cwd(), '.tmp', 'build-changelog-test');
fs.mkdirSync(baseDir, { recursive: true });
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
test('resolveChangelogOutputPaths stays repo-local and never writes docs paths', async () => {
const { resolveChangelogOutputPaths } = await loadModule();
const workspace = createWorkspace('with-docs-repo');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(projectRoot, { recursive: true });
try {
const outputPaths = resolveChangelogOutputPaths({ cwd: projectRoot });
assert.deepEqual(outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
assert.equal(outputPaths.includes(path.join(projectRoot, 'docs', 'changelog.md')), false);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeChangelogArtifacts ignores README, groups fragments by type, writes release notes, and deletes only fragment files', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('write-artifacts');
const projectRoot = path.join(workspace, 'SubMiner');
const existingChangelog = ['# Changelog', '', '## v0.4.0 (2026-03-01)', '- Existing fix', ''].join('\n');
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', 'README.md'), '# Changelog Fragments\n\nIgnored helper text.\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added release fragments.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: release', '', 'Fixed release notes generation.'].join('\n'),
'utf8',
);
try {
const result = writeChangelogArtifacts({
cwd: projectRoot,
version: '0.4.1',
date: '2026-03-07',
});
assert.deepEqual(result.outputPaths, [path.join(projectRoot, 'CHANGELOG.md')]);
assert.deepEqual(
result.deletedFragmentPaths,
[
path.join(projectRoot, 'changes', '001.md'),
path.join(projectRoot, 'changes', '002.md'),
],
);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), false);
assert.equal(fs.existsSync(path.join(projectRoot, 'changes', 'README.md')), true);
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.match(
changelog,
/^# Changelog\n\n## v0\.4\.1 \(2026-03-07\)\n\n### Added\n- Overlay: Added release fragments\.\n\n### Fixed\n- Release: Fixed release notes generation\.\n\n## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/m,
);
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8');
assert.match(releaseNotes, /## Highlights\n### Added\n- Overlay: Added release fragments\./);
assert.match(releaseNotes, /### Fixed\n- Release: Fixed release notes generation\./);
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogReadyForRelease ignores README but rejects pending fragments and missing version sections', async () => {
const { verifyChangelogReadyForRelease } = await loadModule();
const workspace = createWorkspace('verify-release');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', 'README.md'), '# Changelog Fragments\n', 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), '- Pending fragment.\n', 'utf8');
try {
assert.throws(
() => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
/Pending changelog fragments/,
);
fs.rmSync(path.join(projectRoot, 'changes', '001.md'));
assert.throws(
() => verifyChangelogReadyForRelease({ cwd: projectRoot, version: '0.4.1' }),
/Missing CHANGELOG section for v0\.4\.1/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogFragments rejects invalid metadata', async () => {
const { verifyChangelogFragments } = await loadModule();
const workspace = createWorkspace('lint-invalid');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: nope', 'area: overlay', '', '- Invalid type.'].join('\n'),
'utf8',
);
try {
assert.throws(
() => verifyChangelogFragments({ cwd: projectRoot }),
/must declare type as one of/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyPullRequestChangelog requires fragments for user-facing changes and skips docs-only changes', async () => {
const { verifyPullRequestChangelog } = await loadModule();
assert.throws(
() =>
verifyPullRequestChangelog({
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
changedLabels: [],
}),
/requires a changelog fragment/,
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [{ path: 'docs/RELEASING.md', status: 'M' }],
changedLabels: [],
}),
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
changedLabels: ['skip-changelog'],
}),
);
assert.throws(
() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'D' },
],
changedLabels: [],
}),
/requires a changelog fragment/,
);
assert.doesNotThrow(() =>
verifyPullRequestChangelog({
changedEntries: [
{ path: 'src/main-entry.ts', status: 'M' },
{ path: 'changes/001.md', status: 'A' },
],
changedLabels: [],
}),
);
});

566
scripts/build-changelog.ts Normal file
View File

@@ -0,0 +1,566 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
readdirSync?: (candidate: string, options: { withFileTypes: true }) => fs.Dirent[];
rmSync?: (candidate: string) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void;
};
type ChangelogOptions = {
cwd?: string;
date?: string;
version?: string;
deps?: ChangelogFsDeps;
};
type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal';
type ChangeFragment = {
area: string;
bullets: string[];
path: string;
type: FragmentType;
};
type PullRequestChangelogOptions = {
changedEntries: Array<{
path: string;
status: string;
}>;
changedLabels?: string[];
};
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
const CHANGELOG_HEADER = '# Changelog';
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
const CHANGE_TYPE_HEADINGS: Record<FragmentType, string> = {
added: 'Added',
changed: 'Changed',
fixed: 'Fixed',
docs: 'Docs',
internal: 'Internal',
};
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
function normalizeVersion(version: string): string {
return version.replace(/^v/, '');
}
function resolveDate(date?: string): string {
return date ?? new Date().toISOString().slice(0, 10);
}
function resolvePackageVersion(cwd: string, readFileSync: (candidate: string, encoding: BufferEncoding) => string): string {
const packageJsonPath = path.join(cwd, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string };
if (!packageJson.version) {
throw new Error(`Missing package.json version at ${packageJsonPath}`);
}
return normalizeVersion(packageJson.version);
}
function resolveVersion(
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
): string {
const cwd = options.cwd ?? process.cwd();
const readFileSync = options.deps?.readFileSync ?? fs.readFileSync;
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
}
function resolveChangesDir(cwd: string): string {
return path.join(cwd, 'changes');
}
function resolveFragmentPaths(
cwd: string,
deps?: ChangelogFsDeps,
): string[] {
const changesDir = resolveChangesDir(cwd);
const existsSync = deps?.existsSync ?? fs.existsSync;
const readdirSync = deps?.readdirSync ?? fs.readdirSync;
if (!existsSync(changesDir)) {
return [];
}
return readdirSync(changesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name.toLowerCase() !== 'readme.md')
.map((entry) => path.join(changesDir, entry.name))
.sort();
}
function normalizeFragmentBullets(content: string): string[] {
const lines = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = /^[-*]\s+(.*)$/.exec(line);
return `- ${(match?.[1] ?? line).trim()}`;
});
if (lines.length === 0) {
throw new Error('Changelog fragment cannot be empty.');
}
return lines;
}
function parseFragmentMetadata(content: string, fragmentPath: string): {
area: string;
body: string;
type: FragmentType;
} {
const lines = content.split(/\r?\n/);
let index = 0;
while (index < lines.length && !(lines[index] ?? '').trim()) {
index += 1;
}
const metadata = new Map<string, string>();
while (index < lines.length) {
const trimmed = (lines[index] ?? '').trim();
if (!trimmed) {
index += 1;
break;
}
const match = /^([a-z]+):\s*(.+)$/.exec(trimmed);
if (!match) {
break;
}
const [, rawKey = '', rawValue = ''] = match;
metadata.set(rawKey, rawValue.trim());
index += 1;
}
const type = metadata.get('type');
if (!type || !CHANGE_TYPES.includes(type as FragmentType)) {
throw new Error(
`${fragmentPath} must declare type as one of: ${CHANGE_TYPES.join(', ')}.`,
);
}
const area = metadata.get('area');
if (!area) {
throw new Error(`${fragmentPath} must declare area.`);
}
const body = lines.slice(index).join('\n').trim();
if (!body) {
throw new Error(`${fragmentPath} must include at least one changelog bullet.`);
}
return {
area,
body,
type: type as FragmentType,
};
}
function readChangeFragments(
cwd: string,
deps?: ChangelogFsDeps,
): ChangeFragment[] {
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
return resolveFragmentPaths(cwd, deps).map((fragmentPath) => {
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
return {
area: parsed.area,
bullets: normalizeFragmentBullets(parsed.body),
path: fragmentPath,
type: parsed.type,
};
});
}
function formatAreaLabel(area: string): string {
return area
.split(/[-_\s]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
}
function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string {
return `- ${formatAreaLabel(fragment.area)}: ${bullet.replace(/^- /, '')}`;
}
function renderGroupedChanges(fragments: ChangeFragment[]): string {
const sections = CHANGE_TYPES.flatMap((type) => {
const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) {
return [];
}
const bullets = typeFragments
.flatMap((fragment) => fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)))
.join('\n');
return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`];
});
return sections.join('\n\n');
}
function buildReleaseSection(version: string, date: string, fragments: ChangeFragment[]): string {
if (fragments.length === 0) {
throw new Error('No changelog fragments found in changes/.');
}
return [`## v${version} (${date})`, '', renderGroupedChanges(fragments), ''].join(
'\n',
);
}
function ensureChangelogHeader(existingChangelog: string): string {
const trimmed = existingChangelog.trim();
if (!trimmed) {
return `${CHANGELOG_HEADER}\n`;
}
if (trimmed.startsWith(CHANGELOG_HEADER)) {
return `${trimmed}\n`;
}
return `${CHANGELOG_HEADER}\n\n${trimmed}\n`;
}
function prependReleaseSection(existingChangelog: string, releaseSection: string, version: string): string {
const normalizedExisting = ensureChangelogHeader(existingChangelog);
if (extractReleaseSectionBody(normalizedExisting, version) !== null) {
throw new Error(`CHANGELOG already contains a section for v${version}.`);
}
const withoutHeader = normalizedExisting.replace(/^# Changelog\s*/, '').trimStart();
const body = [releaseSection.trimEnd(), withoutHeader.trimEnd()].filter(Boolean).join('\n\n');
return `${CHANGELOG_HEADER}\n\n${body}\n`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractReleaseSectionBody(changelog: string, version: string): string | null {
const headingPattern = new RegExp(
`^## v${escapeRegExp(normalizeVersion(version))} \\([^\\n]+\\)$`,
'm',
);
const headingMatch = headingPattern.exec(changelog);
if (!headingMatch) {
return null;
}
const bodyStart = headingMatch.index + headingMatch[0].length + 1;
const remaining = changelog.slice(bodyStart);
const nextHeadingMatch = /^## /m.exec(remaining);
const body = nextHeadingMatch ? remaining.slice(0, nextHeadingMatch.index) : remaining;
return body.trim();
}
export function resolveChangelogOutputPaths(options?: {
cwd?: string;
}): string[] {
const cwd = options?.cwd ?? process.cwd();
return [path.join(cwd, 'CHANGELOG.md')];
}
function renderReleaseNotes(changes: string): string {
return [
'## Highlights',
changes,
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`',
'- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher',
'',
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
'',
].join('\n');
}
function writeReleaseNotesFile(
cwd: string,
changes: string,
deps?: ChangelogFsDeps,
): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH);
mkdirSync(path.dirname(releaseNotesPath), { recursive: true });
writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8');
return releaseNotesPath;
}
export function writeChangelogArtifacts(options?: ChangelogOptions): {
deletedFragmentPaths: string[];
outputPaths: string[];
releaseNotesPath: string;
} {
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const mkdirSync = options?.deps?.mkdirSync ?? fs.mkdirSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const rmSync = options?.deps?.rmSync ?? fs.rmSync;
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
const log = options?.deps?.log ?? console.log;
const version = resolveVersion(options ?? {});
const date = resolveDate(options?.date);
const fragments = readChangeFragments(cwd, options?.deps);
const releaseSection = buildReleaseSection(version, date, fragments);
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
const existingChangelog = existsSync(existingChangelogPath)
? readFileSync(existingChangelogPath, 'utf8')
: '';
const outputPaths = resolveChangelogOutputPaths({ cwd });
const nextChangelog = prependReleaseSection(existingChangelog, releaseSection, version);
for (const outputPath of outputPaths) {
mkdirSync(path.dirname(outputPath), { recursive: true });
writeFileSync(outputPath, nextChangelog, 'utf8');
log(`Updated ${outputPath}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
extractReleaseSectionBody(nextChangelog, version) ?? releaseSection,
options?.deps,
);
log(`Generated ${releaseNotesPath}`);
for (const fragment of fragments) {
rmSync(fragment.path);
log(`Removed ${fragment.path}`);
}
return {
deletedFragmentPaths: fragments.map((fragment) => fragment.path),
outputPaths,
releaseNotesPath,
};
}
export function verifyChangelogFragments(options?: ChangelogOptions): void {
readChangeFragments(options?.cwd ?? process.cwd(), options?.deps);
}
export function verifyChangelogReadyForRelease(options?: ChangelogOptions): void {
const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
const pendingFragments = resolveFragmentPaths(cwd, options?.deps);
if (pendingFragments.length > 0) {
throw new Error(`Pending changelog fragments must be released first: ${pendingFragments.join(', ')}`);
}
const changelogPath = path.join(cwd, 'CHANGELOG.md');
if (!(options?.deps?.existsSync ?? fs.existsSync)(changelogPath)) {
throw new Error(`Missing ${changelogPath}`);
}
const changelog = readFileSync(changelogPath, 'utf8');
if (extractReleaseSectionBody(changelog, version) === null) {
throw new Error(`Missing CHANGELOG section for v${version}.`);
}
}
function isFragmentPath(candidate: string): boolean {
return /^changes\/.+\.md$/u.test(candidate) && !/\/?README\.md$/iu.test(candidate);
}
function isIgnoredPullRequestPath(candidate: string): boolean {
return (
candidate === 'CHANGELOG.md'
|| candidate === 'release/release-notes.md'
|| candidate === 'AGENTS.md'
|| candidate === 'README.md'
|| candidate.startsWith('changes/')
|| candidate.startsWith('docs/')
|| candidate.startsWith('.github/')
|| candidate.startsWith('backlog/')
);
}
export function verifyPullRequestChangelog(options: PullRequestChangelogOptions): void {
const labels = (options.changedLabels ?? []).map((label) => label.trim()).filter(Boolean);
if (labels.includes(SKIP_CHANGELOG_LABEL)) {
return;
}
const normalizedEntries = options.changedEntries
.map((entry) => ({
path: entry.path.trim(),
status: entry.status.trim().toUpperCase(),
}))
.filter((entry) => entry.path);
if (normalizedEntries.length === 0) {
return;
}
const hasFragment = normalizedEntries.some(
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
);
const requiresFragment = normalizedEntries.some(
(entry) => !isIgnoredPullRequestPath(entry.path),
);
if (requiresFragment && !hasFragment) {
throw new Error(
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`,
);
}
}
function resolveChangedPathsFromGit(
cwd: string,
baseRef: string,
headRef: string,
): Array<{ path: string; status: string }> {
const output = execFileSync('git', ['diff', '--name-status', `${baseRef}...${headRef}`], {
cwd,
encoding: 'utf8',
});
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [status = '', ...paths] = line.split(/\s+/);
return {
path: paths[paths.length - 1] ?? '',
status,
};
})
.filter((entry) => entry.path);
}
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
const changelogPath = path.join(cwd, 'CHANGELOG.md');
const changelog = readFileSync(changelogPath, 'utf8');
const changes = extractReleaseSectionBody(changelog, version);
if (changes === null) {
throw new Error(`Missing CHANGELOG section for v${version}.`);
}
return writeReleaseNotesFile(cwd, changes, options?.deps);
}
function parseCliArgs(argv: string[]): {
baseRef?: string;
cwd?: string;
date?: string;
headRef?: string;
labels?: string;
version?: string;
} {
const parsed: {
baseRef?: string;
cwd?: string;
date?: string;
headRef?: string;
labels?: string;
version?: string;
} = {};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
const next = argv[index + 1];
if (current === '--cwd' && next) {
parsed.cwd = next;
index += 1;
continue;
}
if (current === '--date' && next) {
parsed.date = next;
index += 1;
continue;
}
if (current === '--version' && next) {
parsed.version = next;
index += 1;
continue;
}
if (current === '--base-ref' && next) {
parsed.baseRef = next;
index += 1;
continue;
}
if (current === '--head-ref' && next) {
parsed.headRef = next;
index += 1;
continue;
}
if (current === '--labels' && next) {
parsed.labels = next;
index += 1;
}
}
return parsed;
}
function main(): void {
const [command = 'build', ...argv] = process.argv.slice(2);
const options = parseCliArgs(argv);
if (command === 'build') {
writeChangelogArtifacts(options);
return;
}
if (command === 'check') {
verifyChangelogReadyForRelease(options);
return;
}
if (command === 'lint') {
verifyChangelogFragments(options);
return;
}
if (command === 'pr-check') {
verifyChangelogFragments(options);
verifyPullRequestChangelog({
changedLabels: options.labels?.split(',') ?? [],
changedEntries: resolveChangedPathsFromGit(
options.cwd ?? process.cwd(),
options.baseRef ?? 'origin/main',
options.headRef ?? 'HEAD',
),
});
return;
}
if (command === 'release-notes') {
writeReleaseNotesForVersion(options);
return;
}
throw new Error(`Unknown changelog command: ${command}`);
}
if (require.main === module) {
main();
}

16
src/ci-workflow.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
test('ci workflow lints changelog fragments', () => {
assert.match(ciWorkflow, /bun run changelog:lint/);
});
test('ci workflow checks pull requests for required changelog fragments', () => {
assert.match(ciWorkflow, /bun run changelog:pr-check/);
assert.match(ciWorkflow, /skip-changelog/);
});

View File

@@ -9,3 +9,12 @@ const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
test('publish release leaves prerelease unset so gh creates a normal release', () => {
assert.ok(!releaseWorkflow.includes('--prerelease'));
});
test('release workflow verifies a committed changelog section before publish', () => {
assert.match(releaseWorkflow, /bun run changelog:check/);
});
test('release workflow generates release notes from committed changelog output', () => {
assert.match(releaseWorkflow, /bun run changelog:release-notes/);
assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"'));
});