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
|
||||
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
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -281,23 +281,11 @@ jobs:
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
|
||||
else
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
if [ "$COMMIT_COUNT" -gt 10 ]; then
|
||||
CHANGES=$(git log --pretty=format:"- %s" HEAD~10..HEAD)
|
||||
else
|
||||
CHANGES=$(git log --pretty=format:"- %s")
|
||||
fi
|
||||
fi
|
||||
echo "CHANGES<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Verify changelog is ready for tagged release
|
||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
- name: Generate release notes from changelog
|
||||
run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}"
|
||||
|
||||
- name: Publish Release
|
||||
env:
|
||||
@@ -305,46 +293,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cat > release-body.md <<'EOF'
|
||||
## Changes
|
||||
${{ steps.changelog.outputs.CHANGES }}
|
||||
|
||||
## Installation
|
||||
|
||||
### AppImage (Recommended)
|
||||
1. Download the AppImage below
|
||||
2. Make it executable: `chmod +x SubMiner.AppImage`
|
||||
3. Run: `./SubMiner.AppImage`
|
||||
|
||||
### macOS
|
||||
1. Download `subminer-*.dmg`
|
||||
2. Open the DMG and drag `SubMiner.app` into `/Applications`
|
||||
3. If needed, use the ZIP artifact as an alternative
|
||||
|
||||
### Manual Installation
|
||||
See the [README](https://github.com/${{ github.repository }}#installation) for manual installation instructions.
|
||||
|
||||
### Optional Assets (config example + mpv plugin + rofi theme)
|
||||
1. Download `subminer-assets.tar.gz`
|
||||
2. Extract and copy `config.example.jsonc` to `~/.config/SubMiner/config.jsonc`
|
||||
3. Copy `plugin/subminer/` directory contents to `~/.config/mpv/scripts/`
|
||||
4. Copy `plugin/subminer.conf` to `~/.config/mpv/script-opts/`
|
||||
5. Copy `assets/themes/subminer.rasi` to:
|
||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
EOF
|
||||
|
||||
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||
# Do not pass the prerelease flag here; gh defaults to a normal release.
|
||||
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||
--title "${{ steps.version.outputs.VERSION }}" \
|
||||
--notes-file release-body.md
|
||||
--notes-file release/release-notes.md
|
||||
else
|
||||
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||
--title "${{ steps.version.outputs.VERSION }}" \
|
||||
--notes-file release-body.md
|
||||
--notes-file release/release-notes.md
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,4 +37,3 @@ tests/*
|
||||
.worktrees/
|
||||
.codex/*
|
||||
.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 -->
|
||||
|
||||
@@ -17,6 +74,7 @@ This project uses Backlog.md MCP for all task and project management activities.
|
||||
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
|
||||
|
||||
These guides cover:
|
||||
|
||||
- Decision framework for when to create tasks
|
||||
- Search-first workflow to avoid duplicates
|
||||
- Links to detailed guides for task creation, execution, and finalization
|
||||
|
||||
49
CHANGELOG.md
Normal file
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": "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",
|
||||
|
||||
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', () => {
|
||||
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