diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2c2f993 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,3 @@ +## Checklist + +- [ ] Added a changelog fragment in `changes/`, or this PR is labeled `skip-changelog` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13086fe..c158c53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01840d7..dcad15c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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<> $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 diff --git a/.gitignore b/.gitignore index b0aea3b..f5dedb5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,3 @@ tests/* .worktrees/ .codex/* .agents/* -docs/* diff --git a/AGENTS.md b/AGENTS.md index b905b00..bc63792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` + 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 repo’s 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`, …). +- Don’t 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, that’s fine (hint, not hard guardrail). +- If user types a command (“pull and push”), that’s 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). @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a085bcd --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/changes/README.md b/changes/README.md new file mode 100644 index 0000000..b811b38 --- /dev/null +++ b/changes/README.md @@ -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 diff --git a/package.json b/package.json index 4f84c62..9f4cdb0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts new file mode 100644 index 0000000..295dbcd --- /dev/null +++ b/scripts/build-changelog.test.ts @@ -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: [], + }), + ); +}); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts new file mode 100644 index 0000000..3062e88 --- /dev/null +++ b/scripts/build-changelog.ts @@ -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 = { + 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, +): 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(); + 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(); +} diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts new file mode 100644 index 0000000..3a6e45b --- /dev/null +++ b/src/ci-workflow.test.ts @@ -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/); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index de07458..4073974 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -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"')); +});