From a5dbe055fce19cfa260a837325f1c4133261fb33 Mon Sep 17 00:00:00 2001 From: Kyle Date: Thu, 9 Apr 2026 00:26:38 -0700 Subject: [PATCH] chore: prep 0.12.0-beta.1 prerelease workflow --- .github/workflows/prerelease.yml | 389 ++++++++++++++++++++++ .github/workflows/release.yml | 3 + changes/2026-04-09-prerelease-workflow.md | 5 + changes/README.md | 6 + docs/RELEASING.md | 24 ++ package.json | 5 +- scripts/build-changelog.test.ts | 183 ++++++++++ scripts/build-changelog.ts | 57 +++- src/prerelease-workflow.test.ts | 61 ++++ src/release-workflow.test.ts | 5 + 10 files changed, 732 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/prerelease.yml create mode 100644 changes/2026-04-09-prerelease-workflow.md create mode 100644 src/prerelease-workflow.test.ts diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 00000000..6d07ea96 --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,389 @@ +name: Prerelease + +on: + push: + tags: + - 'v*-beta.*' + - 'v*-rc.*' + +concurrency: + group: prerelease-${{ github.ref }} + cancel-in-progress: false + +jobs: + quality-gate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Lint stats (formatting) + run: bun run lint:stats + + - name: Build (TypeScript check) + run: bun run typecheck + + - name: Test suite (source) + run: bun run test:fast + + - name: Coverage suite (maintained source lane) + run: bun run test:coverage:src + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-test-src + path: coverage/test-src/lcov.info + if-no-files-found: error + + - name: Launcher smoke suite (source) + run: bun run test:launcher:smoke:src + + - name: Upload launcher smoke artifacts (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: launcher-smoke + path: .tmp/launcher-smoke/** + if-no-files-found: ignore + + - name: Build (bundle) + run: bun run build + + - name: Immersion SQLite verification + run: bun run test:immersion:sqlite:dist + + - name: Dist smoke suite + run: bun run test:smoke:dist + + build-linux: + needs: [quality-gate] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + run: | + cd vendor/texthooker-ui + bun install + bun run build + + - name: Build AppImage + run: bun run build:appimage + + - name: Build unversioned AppImage + run: | + shopt -s nullglob + appimages=(release/SubMiner-*.AppImage) + if [ "${#appimages[@]}" -eq 0 ]; then + echo "No versioned AppImage found to create unversioned artifact." + ls -la release + exit 1 + fi + cp "${appimages[0]}" release/SubMiner.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: appimage + path: release/*.AppImage + + build-macos: + needs: [quality-gate] + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Validate macOS signing/notarization secrets + run: | + missing=0 + for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!name}" ]; then + echo "Missing required secret: $name" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + echo "Set all required macOS signing/notarization secrets and rerun." + exit 1 + fi + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + run: | + cd vendor/texthooker-ui + bun install + bun run build + + - name: Build signed + notarized macOS artifacts + run: bun run build:mac + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos + path: | + release/*.dmg + release/*.zip + + build-windows: + needs: [quality-gate] + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + shell: powershell + run: | + Set-Location vendor/texthooker-ui + bun install + bun run build + + - name: Build unsigned Windows artifacts + run: bun run build:win:unsigned + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows + path: | + release/*.exe + release/*.zip + if-no-files-found: error + + release: + needs: [build-linux, build-macos, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download AppImage + uses: actions/download-artifact@v4 + with: + name: appimage + path: release + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos + path: release + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows + path: release + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Bun subminer wrapper + run: make build-launcher + + - name: Verify Bun subminer wrapper + run: dist/launcher/subminer --help >/dev/null + + - name: Enforce generated launcher workflow + run: bash scripts/verify-generated-launcher.sh + + - name: Verify generated config examples + run: bun run verify:config-example + + - name: Package optional assets bundle + run: | + tar -czf "release/subminer-assets.tar.gz" \ + config.example.jsonc \ + plugin/subminer \ + plugin/subminer.conf \ + assets/themes/subminer.rasi + + - name: Generate checksums + run: | + shopt -s nullglob + files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer) + if [ "${#files[@]}" -eq 0 ]; then + echo "No release artifacts found for checksum generation." + exit 1 + fi + sha256sum "${files[@]}" > release/SHA256SUMS.txt + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Generate prerelease notes from pending fragments + run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}" + + - name: Publish Prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then + gh release edit "${{ steps.version.outputs.VERSION }}" \ + --draft=false \ + --prerelease \ + --title "${{ steps.version.outputs.VERSION }}" \ + --notes-file release/prerelease-notes.md + else + gh release create "${{ steps.version.outputs.VERSION }}" \ + --latest=false \ + --prerelease \ + --title "${{ steps.version.outputs.VERSION }}" \ + --notes-file release/prerelease-notes.md + fi + + shopt -s nullglob + artifacts=( + release/*.AppImage + release/*.dmg + release/*.exe + release/*.zip + release/*.tar.gz + release/SHA256SUMS.txt + dist/launcher/subminer + ) + + if [ "${#artifacts[@]}" -eq 0 ]; then + echo "No release artifacts found for upload." + exit 1 + fi + + for asset in "${artifacts[@]}"; do + gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5c29709..3234431e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: - 'v*' + tags-ignore: + - 'v*-beta.*' + - 'v*-rc.*' concurrency: group: release-${{ github.ref }} diff --git a/changes/2026-04-09-prerelease-workflow.md b/changes/2026-04-09-prerelease-workflow.md new file mode 100644 index 00000000..37941416 --- /dev/null +++ b/changes/2026-04-09-prerelease-workflow.md @@ -0,0 +1,5 @@ +type: internal +area: release + +- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR. +- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut. diff --git a/changes/README.md b/changes/README.md index 28dc8871..2f869c80 100644 --- a/changes/README.md +++ b/changes/README.md @@ -30,3 +30,9 @@ Rules: - 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 + +Prerelease notes: + +- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` +- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md` +- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 25d9da8c..6c0c4d47 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,8 @@ # Releasing +## Stable Release + 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples. 3. Run `bun run changelog:lint`. @@ -24,15 +26,37 @@ 10. Tag the commit: `git tag v`. 11. Push commit + tag. +## Prerelease + +1. Confirm release-facing docs and pending `changes/*.md` fragments are current. +2. Run `bun run changelog:lint`. +3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`. +4. Run the prerelease gate locally: + `bun run changelog:prerelease-notes --version ` + `bun run verify:config-example` + `bun run typecheck` + `bun run test:fast` + `bun run test:env` + `bun run build` +5. Commit the prerelease prep. Do not run `bun run changelog:build`. +6. Tag the commit: `git tag v`. +7. Push commit + tag. + +Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes. + Notes: - Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`. +- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - `changelog:check` now rejects tag/package version mismatches. +- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes. - Do not tag while `changes/*.md` fragments still exist. +- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. - If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. +- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. diff --git a/package.json b/package.json index 4dc21d26..ad2deaa5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "subminer", "productName": "SubMiner", "desktopName": "SubMiner.desktop", - "version": "0.11.2", + "version": "0.12.0-beta.1", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", @@ -26,6 +26,7 @@ "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", + "changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes", "format": "prettier --write .", "format:check": "prettier --check .", "format:src": "bash scripts/prettier-scope.sh --write", @@ -69,7 +70,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 run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index d100e1a6..9c835870 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -310,3 +310,186 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and }), ); }); + +test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-beta-notes'); + const projectRoot = path.join(workspace, 'SubMiner'); + const changelogPath = path.join(projectRoot, 'CHANGELOG.md'); + const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md'); + const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n'; + const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n'; + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync(changelogPath, existingChangelog, 'utf8'); + fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8'); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '002.md'), + ['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'), + 'utf8', + ); + + try { + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.1', + }); + + assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); + assert.equal( + fs.readFileSync(changelogPath, 'utf8'), + existingChangelog, + 'stable CHANGELOG.md should remain unchanged', + ); + assert.equal( + fs.readFileSync(docsChangelogPath, 'utf8'), + existingDocsChangelog, + 'docs-site changelog should remain unchanged', + ); + assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true); + assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); + assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./); + assert.match( + prereleaseNotes, + /### Fixed\n- Launcher: Fixed prerelease packaging checks\./, + ); + assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion supports rc prereleases', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-rc-notes'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'), + 'utf8', + ); + + try { + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-rc.1', + }); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match( + prereleaseNotes, + /## Highlights\n### Changed\n- Release: Prepared release candidate notes\./, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-alpha-reject'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-alpha.1', + }), + /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-version-mismatch'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.2', + }), + /package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-no-fragments'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.1', + }), + /No changelog fragments found in changes\//, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 63bc2d3a..c5fe4158 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -38,6 +38,7 @@ type PullRequestChangelogOptions = { }; const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md'); +const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md'); const CHANGELOG_HEADER = '# Changelog'; const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal']; const CHANGE_TYPE_HEADINGS: Record = { @@ -75,6 +76,10 @@ function resolveVersion(options: Pick, ): void { @@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[ return [path.join(cwd, 'CHANGELOG.md')]; } -function renderReleaseNotes(changes: string): string { +function renderReleaseNotes( + changes: string, + options?: { + disclaimer?: string; + }, +): string { + const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; return [ + ...prefix, '## Highlights', changes, '', @@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string { ].join('\n'); } -function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string { +function writeReleaseNotesFile( + cwd: string, + changes: string, + deps?: ChangelogFsDeps, + options?: { + disclaimer?: string; + outputPath?: string; + }, +): string { const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; - const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH); + const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH); mkdirSync(path.dirname(releaseNotesPath), { recursive: true }); - writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8'); + writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8'); return releaseNotesPath; } @@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string return writeReleaseNotesFile(cwd, changes, options?.deps); } +export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string { + verifyRequestedVersionMatchesPackageVersion(options ?? {}); + + const cwd = options?.cwd ?? process.cwd(); + const version = resolveVersion(options ?? {}); + if (!isSupportedPrereleaseVersion(version)) { + throw new Error( + `Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`, + ); + } + + const fragments = readChangeFragments(cwd, options?.deps); + if (fragments.length === 0) { + throw new Error('No changelog fragments found in changes/.'); + } + + const changes = renderGroupedChanges(fragments); + return writeReleaseNotesFile(cwd, changes, options?.deps, { + disclaimer: + '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', + outputPath: PRERELEASE_NOTES_PATH, + }); +} + function parseCliArgs(argv: string[]): { baseRef?: string; cwd?: string; @@ -710,6 +754,11 @@ function main(): void { return; } + if (command === 'prerelease-notes') { + writePrereleaseNotesForVersion(options); + return; + } + if (command === 'docs') { generateDocsChangelog(options); return; diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts new file mode 100644 index 00000000..ce30aedd --- /dev/null +++ b/src/prerelease-workflow.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); +const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8'); +const packageJsonPath = resolve(__dirname, '../package.json'); +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + scripts: Record; +}; + +test('prerelease workflow triggers on beta and rc tags only', () => { + assert.match(prereleaseWorkflow, /name: Prerelease/); + assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/); + assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/); +}); + +test('package scripts expose prerelease notes generation separately from stable changelog build', () => { + assert.equal( + packageJson.scripts['changelog:prerelease-notes'], + 'bun run scripts/build-changelog.ts prerelease-notes', + ); +}); + +test('prerelease workflow generates prerelease notes from pending fragments', () => { + assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/); + assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/); +}); + +test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => { + assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/); + assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/); + assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/); +}); + +test('prerelease workflow builds and uploads all release platforms', () => { + assert.match(prereleaseWorkflow, /build-linux:/); + assert.match(prereleaseWorkflow, /build-macos:/); + assert.match(prereleaseWorkflow, /build-windows:/); + assert.match(prereleaseWorkflow, /name: appimage/); + assert.match(prereleaseWorkflow, /name: macos/); + assert.match(prereleaseWorkflow, /name: windows/); +}); + +test('prerelease workflow publishes the same release assets as the stable workflow', () => { + assert.match( + prereleaseWorkflow, + /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/, + ); + assert.match( + prereleaseWorkflow, + /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, + ); +}); + +test('prerelease workflow does not publish to AUR', () => { + assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/); + assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); + assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index cfe5a82d..a0040472 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -22,6 +22,11 @@ test('publish release leaves prerelease unset so gh creates a normal release', ( assert.ok(!releaseWorkflow.includes('--prerelease')); }); +test('stable release workflow excludes prerelease beta and rc tags', () => { + assert.match(releaseWorkflow, /tags-ignore:\s*\n\s*-\s*'v\*-beta\.\*'/); + assert.match(releaseWorkflow, /tags-ignore:\s*\n(?:.*\n)*\s*-\s*'v\*-rc\.\*'/); +}); + test('publish release forces an existing draft tag release to become public', () => { assert.ok(releaseWorkflow.includes('--draft=false')); });