Compare commits

...
Author SHA1 Message Date
sudacode e6dc9dfec5 test: clone VM results across Electron boundaries
- Clone tokenizer VM results into the host realm
- Expect null-prototype profile maps in IPC validation tests
2026-09-11 01:55:32 -07:00
sudacode 6d69a56574 fix(overlay): keep Hyprland recovery dialogs above overlays (#245) 2026-09-11 01:52:01 -07:00
sudacode 0c37c665a2 build(release): reduce package size and report release sizes (#244) 2026-09-11 01:15:36 -07:00
sudacode 8ae5bde6a4 test(stats): increase occurrence merge test timeout 2026-09-11 01:09:59 -07:00
29 changed files with 1260 additions and 522 deletions
+266
View File
@@ -0,0 +1,266 @@
name: Package release
on:
workflow_call:
secrets:
CSC_LINK:
required: true
CSC_KEY_PASSWORD:
required: true
APPLE_ID:
required: true
APPLE_APP_SPECIFIC_PASSWORD:
required: true
APPLE_TEAM_ID:
required: true
permissions:
contents: read
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- 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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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 --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- 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: Smoke packaged runtime assets
shell: bash
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: appimage
path: |
release/*.AppImage
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-macos:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- 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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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 --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- 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: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos
path: |
release/*.dmg
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
persist-credentials: false
- 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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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 --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Build unsigned Windows artifacts
run: bun run build:win:unsigned
- name: Smoke packaged runtime assets
shell: bash
run: bun run test:package "release/win-unpacked/resources"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: |
release/*.exe
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
+13 -198
View File
@@ -16,207 +16,20 @@ jobs:
contents: read contents: read
uses: ./.github/workflows/quality-gate.yml uses: ./.github/workflows/quality-gate.yml
build-linux: package:
needs: [quality-gate] needs: [quality-gate]
runs-on: ubuntu-latest permissions:
steps: contents: read
- name: Checkout uses: ./.github/workflows/package-release.yml
uses: actions/checkout@v4 secrets:
with: CSC_LINK: ${{ secrets.CSC_LINK }}
submodules: true CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
- name: Setup Bun APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
uses: oven-sh/setup-bun@v2 APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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
release/latest*.yml
release/*.blockmap
if-no-files-found: error
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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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
release/latest*.yml
release/*.blockmap
if-no-files-found: error
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 }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-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: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- 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
release/latest*.yml
release/*.blockmap
if-no-files-found: error
release: release:
needs: [build-linux, build-macos, build-windows] needs: [package]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -291,6 +104,7 @@ jobs:
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -337,6 +151,7 @@ jobs:
release/latest*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer dist/launcher/subminer
dist/launcher/subminer.cmd dist/launcher/subminer.cmd
) )
+13 -196
View File
@@ -17,205 +17,20 @@ jobs:
contents: read contents: read
uses: ./.github/workflows/quality-gate.yml uses: ./.github/workflows/quality-gate.yml
build-linux: package:
needs: [quality-gate] needs: [quality-gate]
runs-on: ubuntu-latest permissions:
steps: contents: read
- name: Checkout uses: ./.github/workflows/package-release.yml
uses: actions/checkout@v4 secrets:
with: CSC_LINK: ${{ secrets.CSC_LINK }}
submodules: true CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
- name: Setup Bun APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
uses: oven-sh/setup-bun@v2 APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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
release/latest*.yml
release/*.blockmap
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
release/latest*.yml
release/*.blockmap
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: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
- name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- 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
release/latest*.yml
release/*.blockmap
if-no-files-found: error
release: release:
needs: [build-linux, build-macos, build-windows] needs: [package]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -290,6 +105,7 @@ jobs:
run: | run: |
shopt -s nullglob shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd) files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
@@ -354,6 +170,7 @@ jobs:
release/latest*.yml release/latest*.yml
release/*.blockmap release/*.blockmap
release/SHA256SUMS.txt release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer dist/launcher/subminer
dist/launcher/subminer.cmd dist/launcher/subminer.cmd
) )
+1
View File
@@ -18,6 +18,7 @@
"ws": "^8.21.0", "ws": "^8.21.0",
}, },
"devDependencies": { "devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: release
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows.
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
+4
View File
@@ -378,6 +378,10 @@ windowrule = no_blur on, match:class SubMiner
If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay - the `opaque`/`opacity` and `no_blur` fields above override it. If you still see a solid background or visual artifacts instead of the mpv video underneath, the culprit is almost always a global opacity/blur rule applying to the overlay - the `opaque`/`opacity` and `no_blur` fields above override it.
**Application Not Responding dialog covered by the overlay**
SubMiner keeps visible Hyprland system dialogs above its windows on the same workspace when updating overlay placement. This lets you click the recovery dialog even while the overlay accepts mouse input. If the whole SubMiner process is frozen, use Hyprland's window-focus bindings to reach the dialog; SubMiner cannot update window order until it resumes.
**Global shortcuts not working** **Global shortcuts not working**
On Hyprland, Electron cannot register global shortcuts on its own. You must explicitly pass keybindings to SubMiner using `pass` rules: On Hyprland, Electron cannot register global shortcuts on its own. You must explicitly pass keybindings to SubMiner using `pass` rules:
+42
View File
@@ -11,6 +11,48 @@
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if `ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it. you don't already have it.
## Package contents and size checks
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
TypeScript files, and nested test or fixture directories
fail the build. Size measurements are informational and do not block releases.
Current targets are Linux x64, macOS arm64, and Windows x64.
The runtime allowlist includes `dist/`, `stats/dist/`, and
`vendor/texthooker-ui/docs/` plus metadata, config example, and license. The
texthooker `docs/` directory is its built UI. Keep the positive `package.json`
pattern in platform `files` lists: electron-builder otherwise treats an
exclusion-only platform list as a separate include-all matcher. Windows keeps
only its target Koffi binary; other platforms omit Koffi. Desktop UIs share the
original M PLUS 1 TTF in `dist/fonts/`.
`release/package-size-<platform>-<arch>.json` reports unpacked bytes, largest
files inside and outside ASAR, native binaries, and compressed artifact sizes.
Framework symlinks are not counted twice. Reports are checksummed and published.
CI downloads the preceding release's reports for comparison; older releases
without reports skip comparison. Review the inventory and reason for growth
when comparing releases. An AppImage normally
runs compressed; its extracted size is a separate measurement.
The shared workflow runs `bun run test:package <resources-directory>` with the
pinned Electron runtime and temporary user data. On headless Linux, prefix it
with `xvfb-run -a`. This checks packaged SQLite, Windows FFI loading/polling,
texthooker serving, Yomitan loading, UI assets, and Japanese font loading.
Standalone pages lack app IPC handlers and can log related errors; this check
does not replace an installed app session.
Before shipping packaging changes, check each platform's installed app:
startup and mpv tracking, dictionary lookup and stroke orders, settings/sync UI,
stats persistence, sentence mining with AnkiConnect, and updating from the prior
release. Preserve Electron locales, graphics fallbacks, codecs, dictionaries,
license notices, updater metadata, blockmaps, and the macOS updater ZIP. Trim
files before signing and generating updater hashes, never from a signed app.
## Stable Release ## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
+5
View File
@@ -52,6 +52,11 @@ bun run docs:build
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats` - Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts` - Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
Content checks and informational size reporting run inside electron-builder hooks. See the
[release guide](../RELEASING.md#package-contents-and-size-checks) for size reports
and the installed-app verification checklist.
- Coverage for the maintained source lane: `bun run test:coverage:src` - Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above - Deep/local full gate: default handoff gate above
+40 -43
View File
@@ -80,7 +80,8 @@
"build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip --publish never", "build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip --publish never",
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never", "build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
"build:win": "bun run build && electron-builder --win nsis zip --publish never", "build:win": "bun run build && electron-builder --win nsis zip --publish never",
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs" "build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
"test:package": "bun scripts/run-package-smoke.mjs"
}, },
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.15", "@xmldom/xmldom": "0.8.15",
@@ -124,15 +125,16 @@
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@electron/asar": "3.4.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"undici": "7.29.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"undici": "7.29.0"
}, },
"build": { "build": {
"appId": "com.sudacode.SubMiner", "appId": "com.sudacode.SubMiner",
@@ -159,6 +161,10 @@
"category": "AudioVideo", "category": "AudioVideo",
"executableArgs": [ "executableArgs": [
"--background" "--background"
],
"files": [
"package.json",
"!node_modules/koffi{,/**/*}"
] ]
}, },
"mac": { "mac": {
@@ -177,6 +183,10 @@
"from": "dist/scripts/get-mpv-window-macos", "from": "dist/scripts/get-mpv-window-macos",
"to": "scripts/get-mpv-window-macos" "to": "scripts/get-mpv-window-macos"
} }
],
"files": [
"package.json",
"!node_modules/koffi{,/**/*}"
] ]
}, },
"dmg": { "dmg": {
@@ -188,7 +198,11 @@
"nsis", "nsis",
"zip" "zip"
], ],
"icon": "assets/SubMiner.ico" "icon": "assets/SubMiner.ico",
"files": [
"package.json",
"!node_modules/koffi/build/koffi/!(win32_${arch}){,/**/*}"
]
}, },
"nsis": { "nsis": {
"artifactName": "SubMiner-${version}.${ext}", "artifactName": "SubMiner-${version}.${ext}",
@@ -198,43 +212,19 @@
"include": "build/installer.nsh" "include": "build/installer.nsh"
}, },
"files": [ "files": [
"**/*", "dist/**/*",
"!assets{,/**/*}", "stats/dist/**/*",
"!src{,/**/*}", "vendor/texthooker-ui/docs/**/*",
"!launcher{,/**/*}", "config.example.jsonc",
"!docs{,/**/*}", "LICENSE",
"!tests{,/**/*}", "!**/*.map",
"!packaging{,/**/*}", "!**/*.{ts,tsx,mts,cts}",
"!README.md", "!**/*.{test,spec}.*",
"!CHANGELOG.md", "!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
"!AGENTS.md", "!dist/launcher{,/**/*}",
"!CLAUDE.md", "!dist/scripts{,/**/*}",
"!stats/src{,/**/*}", "!dist/{renderer,settings,syncui}/fonts{,/**/*}",
"!stats/index.html", "!node_modules/koffi/{src,vendor,doc}{,/**/*}",
"!stats/public{,/**/*}",
"!stats/package.json",
"!stats/tsconfig.json",
"!stats/vite.config.ts",
"!docs-site{,/**/*}",
"!changes{,/**/*}",
"!.tmp{,/**/*}",
"!release-*{,/**/*}",
"!dist/**/*.map",
"!dist/**/*.test.*",
"!dist/**/__tests__{,/**/*}",
"!scripts/**/*.test.*",
"!plugin{,/**/*}",
"!vendor/subminer-yomitan{,/**/*}",
"!vendor/yomitan-jlpt-vocab{,/**/*}",
"!vendor/texthooker-ui/src{,/**/*}",
"!vendor/texthooker-ui/node_modules{,/**/*}",
"!vendor/texthooker-ui/.svelte-kit{,/**/*}",
"!vendor/texthooker-ui/.vscode{,/**/*}",
"!vendor/texthooker-ui/public{,/**/*}",
"!vendor/texthooker-ui/README.md",
"!vendor/texthooker-ui/package.json",
"!vendor/texthooker-ui/package-lock.json",
"!vendor/texthooker-ui/tsconfig*.json",
"!node_modules/@libsql/linux-x64-musl{,/**/*}" "!node_modules/@libsql/linux-x64-musl{,/**/*}"
], ],
"extraResources": [ "extraResources": [
@@ -248,7 +238,13 @@
}, },
{ {
"from": "assets", "from": "assets",
"to": "assets" "to": "assets",
"filter": [
"SubMiner*.png",
"SubMiner.ico",
"themes/**/*",
"thumbnailers/**/*"
]
}, },
{ {
"from": "plugin/subminer", "from": "plugin/subminer",
@@ -273,7 +269,8 @@
"from": "CHANGELOG.md", "from": "CHANGELOG.md",
"to": "CHANGELOG.md" "to": "CHANGELOG.md"
} }
] ],
"afterAllArtifactBuild": "scripts/package-audit.cjs"
}, },
"patchedDependencies": { "patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch" "@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
+1
View File
@@ -105,6 +105,7 @@ async function afterPack(context, deps = {}) {
await stageLinuxAppImageSharedLibrary(context); await stageLinuxAppImageSharedLibrary(context);
await verifyMacOSWindowHelper(context); await verifyMacOSWindowHelper(context);
await stageBundledBunRuntime(context, deps); await stageBundledBunRuntime(context, deps);
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
} }
module.exports = { module.exports = {
+10 -1
View File
@@ -21,6 +21,7 @@ const {
packager?: { appInfo?: { productFilename?: string } }; packager?: { appInfo?: { productFilename?: string } };
}, },
deps?: { deps?: {
auditPackage?: (context: { appOutDir: string }) => Promise<void>;
stageBunRuntime?: (options: { stageBunRuntime?: (options: {
appOutDir: string; appOutDir: string;
platform: string; platform: string;
@@ -172,11 +173,12 @@ test('afterPack propagates Linux staging failures', async () => {
} }
}); });
test('afterPack preserves Linux staging and forwards the electron-builder target to Bun staging', async () => { test('afterPack stages Linux and Bun runtime assets before auditing the package', async () => {
const workspace = createWorkspace('subminer-after-pack-target'); const workspace = createWorkspace('subminer-after-pack-target');
const appOutDir = path.join(workspace, 'SubMiner-linux-arm64'); const appOutDir = path.join(workspace, 'SubMiner-linux-arm64');
const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY); const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY);
const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY); const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY);
const operations: string[] = [];
let stagedOptions: let stagedOptions:
| { | {
appOutDir: string; appOutDir: string;
@@ -200,10 +202,17 @@ test('afterPack preserves Linux staging and forwards the electron-builder target
{ {
stageBunRuntime: async (options) => { stageBunRuntime: async (options) => {
stagedOptions = options; stagedOptions = options;
operations.push('stage-bun');
},
auditPackage: async (context) => {
assert.equal(context.appOutDir, appOutDir);
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
operations.push('audit');
}, },
}, },
); );
assert.deepEqual(operations, ['stage-bun', 'audit']);
assert.deepEqual(stagedOptions, { assert.deepEqual(stagedOptions, {
appOutDir, appOutDir,
platform: 'linux', platform: 'linux',
+243
View File
@@ -0,0 +1,243 @@
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const asar = require('@electron/asar');
const { Arch } = require('builder-util');
const MIB = 1024 * 1024;
const currentReports = new Set();
const REQUIRED_APP_FILES = [
'package.json',
'LICENSE',
'config.example.jsonc',
'dist/main-entry.js',
'dist/main.js',
'dist/preload.js',
'dist/preload-settings.js',
'dist/preload-syncui.js',
'dist/preload-stats.js',
'dist/preload-jellyfin-setup.js',
'dist/fonts/MPLUS1[wght].ttf',
'stats/dist/index.html',
'vendor/texthooker-ui/docs/index.html',
...['renderer', 'settings', 'syncui'].flatMap((ui) => [
`dist/${ui}/index.html`,
`dist/${ui}/style.css`,
`dist/${ui}/${ui}.js`,
]),
];
const REQUIRED_RESOURCES = [
'yomitan/manifest.json',
'yomitan/data/fonts/kanji-stroke-orders.ttf',
'yomitan/fonts/NotoSansJP-Regular.ttf',
'yomitan/lib/resvg.wasm',
'launcher/subminer',
'plugin/subminer/main.lua',
'plugin/subminer.conf',
'assets/SubMiner.png',
'assets/SubMiner-square.png',
'assets/themes/subminer.rasi',
'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'CHANGELOG.md',
];
// Do not follow framework symlinks or count ASAR unpacked entries twice.
function listFiles(root, prefix = '') {
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) return [];
if (entry.isDirectory()) return listFiles(root, name);
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
});
}
function listAppFiles(archive) {
return asar.listPackage(archive).flatMap((entry) => {
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
const stat = asar.statFile(archive, name);
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
});
}
function verifyAppPath(name, platform, arch) {
const allowedRoots = new Set([
'dist',
'node_modules',
'stats',
'vendor',
'package.json',
'LICENSE',
'config.example.jsonc',
]);
assert(allowedRoots.has(name.split('/')[0]), `Unexpected app file: ${name}`);
assert(!name.endsWith('.map'), `Packaged source map: ${name}`);
assert(!/\.(?:[cm]?ts|tsx)$/.test(name), `Packaged TypeScript: ${name}`);
assert(!/\.(?:test|spec)\./.test(name), `Packaged test: ${name}`);
assert(
!/(?:^|\/)(?:tests?|__tests__|fixtures?|__fixtures__)\//.test(name),
`Packaged test or fixture directory: ${name}`,
);
assert(!/^dist\/.*\.test\./.test(name), `Packaged test: ${name}`);
assert(!/^dist\/(launcher|scripts)\//.test(name), `Duplicate helper: ${name}`);
assert(!/^dist\/(renderer|settings|syncui)\/fonts\//.test(name), `Duplicate font: ${name}`);
assert(!name.startsWith('stats/') || name.startsWith('stats/dist/'), `Stats source: ${name}`);
assert(
!name.startsWith('vendor/') || name.startsWith('vendor/texthooker-ui/docs/'),
`Vendor source: ${name}`,
);
if (name.startsWith('node_modules/koffi/')) {
assert.equal(platform, 'win32', `Koffi shipped on ${platform}`);
assert(!/^node_modules\/koffi\/(src|vendor|doc)\//.test(name), `Koffi build files: ${name}`);
if (name.endsWith('.node')) {
assert.equal(name, `node_modules/koffi/build/koffi/win32_${arch}/koffi.node`);
}
}
}
function verifyContents(archive, resources, platform, arch) {
const entries = listAppFiles(archive);
const names = new Set(entries.map((entry) => entry.path));
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
for (const name of REQUIRED_RESOURCES) {
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
}
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
const libsqlPlatform = {
linux: `linux-${arch}-gnu`,
darwin: `darwin-${arch}`,
win32: `win32-${arch}-msvc`,
}[platform];
const libsqlBinary = `node_modules/@libsql/${libsqlPlatform}/index.node`;
assert(names.has(libsqlBinary), `Missing SQLite native binary: ${libsqlBinary}`);
for (const name of names) {
if (name.startsWith('node_modules/@libsql/') && name.endsWith('.node')) {
assert.equal(name, libsqlBinary, `Foreign SQLite binary: ${name}`);
}
}
if (platform === 'win32') {
for (const name of [
'index.js',
'package.json',
'LICENSE.txt',
`build/koffi/win32_${arch}/koffi.node`,
]) {
assert(names.has(`node_modules/koffi/${name}`), `Missing Windows FFI file: ${name}`);
}
}
for (const name of listFiles(path.join(resources, 'assets'))) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
}
for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
}
return entries;
}
async function auditPackage(context) {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
const key = `${platform}-${arch}`;
const appRoot =
platform === 'darwin'
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
: context.appOutDir;
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
const files = listFiles(appRoot);
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
const report = {
version: context.packager.appInfo.version,
platform,
arch,
unpackedBytes,
appDirectory: path.relative(context.outDir, appRoot),
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
artifacts: [],
};
const output = path.join(context.outDir, `package-size-${key}.json`);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
currentReports.add(output);
console.log(
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
);
}
function artifactKind(name) {
if (name.endsWith('-mac.zip')) return 'mac.zip';
if (name.endsWith('-win.zip')) return 'win.zip';
const extension = path.extname(name).slice(1);
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
}
function compareSizes(report, previous) {
assert.equal(previous.platform, report.platform);
assert.equal(previous.arch, report.arch);
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
return {
version: previous.version,
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
artifacts: report.artifacts.flatMap((artifact) => {
const old = previousArtifacts.find(
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
);
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
}),
};
}
// Runs after signing and installer creation, before release upload.
async function afterAllArtifactBuild(result) {
const reports = [];
for (const reportPath of currentReports) {
const filename = path.basename(reportPath);
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const key = `${report.platform}-${report.arch}`;
const files = listFiles(path.join(result.outDir, report.appDirectory));
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
report.artifacts = result.artifactPaths.flatMap((file) => {
const kind = artifactKind(file);
if (!kind) return [];
const bytes = fs.statSync(file).size;
return [{ name: path.basename(file), kind, bytes }];
});
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
if (fs.existsSync(previousPath)) {
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
report.comparison = compareSizes(report, previous);
}
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const summary = [
`### Package size: ${key}`,
'',
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
report.comparison
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
: 'No previous size report available.',
'',
].join('\n');
console.log(summary);
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
reports.push(reportPath);
}
assert(reports.length > 0, 'No package size reports generated by afterPack');
return reports;
}
module.exports = {
auditPackage,
verifyContents,
verifyAppPath,
listFiles,
listAppFiles,
compareSizes,
default: afterAllArtifactBuild,
};
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync, createReadStream } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createPackageFromStreams } from '@electron/asar';
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
import config from '../package.json';
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
const root = process.cwd();
const fileStat = statSync('package.json');
for (const platform of ['linux', 'mac', 'win'] as const) {
const matchers = getFileMatchers(
{ files: [{ filter: config.build.files }] },
'files',
'/tmp/subminer-filter-output',
{
defaultSrc: root,
globalOutDir: path.join(root, 'release'),
customBuildOptions: { files: config.build[platform].files },
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
},
);
assert(matchers);
// This is builder's default for an exclusion-only platform matcher.
for (const matcher of matchers) {
if (matcher.containsOnlyIgnore()) matcher.prependPattern('**/*');
}
const included = (name: string) =>
matchers.some((matcher) => matcher.createFilter()(path.join(root, name), fileStat));
for (const name of [
'dist/main-entry.js',
'dist/fonts/MPLUS1[wght].ttf',
'stats/dist/index.html',
'vendor/texthooker-ui/docs/index.html',
'package.json',
]) {
assert(included(name), `${platform} must ship ${name}`);
}
for (const name of [
'.agents/skills/test.md',
'src/main.ts',
'scripts/build-yomitan.mjs',
'docs-site/index.md',
'dist/main.js.map',
'dist/main.test.js',
'dist/nested/source.ts',
'dist/nested/__tests__/helper.js',
'stats/dist/nested/fixtures/data.json',
'vendor/texthooker-ui/docs/nested/component.tsx',
'dist/launcher/subminer',
'dist/settings/fonts/MPLUS1[wght].ttf',
'vendor/subminer-yomitan/ext/manifest.json',
]) {
assert(!included(name), `${platform} must exclude ${name}`);
}
}
});
test('dependency filters keep only the target Windows Koffi binary', () => {
const root = process.cwd();
for (const arch of ['x64', 'arm64']) {
for (const platform of ['linux', 'mac', 'win'] as const) {
const patterns = [
'**/*',
...config.build.files.filter((name) => name.startsWith('!')),
...config.build[platform].files.filter((name) => name.startsWith('!')),
];
const filter = new FileMatcher(
root,
'/tmp/subminer-filter-output',
(value) => value.replaceAll('${arch}', arch),
patterns,
).createFilter();
const included = (name: string) =>
filter(path.join(root, 'node_modules', name), statSync('package.json'));
assert(included('@libsql/win32-x64-msvc/index.node'));
assert(!included('axios/dist/axios.js.map'));
assert(!included('koffi/src/koffi/src/ffi.c'));
assert(!included('agent-base/src/index.ts'));
assert(!included('@discordjs/rest/dist/index.d.mts'));
assert(!included('example/lib/tests/helper.js'));
for (const target of [
'win32_x64',
'win32_arm64',
'linux_x64',
'darwin_arm64',
'openbsd_x64',
]) {
assert.equal(
included(`koffi/build/koffi/${target}/koffi.node`),
platform === 'win' && target === `win32_${arch}`,
`${platform}/${arch}: ${target}`,
);
}
assert.equal(included('koffi/index.js'), platform === 'win');
assert.equal(included('koffi/LICENSE.txt'), platform === 'win');
}
}
});
test('content audit rejects development files beneath approved roots', () => {
for (const root of ['dist', 'stats/dist', 'vendor/texthooker-ui/docs', 'node_modules/example']) {
for (const suffix of [
'nested/source.ts',
'nested/component.tsx',
'nested/types.d.mts',
'nested/source.cts',
'nested/__tests__/helper.js',
'nested/tests/helper.js',
'nested/test/helper.js',
'nested/__fixtures__/data.json',
'nested/fixtures/data.json',
'nested/fixture/data.json',
'nested/component.spec.js',
'nested/component.test.cjs',
]) {
assert.throws(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'), /Packaged/);
}
for (const suffix of ['nested/runtime.js', 'nested/style.css', 'nested/data.json']) {
assert.doesNotThrow(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'));
}
}
});
test('archive inventory handles native files without counting them twice on disk', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
try {
const input = path.join(root, 'input');
const output = path.join(root, 'output');
mkdirSync(input);
mkdirSync(output);
writeFileSync(path.join(input, 'main.js'), 'hello');
writeFileSync(path.join(input, 'native.node'), 'native');
const archive = path.join(output, 'app.asar');
await createPackageFromStreams(
archive,
['main.js', 'native.node'].map((name) => ({
path: name,
type: 'file',
unpacked: name.endsWith('.node'),
stat: statSync(path.join(input, name)),
streamGenerator: () => createReadStream(path.join(input, name)),
})),
);
assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 },
]);
assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
statSync(archive).size + 6,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('size comparison tolerates older reports without artifact measurements', () => {
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
assert.deepEqual(compareSizes(current, previous), {
version: '0.19.6',
unpackedDeltaBytes: -20,
artifacts: [],
});
assert.deepEqual(
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
.artifacts,
[{ kind: 'AppImage', deltaBytes: -10 }],
);
assert.throws(
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
/Invalid previous size report/,
);
});
+4 -4
View File
@@ -29,10 +29,6 @@ function copyFile(sourcePath, outputPath) {
function copyAssets(sourceDir, outputDir, label) { function copyAssets(sourceDir, outputDir, label) {
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html')); copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css')); copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true,
force: true,
});
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`); process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
} }
@@ -102,6 +98,10 @@ function buildMacosHelper() {
} }
function main() { function main() {
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
recursive: true,
force: true,
});
copyRendererAssets(); copyRendererAssets();
copySettingsAssets(); copySettingsAssets();
copySyncUiAssets(); copySyncUiAssets();
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const resources = process.argv[2];
if (!resources) throw new Error('Usage: bun run test:package <resources-directory>');
const require = createRequire(import.meta.url);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-package-smoke-'));
const env = { ...process.env, SUBMINER_PACKAGE_SMOKE_DATA: profile };
delete env.ELECTRON_RUN_AS_NODE;
try {
const result = spawnSync(
require('electron'),
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
{ env, stdio: 'inherit', timeout: 75_000 },
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
}
+94
View File
@@ -0,0 +1,94 @@
// Run with the pinned Electron runtime against a finished app's resources folder.
const { app, BrowserWindow, session } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const assert = require('node:assert/strict');
const { once } = require('node:events');
const resources = path.resolve(process.argv[2]);
const archive = path.join(resources, 'app.asar');
const isolatedData = process.env.SUBMINER_PACKAGE_SMOKE_DATA;
assert(
isolatedData && fs.existsSync(isolatedData),
'Use bun run test:package to create an isolated profile',
);
app.setPath('userData', isolatedData);
app.disableHardwareAcceleration();
app.on('window-all-closed', () => {});
const timeout = setTimeout(() => {
console.error('Package smoke timed out');
app.exit(1);
}, 60_000);
async function smoke() {
await app.whenReady();
const packagedRequire = createRequire(path.join(archive, 'package.json'));
const Database = packagedRequire('libsql');
const database = new Database(':memory:');
assert.equal(database.prepare('select 42 as answer').get().answer, 42);
database.close();
if (process.platform === 'win32') {
const win32 = packagedRequire('./dist/window-trackers/win32.js');
assert(Array.isArray(win32.findMpvWindows().matches));
}
const { Texthooker } = packagedRequire('./dist/core/services/texthooker.js');
const texthooker = new Texthooker();
const server = texthooker.start(0);
assert(server, 'Packaged texthooker assets could not be found');
try {
await once(server, 'listening');
const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
assert.equal(response.status, 200);
assert((await response.text()).includes('<html'));
} finally {
texthooker.stop();
}
const extension = await session.defaultSession.extensions.loadExtension(
path.join(resources, 'yomitan'),
{ allowFileAccess: true },
);
assert(extension.id, 'Yomitan extension failed to load');
const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
if (details.error !== 'net::ERR_ABORTED')
failedRequests.push(`${details.url}: ${details.error}`);
});
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
const win = new BrowserWindow({
show: false,
webPreferences: {
sandbox: false,
preload: path.join(archive, 'dist', ui === 'renderer' ? 'preload.js' : `preload-${ui}.js`),
},
});
try {
await win.loadFile(
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
);
if (ui !== 'stats') {
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
assert(loaded, `${ui}: shared Japanese font failed to load`);
}
} finally {
win.destroy();
}
}
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
);
}
smoke()
.then(() => {
clearTimeout(timeout);
app.exit(0);
})
.catch((error) => {
console.error(error);
clearTimeout(timeout);
app.exit(1);
});
@@ -156,6 +156,107 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
); );
}); });
test('Hyprland placement keeps a recovery dialog above the input-catching overlay', () => {
for (const configProvider of ['hyprlang', 'lua']) {
for (const retryBounds of [false, true]) {
// Bottom to top, as when Hyprland opens its recovery dialog over playback.
const stack = ['0xmpv', '0xoverlay', '0xdialog'];
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
at: [10, 20],
size: [100, 100],
},
{
address: '0xdialog',
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
},
];
let clientReads = 0;
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
bounds: retryBounds ? { x: 0, y: 0, width: 1280, height: 720 } : undefined,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') {
clientReads += 1;
return JSON.stringify(clients);
}
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider });
if (args.join(' ').match(/alterzorder|alter_zorder/)) {
const address = args.join(' ').match(/address:(0x\w+)/)?.[1];
assert.ok(address);
stack.splice(stack.indexOf(address), 1);
stack.push(address);
}
return '';
},
});
assert.equal(status.dispatched, true);
assert.equal(clientReads, retryBounds ? 2 : 1);
assert.deepEqual(stack, ['0xmpv', '0xoverlay', '0xdialog'], configProvider);
}
}
});
test('Hyprland placement only promotes mapped dialogs on the placed window workspace', () => {
const calls: string[] = [];
const dialog = {
class: 'hyprland-dialog',
mapped: true,
hidden: false,
workspace: { id: 1 },
};
const clients = [
{
address: '0xoverlay',
pid: 456,
title: 'SubMiner Overlay',
floating: true,
workspace: { id: 1 },
},
{ ...dialog, address: '0xhidden', hidden: true },
{ ...dialog, address: '0xunmapped', mapped: false },
{ ...dialog, address: '0xother', workspace: { id: 2 } },
{ ...dialog, address: '0xordinary', class: 'terminal' },
{ ...dialog, address: '0xinitial', class: '', initialClass: 'hyprland-dialog' },
];
for (const promote of [true, false]) {
calls.length = 0;
ensureHyprlandWindowFloatingByTitleWithStatus({
title: 'SubMiner Overlay',
platform: 'linux',
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
pid: 456,
promote,
execFileSync: (_command, args) => {
if (args.join(' ') === '-j clients') return JSON.stringify(clients);
if (args.join(' ') === '-j status') return JSON.stringify({ configProvider: 'hyprlang' });
calls.push(args.join(' '));
return '';
},
});
assert.deepEqual(
calls,
promote
? [
'dispatch alterzorder top,address:0xoverlay',
'dispatch alterzorder top,address:0xinitial',
]
: [],
);
}
});
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => { test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
const buildDispatches = buildHyprlandPlacementDispatches as ( const buildDispatches = buildHyprlandPlacementDispatches as (
client: Parameters<typeof buildHyprlandPlacementDispatches>[0], client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
+52 -4
View File
@@ -3,14 +3,17 @@ import { execFileSync } from 'node:child_process';
export interface HyprlandPlacementClient { export interface HyprlandPlacementClient {
address?: string; address?: string;
at?: [number, number]; at?: [number, number];
class?: string;
floating?: boolean; floating?: boolean;
hidden?: boolean; hidden?: boolean;
initialClass?: string;
initialTitle?: string; initialTitle?: string;
mapped?: boolean; mapped?: boolean;
pid?: number; pid?: number;
pinned?: boolean; pinned?: boolean;
size?: [number, number]; size?: [number, number];
title?: string; title?: string;
workspace?: { id: number };
} }
export interface HyprlandPlacementBounds { export interface HyprlandPlacementBounds {
@@ -25,7 +28,11 @@ export interface HyprlandPlacementDispatchOptions {
promote?: boolean; promote?: boolean;
} }
type ExecFileSync = typeof execFileSync; type ExecFileSync = (
file: string,
args: string[],
options: NonNullable<Parameters<typeof execFileSync>[2]>,
) => ReturnType<typeof execFileSync>;
export type HyprlandConfigProvider = 'hyprlang' | 'lua'; export type HyprlandConfigProvider = 'hyprlang' | 'lua';
export function shouldAttemptHyprlandWindowPlacement( export function shouldAttemptHyprlandWindowPlacement(
@@ -154,6 +161,33 @@ function luaWindowDispatch(name: string, windowAddress: string, fields: string[]
]; ];
} }
// Compositor recovery dialogs must remain clickable even when an overlay still owns input.
function buildHyprlandDialogPromotionDispatches(
clients: HyprlandPlacementClient[],
placedClient: HyprlandPlacementClient,
configProvider: HyprlandConfigProvider,
): string[][] {
if (typeof placedClient.workspace?.id !== 'number') return [];
return clients.flatMap((client) => {
if (
!client.address ||
client.address === placedClient.address ||
client.mapped === false ||
client.hidden === true ||
client.workspace?.id !== placedClient.workspace?.id ||
(client.class !== 'hyprland-dialog' && client.initialClass !== 'hyprland-dialog')
) {
return [];
}
const windowAddress = `address:${client.address}`;
return [
configProvider === 'lua'
? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"'])
: ['dispatch', 'alterzorder', `top,${windowAddress}`],
];
});
}
function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] { function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] {
return luaWindowDispatch('set_prop', windowAddress, [ return luaWindowDispatch('set_prop', windowAddress, [
`prop = ${luaString(prop)}`, `prop = ${luaString(prop)}`,
@@ -331,12 +365,16 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
configProvider, configProvider,
promote: options.promote, promote: options.promote,
}); });
if (options.promote !== false) {
dispatches.push(...buildHyprlandDialogPromotionDispatches(clients, client, configProvider));
}
for (const args of dispatches) { for (const args of dispatches) {
run('hyprctl', args, { stdio: 'ignore' }); run('hyprctl', args, { stdio: 'ignore' });
} }
if (shouldVerifyBounds) { if (shouldVerifyBounds) {
try { try {
const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), { const refreshedClients = readHyprlandPlacementClients(run);
const refreshedClient = findHyprlandWindowForPlacement(refreshedClients, {
pid: options.pid ?? process.pid, pid: options.pid ?? process.pid,
title: options.title, title: options.title,
}); });
@@ -345,10 +383,20 @@ export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
targetBounds && targetBounds &&
clientMatchesPlacementBounds(refreshedClient, targetBounds) === false clientMatchesPlacementBounds(refreshedClient, targetBounds) === false
) { ) {
for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, { const retryDispatches = buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
configProvider, configProvider,
promote: options.promote, promote: options.promote,
})) { });
if (options.promote !== false) {
retryDispatches.push(
...buildHyprlandDialogPromotionDispatches(
refreshedClients,
refreshedClient,
configProvider,
),
);
}
for (const args of retryDispatches) {
run('hyprctl', args, { stdio: 'ignore' }); run('hyprctl', args, { stdio: 'ignore' });
} }
} }
+7 -1
View File
@@ -1037,7 +1037,13 @@ test('registerIpcHandlers accepts per-controller profile config updates', async
}, },
}; };
await saveHandler({}, update); await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]); assert.deepEqual(controllerSaves, [
{
...update,
// Validation uses a null prototype to safely store arbitrary profile IDs.
profiles: { __proto__: null, ...update.profiles },
},
]);
await assert.rejects(async () => { await assert.rejects(async () => {
await saveHandler( await saveHandler(
@@ -66,7 +66,7 @@ function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number
for (const legacyOccurrences of [false, true]) { for (const legacyOccurrences of [false, true]) {
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer'; const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
test(`sync merge carries occurrence timestamps in from ${label}`, () => { test(`sync merge carries occurrence timestamps in from ${label}`, { timeout: 15_000 }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
try { try {
const local = buildDb(dir, 'local.sqlite', { const local = buildDb(dir, 'local.sqlite', {
@@ -396,7 +396,8 @@ function createInjectedScriptVm(store: ReplayMessageStore): (script: string) =>
Set, Set,
String, String,
}); });
return async (script: string) => await vm.runInContext(script, context); // Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
} }
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps { export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
@@ -64,7 +64,8 @@ export async function runInjectedYomitanScript(
script: string, script: string,
handler: (action: string, params: unknown) => unknown, handler: (action: string, params: unknown) => unknown,
): Promise<unknown> { ): Promise<unknown> {
return await vm.runInNewContext(script, createYomitanScriptSandbox(handler)); // Clone results into the host realm, matching Electron's process boundary.
return structuredClone(await vm.runInNewContext(script, createYomitanScriptSandbox(handler)));
} }
// Persistent page context shared across executeJavaScript calls, matching the // Persistent page context shared across executeJavaScript calls, matching the
@@ -75,7 +76,8 @@ function createPersistentYomitanScriptRunner(
handler: (action: string, params: unknown) => unknown, handler: (action: string, params: unknown) => unknown,
): (script: string) => Promise<unknown> { ): (script: string) => Promise<unknown> {
const context = vm.createContext(createYomitanScriptSandbox(handler)); const context = vm.createContext(createYomitanScriptSandbox(handler));
return async (script: string) => await vm.runInContext(script, context); // Clone results into the host realm, matching Electron's process boundary.
return async (script: string) => structuredClone(await vm.runInContext(script, context));
} }
// Deps whose parser window executes every injected script (profile metadata, // Deps whose parser window executes every injected script (profile metadata,
+87 -17
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { import {
executableRunLines,
jobSteps, jobSteps,
readWorkflow, readWorkflow,
stepRunsCommand, stepRunsCommand,
@@ -12,7 +13,14 @@ import {
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n'); const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n');
const packageWorkflow = readFileSync(
resolve(__dirname, '../.github/workflows/package-release.yml'),
'utf8',
);
const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath); const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath);
const parsedPackageWorkflow = readWorkflow(
resolve(__dirname, '../.github/workflows/package-release.yml'),
);
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>; scripts: Record<string, string>;
@@ -42,15 +50,10 @@ test('prerelease workflow uses committed prerelease notes and never calls claude
}); });
test('prerelease delegates its quality gate instead of duplicating quality steps', () => { test('prerelease delegates its quality gate instead of duplicating quality steps', () => {
assert.match( assert.deepEqual(parsedPrereleaseWorkflow.jobs?.['quality-gate'], {
prereleaseWorkflow, permissions: { contents: 'read' },
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/, uses: './.github/workflows/quality-gate.yml',
); });
const qualityGateJob = prereleaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0];
assert.ok(qualityGateJob);
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
assert.doesNotMatch(qualityGateJob, /bun run test:env/);
}); });
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => { test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
@@ -60,10 +63,10 @@ test('prerelease workflow publishes GitHub prereleases and keeps them off latest
}); });
test('prerelease packaging workflows scope dependency caches by runner architecture', () => { test('prerelease packaging workflows scope dependency caches by runner architecture', () => {
const archScopedCacheKeyMatches = prereleaseWorkflow.match( const archScopedCacheKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
/key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, /key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
); );
const archScopedRestoreKeyMatches = prereleaseWorkflow.match( const archScopedRestoreKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
/\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, /\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
); );
assert.equal(archScopedCacheKeyMatches?.length, 4); assert.equal(archScopedCacheKeyMatches?.length, 4);
@@ -71,12 +74,79 @@ test('prerelease packaging workflows scope dependency caches by runner architect
}); });
test('prerelease workflow builds and uploads all release platforms', () => { test('prerelease workflow builds and uploads all release platforms', () => {
assert.match(prereleaseWorkflow, /build-linux:/); assert.deepEqual(Object.keys(parsedPrereleaseWorkflow.jobs ?? {}).sort(), [
assert.match(prereleaseWorkflow, /build-macos:/); 'package',
assert.match(prereleaseWorkflow, /build-windows:/); 'quality-gate',
assert.match(prereleaseWorkflow, /name: appimage/); 'release',
assert.match(prereleaseWorkflow, /name: macos/); ]);
assert.match(prereleaseWorkflow, /name: windows/); assert.equal(
parsedPrereleaseWorkflow.jobs?.package?.uses,
'./.github/workflows/package-release.yml',
);
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.package?.needs, ['quality-gate']);
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.release?.needs, ['package']);
assert.deepEqual(Object.keys(parsedPackageWorkflow.jobs ?? {}).sort(), [
'build-linux',
'build-macos',
'build-windows',
]);
for (const [job, name, paths] of [
['build-linux', 'appimage', ['release/*.AppImage']],
['build-macos', 'macos', ['release/*.dmg', 'release/*.zip']],
['build-windows', 'windows', ['release/*.exe', 'release/*.zip']],
] as const) {
const uploads = jobSteps(parsedPackageWorkflow, job).filter(
(step) => step.uses === 'actions/upload-artifact@v4',
);
assert.equal(uploads.length, 1);
const upload = uploads[0];
assert.ok(upload);
assert.equal(upload.with?.name, name);
assert.equal(upload.with?.['if-no-files-found'], 'error');
const uploadPath = upload.with?.path;
assert.ok(typeof uploadPath === 'string');
assert.deepEqual(uploadPath.trim().split('\n'), [
...paths,
'release/latest*.yml',
'release/*.blockmap',
'release/package-size-*.json',
]);
const download = jobSteps(parsedPrereleaseWorkflow, 'release').find(
(step) => step.uses === 'actions/download-artifact@v4' && step.with?.name === name,
);
assert.equal(download?.with?.path, 'release');
}
const steps = jobSteps(parsedPrereleaseWorkflow, 'release');
const checksum = steps.find((step) => step.name === 'Generate checksums');
const publish = steps.find((step) => step.name === 'Publish Prerelease');
assert.ok(checksum);
assert.ok(publish);
assert.ok(executableRunLines(checksum).includes('files+=(release/package-size-*.json)'));
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
});
test('release callers pass only the declared macOS signing secrets to packaging', () => {
const secrets = [
'CSC_LINK',
'CSC_KEY_PASSWORD',
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
];
assert.deepEqual(
parsedPackageWorkflow.on?.workflow_call?.secrets,
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
);
for (const workflow of [
parsedPrereleaseWorkflow,
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
]) {
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
assert.deepEqual(
workflow.jobs?.package?.secrets,
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
);
}
}); });
test('prerelease workflow publishes both launcher wrappers with the platform packages', () => { test('prerelease workflow publishes both launcher wrappers with the platform packages', () => {
+42 -50
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { import {
jobSteps,
readWorkflow, readWorkflow,
stepsMissingEnvDeclaration, stepsMissingEnvDeclaration,
templateExpressionsInRunBodies, templateExpressionsInRunBodies,
@@ -10,6 +11,10 @@ import {
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
const packageWorkflow = readFileSync(
resolve(__dirname, '../.github/workflows/package-release.yml'),
'utf8',
);
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath); const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath);
@@ -96,20 +101,25 @@ test('release delegates its quality gate instead of duplicating quality steps',
releaseWorkflow, releaseWorkflow,
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/, /quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
); );
const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0]; const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n package:)/)?.[0];
assert.ok(qualityGateJob); assert.ok(qualityGateJob);
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/); assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/); assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
assert.doesNotMatch(qualityGateJob, /bun run test:env/); assert.doesNotMatch(qualityGateJob, /bun run test:env/);
}); });
test('release build jobs install and cache stats dependencies before packaging', () => { test('each release build job installs stats dependencies before packaging', () => {
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/); const workflow = readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml'));
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/); for (const job of ['build-linux', 'build-macos', 'build-windows']) {
assert.match(releaseWorkflow, /build-windows:[\s\S]*stats\/node_modules/); const steps = jobSteps(workflow, job);
assert.match(releaseWorkflow, /build-linux:[\s\S]*cd stats && bun install --frozen-lockfile/); const install = steps.findIndex((step) =>
assert.match(releaseWorkflow, /build-macos:[\s\S]*cd stats && bun install --frozen-lockfile/); step.run?.includes('cd stats && bun install --frozen-lockfile'),
assert.match(releaseWorkflow, /build-windows:[\s\S]*cd stats && bun install --frozen-lockfile/); );
const build = steps.findIndex((step) =>
/bun run build:(appimage|mac|win)/.test(step.run ?? ''),
);
assert(install >= 0 && build > install, `${job} must install stats before packaging`);
}
}); });
test('release workflow generates release notes from committed changelog output', () => { test('release workflow generates release notes from committed changelog output', () => {
@@ -163,42 +173,6 @@ test('top-level package metadata keeps Linux Electron runtime app identity canon
assert.equal(packageJson.desktopName, 'SubMiner.desktop'); assert.equal(packageJson.desktopName, 'SubMiner.desktop');
}); });
test('release packaging keeps default file inclusion and excludes large source-only trees explicitly', () => {
const files = packageJson.build?.files ?? [];
assert.ok(files.includes('**/*'));
assert.ok(files.includes('!src{,/**/*}'));
assert.ok(files.includes('!launcher{,/**/*}'));
assert.ok(files.includes('!stats/src{,/**/*}'));
assert.ok(files.includes('!.tmp{,/**/*}'));
assert.ok(files.includes('!release-*{,/**/*}'));
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
assert.ok(files.includes('!assets{,/**/*}'));
assert.ok(files.includes('!plugin{,/**/*}'));
assert.ok(files.includes('!vendor/yomitan-jlpt-vocab{,/**/*}'));
assert.ok(files.includes('!docs{,/**/*}'));
assert.ok(files.includes('!tests{,/**/*}'));
assert.ok(files.includes('!packaging{,/**/*}'));
assert.ok(files.includes('!README.md'));
assert.ok(files.includes('!CHANGELOG.md'));
assert.ok(files.includes('!AGENTS.md'));
assert.ok(files.includes('!CLAUDE.md'));
assert.ok(files.includes('!stats/public{,/**/*}'));
assert.ok(files.includes('!stats/package.json'));
assert.ok(files.includes('!stats/tsconfig.json'));
assert.ok(files.includes('!stats/vite.config.ts'));
assert.ok(files.includes('!dist/**/*.map'));
assert.ok(files.includes('!dist/**/*.test.*'));
assert.ok(files.includes('!dist/**/__tests__{,/**/*}'));
assert.ok(files.includes('!scripts/**/*.test.*'));
assert.ok(files.includes('!vendor/texthooker-ui/public{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/.vscode{,/**/*}'));
assert.ok(files.includes('!vendor/texthooker-ui/README.md'));
assert.ok(files.includes('!vendor/texthooker-ui/package.json'));
assert.ok(files.includes('!vendor/texthooker-ui/tsconfig*.json'));
assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}'));
});
test('release packaging stages only the generated launcher runtime artifacts', () => { test('release packaging stages only the generated launcher runtime artifacts', () => {
const launcherResource = packageJson.build?.extraResources?.find( const launcherResource = packageJson.build?.extraResources?.find(
(resource) => resource.from === 'dist/launcher' && resource.to === 'launcher', (resource) => resource.from === 'dist/launcher' && resource.to === 'launcher',
@@ -239,12 +213,12 @@ test('config example generation runs directly from source without unrelated bund
}); });
test('windows release workflow publishes unsigned artifacts directly without SignPath', () => { test('windows release workflow publishes unsigned artifacts directly without SignPath', () => {
assert.match(releaseWorkflow, /Build unsigned Windows artifacts/); assert.match(packageWorkflow, /Build unsigned Windows artifacts/);
assert.match(releaseWorkflow, /run: bun run build:win:unsigned/); assert.match(packageWorkflow, /run: bun run build:win:unsigned/);
assert.match(releaseWorkflow, /name: windows/); assert.match(packageWorkflow, /name: windows/);
assert.match(releaseWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/); assert.match(packageWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/);
assert.ok(!releaseWorkflow.includes('signpath/github-action-submit-signing-request')); assert.ok(!packageWorkflow.includes('signpath/github-action-submit-signing-request'));
assert.ok(!releaseWorkflow.includes('SIGNPATH_')); assert.ok(!packageWorkflow.includes('SIGNPATH_'));
}); });
test('release artifact names are distinct before upload', () => { test('release artifact names are distinct before upload', () => {
@@ -306,3 +280,21 @@ test('release and docs workflows keep tag-derived values out of shell bodies', (
// that would be substituted into the condition before the shell reads it. // that would be substituted into the condition before the shell reads it.
assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/); assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/);
}); });
test('stable and prerelease builds use the same packaging gate', () => {
const prerelease = readFileSync(
resolve(__dirname, '../.github/workflows/prerelease.yml'),
'utf8',
);
for (const workflow of [releaseWorkflow, prerelease]) {
assert.match(workflow, /uses: \.\/\.github\/workflows\/package-release\.yml/);
assert.match(workflow, /needs: \[package\]/);
assert.match(workflow, /release\/package-size-\*\.json/);
}
assert.deepEqual(
templateExpressionsInRunBodies(
readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml')),
),
[],
);
});
+1 -1
View File
@@ -18,7 +18,7 @@
@font-face { @font-face {
font-family: 'M PLUS 1'; font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype'); src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-display: swap; font-display: swap;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: 'M PLUS 1'; font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype'); src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-display: swap; font-display: swap;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: 'M PLUS 1'; font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype'); src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900; font-weight: 100 900;
font-display: swap; font-display: swap;
} }
+14 -1
View File
@@ -4,10 +4,23 @@ export type WorkflowStep = {
name?: string; name?: string;
run?: string; run?: string;
env?: Record<string, unknown>; env?: Record<string, unknown>;
uses?: string;
with?: Record<string, unknown>;
}; };
export type ParsedWorkflow = { export type ParsedWorkflow = {
jobs?: Record<string, { steps?: WorkflowStep[] } | undefined>; on?: { workflow_call?: { secrets?: Record<string, { required?: boolean }> } };
jobs?: Record<
string,
| {
steps?: WorkflowStep[];
uses?: string;
needs?: string | string[];
permissions?: Record<string, string>;
secrets?: string | Record<string, string>;
}
| undefined
>;
}; };
// Workflow tests only ever run under `bun test`, which parses YAML natively. // Workflow tests only ever run under `bun test`, which parses YAML natively.