From 0c37c665a24f2982c1c760314edab661bc1d1a21 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 11 Sep 2026 01:15:36 -0700 Subject: [PATCH] build(release): reduce package size and report release sizes (#244) --- .github/workflows/package-release.yml | 266 ++++++++++++++++++++ .github/workflows/prerelease.yml | 211 +--------------- .github/workflows/release.yml | 209 +-------------- bun.lock | 1 + changes/package-size-cleanup.md | 5 + docs/RELEASING.md | 42 ++++ docs/workflow/verification.md | 5 + package.json | 83 +++--- scripts/electron-builder-after-pack.cjs | 1 + scripts/electron-builder-after-pack.test.ts | 11 +- scripts/package-audit.cjs | 243 ++++++++++++++++++ scripts/package-audit.test.ts | 178 +++++++++++++ scripts/prepare-build-assets.mjs | 8 +- scripts/run-package-smoke.mjs | 24 ++ scripts/smoke-package.cjs | 94 +++++++ src/prerelease-workflow.test.ts | 104 ++++++-- src/release-workflow.test.ts | 92 ++++--- src/renderer/style.css | 2 +- src/settings/style.css | 2 +- src/syncui/style.css | 2 +- src/workflow-test-helpers.ts | 15 +- 21 files changed, 1085 insertions(+), 513 deletions(-) create mode 100644 .github/workflows/package-release.yml create mode 100644 changes/package-size-cleanup.md create mode 100644 scripts/package-audit.cjs create mode 100644 scripts/package-audit.test.ts create mode 100644 scripts/run-package-smoke.mjs create mode 100644 scripts/smoke-package.cjs diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml new file mode 100644 index 00000000..b3ce8b14 --- /dev/null +++ b/.github/workflows/package-release.yml @@ -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 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index db425fc9..485a83d3 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -16,207 +16,20 @@ jobs: contents: read uses: ./.github/workflows/quality-gate.yml - build-linux: + package: 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 }}-${{ 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 + permissions: + contents: read + uses: ./.github/workflows/package-release.yml + secrets: + 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 }} release: - needs: [build-linux, build-macos, build-windows] + needs: [package] runs-on: ubuntu-latest permissions: contents: write @@ -291,6 +104,7 @@ jobs: run: | 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/package-size-*.json) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found for checksum generation." exit 1 @@ -337,6 +151,7 @@ jobs: release/latest*.yml release/*.blockmap release/SHA256SUMS.txt + release/package-size-*.json dist/launcher/subminer dist/launcher/subminer.cmd ) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53ae1a64..09a1cd57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,205 +17,20 @@ jobs: contents: read uses: ./.github/workflows/quality-gate.yml - build-linux: + package: 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 - 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 + permissions: + contents: read + uses: ./.github/workflows/package-release.yml + secrets: + 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 }} release: - needs: [build-linux, build-macos, build-windows] + needs: [package] runs-on: ubuntu-latest permissions: contents: write @@ -290,6 +105,7 @@ jobs: run: | 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/package-size-*.json) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found for checksum generation." exit 1 @@ -354,6 +170,7 @@ jobs: release/latest*.yml release/*.blockmap release/SHA256SUMS.txt + release/package-size-*.json dist/launcher/subminer dist/launcher/subminer.cmd ) diff --git a/bun.lock b/bun.lock index b92837b2..d93cfcba 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "ws": "^8.21.0", }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", "electron": "42.6.0", diff --git a/changes/package-size-cleanup.md b/changes/package-size-cleanup.md new file mode 100644 index 00000000..f1e225eb --- /dev/null +++ b/changes/package-size-cleanup.md @@ -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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e4320e44..8438610c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -11,6 +11,48 @@ `ANTHROPIC_API_KEY` works. Install from if 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--.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 ` 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 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index 0c5c436c..7f9c41c3 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -52,6 +52,11 @@ bun run docs:build - Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Stats dashboard UI: `bun run test:stats` - Build/release scripts (`scripts/**`): `bun run test:scripts` +- Packaging: build the platform package, then run `bun run test:package `. + 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` - Deep/local full gate: default handoff gate above diff --git a/package.json b/package.json index e8e8e159..339b4b61 100644 --- a/package.json +++ b/package.json @@ -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:zip": "bun run build && electron-builder --mac 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": { "@xmldom/xmldom": "0.8.15", @@ -124,15 +125,16 @@ "ws": "^8.21.0" }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", "electron": "42.6.0", "electron-builder": "26.15.3", - "undici": "7.29.0", "esbuild": "^0.25.12", "eslint": "^10.8.0", "prettier": "^3.8.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "undici": "7.29.0" }, "build": { "appId": "com.sudacode.SubMiner", @@ -159,6 +161,10 @@ "category": "AudioVideo", "executableArgs": [ "--background" + ], + "files": [ + "package.json", + "!node_modules/koffi{,/**/*}" ] }, "mac": { @@ -177,6 +183,10 @@ "from": "dist/scripts/get-mpv-window-macos", "to": "scripts/get-mpv-window-macos" } + ], + "files": [ + "package.json", + "!node_modules/koffi{,/**/*}" ] }, "dmg": { @@ -188,7 +198,11 @@ "nsis", "zip" ], - "icon": "assets/SubMiner.ico" + "icon": "assets/SubMiner.ico", + "files": [ + "package.json", + "!node_modules/koffi/build/koffi/!(win32_${arch}){,/**/*}" + ] }, "nsis": { "artifactName": "SubMiner-${version}.${ext}", @@ -198,43 +212,19 @@ "include": "build/installer.nsh" }, "files": [ - "**/*", - "!assets{,/**/*}", - "!src{,/**/*}", - "!launcher{,/**/*}", - "!docs{,/**/*}", - "!tests{,/**/*}", - "!packaging{,/**/*}", - "!README.md", - "!CHANGELOG.md", - "!AGENTS.md", - "!CLAUDE.md", - "!stats/src{,/**/*}", - "!stats/index.html", - "!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", + "dist/**/*", + "stats/dist/**/*", + "vendor/texthooker-ui/docs/**/*", + "config.example.jsonc", + "LICENSE", + "!**/*.map", + "!**/*.{ts,tsx,mts,cts}", + "!**/*.{test,spec}.*", + "!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}", + "!dist/launcher{,/**/*}", + "!dist/scripts{,/**/*}", + "!dist/{renderer,settings,syncui}/fonts{,/**/*}", + "!node_modules/koffi/{src,vendor,doc}{,/**/*}", "!node_modules/@libsql/linux-x64-musl{,/**/*}" ], "extraResources": [ @@ -248,7 +238,13 @@ }, { "from": "assets", - "to": "assets" + "to": "assets", + "filter": [ + "SubMiner*.png", + "SubMiner.ico", + "themes/**/*", + "thumbnailers/**/*" + ] }, { "from": "plugin/subminer", @@ -273,7 +269,8 @@ "from": "CHANGELOG.md", "to": "CHANGELOG.md" } - ] + ], + "afterAllArtifactBuild": "scripts/package-audit.cjs" }, "patchedDependencies": { "@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch" diff --git a/scripts/electron-builder-after-pack.cjs b/scripts/electron-builder-after-pack.cjs index c73fe9e7..52310ab2 100644 --- a/scripts/electron-builder-after-pack.cjs +++ b/scripts/electron-builder-after-pack.cjs @@ -105,6 +105,7 @@ async function afterPack(context, deps = {}) { await stageLinuxAppImageSharedLibrary(context); await verifyMacOSWindowHelper(context); await stageBundledBunRuntime(context, deps); + await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context); } module.exports = { diff --git a/scripts/electron-builder-after-pack.test.ts b/scripts/electron-builder-after-pack.test.ts index 0e9f36a5..d8cb3a36 100644 --- a/scripts/electron-builder-after-pack.test.ts +++ b/scripts/electron-builder-after-pack.test.ts @@ -21,6 +21,7 @@ const { packager?: { appInfo?: { productFilename?: string } }; }, deps?: { + auditPackage?: (context: { appOutDir: string }) => Promise; stageBunRuntime?: (options: { appOutDir: 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 appOutDir = path.join(workspace, 'SubMiner-linux-arm64'); const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY); const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY); + const operations: string[] = []; let stagedOptions: | { appOutDir: string; @@ -200,10 +202,17 @@ test('afterPack preserves Linux staging and forwards the electron-builder target { stageBunRuntime: async (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, { appOutDir, platform: 'linux', diff --git a/scripts/package-audit.cjs b/scripts/package-audit.cjs new file mode 100644 index 00000000..9dc15757 --- /dev/null +++ b/scripts/package-audit.cjs @@ -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, +}; diff --git a/scripts/package-audit.test.ts b/scripts/package-audit.test.ts new file mode 100644 index 00000000..0232b580 --- /dev/null +++ b/scripts/package-audit.test.ts @@ -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/, + ); +}); diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index 1afc630d..ac49e4a4 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -29,10 +29,6 @@ function copyFile(sourcePath, outputPath) { function copyAssets(sourceDir, outputDir, label) { copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html')); 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`); } @@ -102,6 +98,10 @@ function buildMacosHelper() { } function main() { + fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), { + recursive: true, + force: true, + }); copyRendererAssets(); copySettingsAssets(); copySyncUiAssets(); diff --git a/scripts/run-package-smoke.mjs b/scripts/run-package-smoke.mjs new file mode 100644 index 00000000..4ef4d7b2 --- /dev/null +++ b/scripts/run-package-smoke.mjs @@ -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 '); +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 }); +} diff --git a/scripts/smoke-package.cjs b/scripts/smoke-package.cjs new file mode 100644 index 00000000..e0d39a67 --- /dev/null +++ b/scripts/smoke-package.cjs @@ -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(' { + 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); + }); diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts index c18f636c..1bc2c9b0 100644 --- a/src/prerelease-workflow.test.ts +++ b/src/prerelease-workflow.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { + executableRunLines, jobSteps, readWorkflow, stepRunsCommand, @@ -12,7 +13,14 @@ import { const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); 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 parsedPackageWorkflow = readWorkflow( + resolve(__dirname, '../.github/workflows/package-release.yml'), +); const packageJsonPath = resolve(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { scripts: Record; @@ -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', () => { - assert.match( - prereleaseWorkflow, - /quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*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/); + assert.deepEqual(parsedPrereleaseWorkflow.jobs?.['quality-gate'], { + permissions: { contents: 'read' }, + uses: './.github/workflows/quality-gate.yml', + }); }); 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', () => { - const archScopedCacheKeyMatches = prereleaseWorkflow.match( + const archScopedCacheKeyMatches = (prereleaseWorkflow + packageWorkflow).match( /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, ); 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', () => { - 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/); + assert.deepEqual(Object.keys(parsedPrereleaseWorkflow.jobs ?? {}).sort(), [ + 'package', + 'quality-gate', + 'release', + ]); + 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', () => { diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 97a73cb0..8f796f01 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { + jobSteps, readWorkflow, stepsMissingEnvDeclaration, templateExpressionsInRunBodies, @@ -10,6 +11,10 @@ import { const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); 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 docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath); @@ -96,20 +101,25 @@ test('release delegates its quality gate instead of duplicating quality steps', releaseWorkflow, /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.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/); assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/); assert.doesNotMatch(qualityGateJob, /bun run test:env/); }); -test('release build jobs install and cache stats dependencies before packaging', () => { - assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/); - assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/); - assert.match(releaseWorkflow, /build-windows:[\s\S]*stats\/node_modules/); - assert.match(releaseWorkflow, /build-linux:[\s\S]*cd stats && bun install --frozen-lockfile/); - assert.match(releaseWorkflow, /build-macos:[\s\S]*cd stats && bun install --frozen-lockfile/); - assert.match(releaseWorkflow, /build-windows:[\s\S]*cd stats && bun install --frozen-lockfile/); +test('each release build job installs stats dependencies before packaging', () => { + const workflow = readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml')); + for (const job of ['build-linux', 'build-macos', 'build-windows']) { + const steps = jobSteps(workflow, job); + const install = steps.findIndex((step) => + step.run?.includes('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', () => { @@ -163,42 +173,6 @@ test('top-level package metadata keeps Linux Electron runtime app identity canon 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', () => { const launcherResource = packageJson.build?.extraResources?.find( (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', () => { - assert.match(releaseWorkflow, /Build unsigned Windows artifacts/); - assert.match(releaseWorkflow, /run: bun run build:win:unsigned/); - assert.match(releaseWorkflow, /name: windows/); - assert.match(releaseWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/); - assert.ok(!releaseWorkflow.includes('signpath/github-action-submit-signing-request')); - assert.ok(!releaseWorkflow.includes('SIGNPATH_')); + assert.match(packageWorkflow, /Build unsigned Windows artifacts/); + assert.match(packageWorkflow, /run: bun run build:win:unsigned/); + assert.match(packageWorkflow, /name: windows/); + assert.match(packageWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/); + assert.ok(!packageWorkflow.includes('signpath/github-action-submit-signing-request')); + assert.ok(!packageWorkflow.includes('SIGNPATH_')); }); 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. 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')), + ), + [], + ); +}); diff --git a/src/renderer/style.css b/src/renderer/style.css index a99ec15b..cfa46d04 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -18,7 +18,7 @@ @font-face { 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-display: swap; } diff --git a/src/settings/style.css b/src/settings/style.css index 5540b239..389fa676 100644 --- a/src/settings/style.css +++ b/src/settings/style.css @@ -1,6 +1,6 @@ @font-face { 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-display: swap; } diff --git a/src/syncui/style.css b/src/syncui/style.css index 43fea1c2..3aa84ca0 100644 --- a/src/syncui/style.css +++ b/src/syncui/style.css @@ -1,6 +1,6 @@ @font-face { 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-display: swap; } diff --git a/src/workflow-test-helpers.ts b/src/workflow-test-helpers.ts index 9e74fae2..5aeeb59d 100644 --- a/src/workflow-test-helpers.ts +++ b/src/workflow-test-helpers.ts @@ -4,10 +4,23 @@ export type WorkflowStep = { name?: string; run?: string; env?: Record; + uses?: string; + with?: Record; }; export type ParsedWorkflow = { - jobs?: Record; + on?: { workflow_call?: { secrets?: Record } }; + jobs?: Record< + string, + | { + steps?: WorkflowStep[]; + uses?: string; + needs?: string | string[]; + permissions?: Record; + secrets?: string | Record; + } + | undefined + >; }; // Workflow tests only ever run under `bun test`, which parses YAML natively.