mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
build: enforce changelog workflow in CI
This commit is contained in:
3
.github/pull_request_template.md
vendored
Normal file
3
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Added a changelog fragment in `changes/`, or this PR is labeled `skip-changelog`
|
||||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -13,6 +13,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
@@ -39,6 +40,13 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
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)
|
- name: Build (TypeScript check)
|
||||||
# Keep explicit typecheck for fast fail before full build/bundle.
|
# Keep explicit typecheck for fast fail before full build/bundle.
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|||||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -281,23 +281,11 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Generate changelog
|
- name: Verify changelog is ready for tagged release
|
||||||
id: changelog
|
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||||
run: |
|
|
||||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
- name: Generate release notes from changelog
|
||||||
if [ -n "$PREV_TAG" ]; then
|
run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}"
|
||||||
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: Publish Release
|
- name: Publish Release
|
||||||
env:
|
env:
|
||||||
@@ -305,46 +293,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
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.
|
# Do not pass the prerelease flag here; gh defaults to a normal release.
|
||||||
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||||
--title "${{ steps.version.outputs.VERSION }}" \
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
--notes-file release-body.md
|
--notes-file release/release-notes.md
|
||||||
else
|
else
|
||||||
gh release create "${{ steps.version.outputs.VERSION }}" \
|
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||||
--title "${{ steps.version.outputs.VERSION }}" \
|
--title "${{ steps.version.outputs.VERSION }}" \
|
||||||
--notes-file release-body.md
|
--notes-file release/release-notes.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,4 +37,3 @@ tests/*
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
.codex/*
|
.codex/*
|
||||||
.agents/*
|
.agents/*
|
||||||
docs/*
|
|
||||||
|
|||||||
58
AGENTS.md
58
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: <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 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).
|
||||||
|
|
||||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
<!-- 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
|
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
||||||
|
|
||||||
These guides cover:
|
These guides cover:
|
||||||
|
|
||||||
- Decision framework for when to create tasks
|
- Decision framework for when to create tasks
|
||||||
- Search-first workflow to avoid duplicates
|
- Search-first workflow to avoid duplicates
|
||||||
- Links to detailed guides for task creation, execution, and finalization
|
- Links to detailed guides for task creation, execution, and finalization
|
||||||
|
|||||||
49
CHANGELOG.md
Normal file
49
CHANGELOG.md
Normal 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
21
changes/README.md
Normal 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
|
||||||
@@ -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: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": "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",
|
"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": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format:src": "bash scripts/prettier-scope.sh --write",
|
"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:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"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: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: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",
|
"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:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle: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",
|
"generate:config-example": "bun run build && bun dist/generate-config-example.js",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
"dev": "bun run build && electron . --start --dev",
|
"dev": "bun run build && electron . --start --dev",
|
||||||
|
|||||||
184
scripts/build-changelog.test.ts
Normal file
184
scripts/build-changelog.test.ts
Normal 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
566
scripts/build-changelog.ts
Normal 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
16
src/ci-workflow.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
@@ -9,3 +9,12 @@ const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
|
|||||||
test('publish release leaves prerelease unset so gh creates a normal release', () => {
|
test('publish release leaves prerelease unset so gh creates a normal release', () => {
|
||||||
assert.ok(!releaseWorkflow.includes('--prerelease'));
|
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"'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user