Electron Release Matrix #51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Electron Release Matrix | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| build_mode: | |
| description: Select strict full-matrix auditing or release-only supported targets. | |
| required: true | |
| default: supported-only | |
| type: choice | |
| options: | |
| - supported-only | |
| - strict-all | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - ".github/workflows/electron-release-matrix.yml" | |
| - "NSMusicS-Electron/**" | |
| tags: | |
| - "v*" | |
| - "electron-v*" | |
| - "NSMusicS-v*" | |
| - "validate-v*" | |
| - "validate-electron-v*" | |
| - "validate-NSMusicS-v*" | |
| concurrency: | |
| group: electron-release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| env: | |
| PROJECT_DIR: NSMusicS-Electron | |
| NODE_VERSION: "20.15.0" | |
| ELECTRON_CACHE: ${{ github.workspace }}/NSMusicS-Electron/.cache/electron | |
| ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/NSMusicS-Electron/.cache/electron-builder | |
| BUILD_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_mode || 'supported-only' }} | |
| ARTIFACT_PREFIX: nsmusics-electron | |
| jobs: | |
| plan: | |
| name: Build Plan | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build_mode: ${{ steps.plan.outputs.build_mode }} | |
| matrix: ${{ steps.plan.outputs.matrix }} | |
| all_targets: ${{ steps.plan.outputs.all_targets }} | |
| planned_targets: ${{ steps.plan.outputs.planned_targets }} | |
| steps: | |
| - name: Generate build plan | |
| id: plan | |
| shell: bash | |
| env: | |
| WORKFLOW_BUILD_MODE: ${{ env.BUILD_MODE }} | |
| run: | | |
| node - <<'NODE' | |
| const fs = require('node:fs') | |
| const buildMode = process.env.WORKFLOW_BUILD_MODE | |
| const outputPath = process.env.GITHUB_OUTPUT | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const allTargets = [ | |
| { | |
| platform: 'win', | |
| platform_name: 'windows', | |
| electron_platform: 'win32', | |
| arch: 'x64', | |
| runner: 'windows-latest', | |
| supported: true, | |
| support_reason: | |
| 'The workflow installs a Windows x64 mpv runtime and stages it into the packaged resources path expected by src/background.ts.', | |
| }, | |
| { | |
| platform: 'win', | |
| platform_name: 'windows', | |
| electron_platform: 'win32', | |
| arch: 'ia32', | |
| runner: 'windows-latest', | |
| supported: true, | |
| support_reason: | |
| 'The workflow downloads a pinned Windows i686 mpv runtime, stages it into resources, and rebuilds native Electron dependencies for ia32 packaging.', | |
| }, | |
| { | |
| platform: 'win', | |
| platform_name: 'windows', | |
| electron_platform: 'win32', | |
| arch: 'arm64', | |
| runner: 'windows-latest', | |
| supported: true, | |
| support_reason: | |
| 'The workflow downloads a pinned Windows arm64 mpv runtime, stages it into resources, and rebuilds native Electron dependencies for arm64 packaging.', | |
| }, | |
| { | |
| platform: 'linux', | |
| platform_name: 'linux', | |
| electron_platform: 'linux', | |
| arch: 'x64', | |
| runner: 'ubuntu-24.04', | |
| supported: true, | |
| support_reason: | |
| 'src/background.ts resolves Linux mpv from NSMUSICS_MPV_BINARY or system PATH, and the workflow installs and validates a system mpv runtime on ubuntu-24.04.', | |
| }, | |
| { | |
| platform: 'linux', | |
| platform_name: 'linux', | |
| electron_platform: 'linux', | |
| arch: 'arm64', | |
| runner: 'ubuntu-24.04-arm', | |
| supported: true, | |
| support_reason: | |
| 'The workflow uses a native Ubuntu arm64 runner, installs system mpv, rebuilds Electron native modules, and publishes Linux arm64 AppImage, deb, rpm, and tar.gz assets.', | |
| }, | |
| { | |
| platform: 'mac', | |
| platform_name: 'macos', | |
| electron_platform: 'darwin', | |
| arch: 'x64', | |
| runner: 'macos-15-intel', | |
| supported: true, | |
| support_reason: | |
| 'The workflow downloads a pinned macOS Intel mpv.app runtime and stages it into the packaged resources path expected by src/background.ts.', | |
| }, | |
| { | |
| platform: 'mac', | |
| platform_name: 'macos', | |
| electron_platform: 'darwin', | |
| arch: 'arm64', | |
| runner: 'macos-15', | |
| supported: true, | |
| support_reason: | |
| 'The workflow downloads a pinned macOS Apple Silicon mpv.app runtime and stages it into the packaged resources path expected by src/background.ts.', | |
| }, | |
| ] | |
| const plannedTargets = | |
| buildMode === 'strict-all' | |
| ? allTargets | |
| : allTargets.filter((target) => target.supported) | |
| const writeOutput = (key, value) => { | |
| fs.appendFileSync(outputPath, `${key}=${value}\n`) | |
| } | |
| writeOutput('build_mode', buildMode) | |
| writeOutput('matrix', JSON.stringify({ include: plannedTargets })) | |
| writeOutput('all_targets', JSON.stringify(allTargets)) | |
| writeOutput('planned_targets', JSON.stringify(plannedTargets)) | |
| if (summaryPath) { | |
| const lines = [ | |
| '## Build Plan', | |
| `- Mode: ${buildMode}`, | |
| `- Total targets: ${allTargets.length}`, | |
| `- Planned targets: ${plannedTargets.length}`, | |
| ] | |
| fs.appendFileSync(summaryPath, `${lines.join('\n')}\n`) | |
| } | |
| NODE | |
| policy: | |
| name: Release Policy | |
| needs: plan | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Verify deterministic dependency lockfile | |
| working-directory: NSMusicS-Electron | |
| shell: bash | |
| run: | | |
| if [ ! -f package-lock.json ]; then | |
| { | |
| echo "## Release Policy" | |
| echo "- FAIL package-lock.json is missing." | |
| echo "- Reason: production releases must use a committed npm lockfile for deterministic dependency resolution." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| echo "::error title=Release Policy::package-lock.json is missing. Production releases must use a committed npm lockfile." | |
| exit 1 | |
| fi | |
| { | |
| echo "## Release Policy" | |
| echo "- PASS package-lock.json is present." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| - name: Verify release tag matches package version | |
| if: ${{ startsWith(github.ref, 'refs/tags/') }} | |
| working-directory: NSMusicS-Electron | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| raw_tag="${GITHUB_REF_NAME}" | |
| normalized_tag="${raw_tag}" | |
| normalized_tag="${normalized_tag#validate-}" | |
| normalized_tag="${normalized_tag#electron-v}" | |
| normalized_tag="${normalized_tag#NSMusicS-v}" | |
| normalized_tag="${normalized_tag#v}" | |
| package_version="$(node -p "require('./package.json').version")" | |
| { | |
| echo "## Release Tag Policy" | |
| echo "- Raw tag: ${raw_tag}" | |
| echo "- Normalized tag version: ${normalized_tag}" | |
| echo "- package.json version: ${package_version}" | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| if [ -z "${normalized_tag}" ]; then | |
| echo "::error title=Release Policy::Unable to normalize release tag version from ${raw_tag}." | |
| exit 1 | |
| fi | |
| if [ "${normalized_tag}" != "${package_version}" ]; then | |
| echo "::error title=Release Policy::Tag version ${normalized_tag} does not match package.json version ${package_version}." | |
| exit 1 | |
| fi | |
| echo "- PASS release tag version matches package.json." >> "${GITHUB_STEP_SUMMARY}" | |
| build: | |
| name: Build ${{ matrix.platform_name }}-${{ matrix.arch }} | |
| needs: | |
| - plan | |
| - policy | |
| runs-on: ${{ matrix.runner }} | |
| timeout-minutes: 90 | |
| defaults: | |
| run: | |
| shell: pwsh | |
| working-directory: NSMusicS-Electron | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: npm | |
| cache-dependency-path: NSMusicS-Electron/package-lock.json | |
| - name: Print build context | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| run: | | |
| node -e "console.log(JSON.stringify({runnerOS: process.env.RUNNER_OS, node: process.version, hostPlatform: process.platform, hostArch: process.arch, targetPlatform: process.env.NSMUSICS_ELECTRON_PLATFORM, targetArch: process.env.NSMUSICS_ELECTRON_ARCH}, null, 2))" | |
| - name: Summarize build policy | |
| run: | | |
| Add-Content $env:GITHUB_STEP_SUMMARY "## Build Policy" | |
| Add-Content $env:GITHUB_STEP_SUMMARY "- Mode: ${{ env.BUILD_MODE }}" | |
| Add-Content $env:GITHUB_STEP_SUMMARY "- Target: ${{ matrix.platform_name }}-${{ matrix.arch }}" | |
| Add-Content $env:GITHUB_STEP_SUMMARY "- Repository supported: ${{ matrix.supported }}" | |
| Add-Content $env:GITHUB_STEP_SUMMARY "- Support note: ${{ matrix.support_reason }}" | |
| - name: Fail unsupported target in strict-all mode | |
| if: ${{ env.BUILD_MODE == 'strict-all' && !matrix.supported }} | |
| run: | | |
| $message = "Strict-all rejected ${{ matrix.platform_name }}-${{ matrix.arch }}. ${{ matrix.support_reason }}" | |
| Add-Content $env:GITHUB_STEP_SUMMARY "- FAIL $message" | |
| Write-Host "::error title=Repository Compatibility::$message" | |
| throw $message | |
| - name: Install Linux packaging dependencies | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y build-essential python3 ruby ruby-dev rpm fakeroot libarchive-tools mpv dpkg desktop-file-utils squashfs-tools xz-utils | |
| sudo gem install --no-document fpm | |
| ruby --version | |
| gem --version | |
| fpm --version | |
| - name: Provision Windows mpv runtime | |
| if: runner.os == 'Windows' | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $releaseTag = '20260408' | |
| switch ('${{ matrix.arch }}') { | |
| 'x64' { | |
| $archiveName = 'mpv-x86_64-20260408-git-e2180e5.7z' | |
| $targetDirName = 'mpv-x86_64-20260408' | |
| $expectedSha256 = 'fa5696629db058d0a2b0817f054e497d4afb556584a18a73565ffc5454e493fa' | |
| } | |
| 'ia32' { | |
| $archiveName = 'mpv-i686-20260408-git-e2180e5.7z' | |
| $targetDirName = 'mpv-i686-20260408' | |
| $expectedSha256 = 'b8ed6fc4cd08f9f8a4a5d5cd549cae97a55411c60018afdf766126446974735d' | |
| } | |
| 'arm64' { | |
| $archiveName = 'mpv-aarch64-20260408-git-e2180e5.7z' | |
| $targetDirName = 'mpv-aarch64-20260408' | |
| $expectedSha256 = '53033dd82f737cb9de50f611ef58e6137262bb2ef95c45167266f46091f87656' | |
| } | |
| default { | |
| throw "Unsupported Windows mpv target arch: ${{ matrix.arch }}" | |
| } | |
| } | |
| $archiveUrl = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/$releaseTag/$archiveName" | |
| $archivePath = Join-Path $env:RUNNER_TEMP $archiveName | |
| $extractRoot = Join-Path $env:RUNNER_TEMP ("mpv-extract-" + '${{ matrix.arch }}') | |
| $sevenZip = Get-Command 7z.exe -ErrorAction SilentlyContinue | |
| if (-not $sevenZip) { | |
| throw '7z.exe is required to extract the pinned Windows mpv runtime archive.' | |
| } | |
| if (Test-Path $extractRoot) { | |
| Remove-Item -LiteralPath $extractRoot -Recurse -Force | |
| } | |
| New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null | |
| Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath | |
| $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() | |
| if ($actualSha256 -ne $expectedSha256) { | |
| throw "Windows mpv runtime hash mismatch for $archiveName. Expected=$expectedSha256 Actual=$actualSha256" | |
| } | |
| & $sevenZip.Source x $archivePath "-o$extractRoot" -y | Out-Host | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "7z extraction failed for $archivePath" | |
| } | |
| $mpvBinary = Get-ChildItem -Path $extractRoot -Filter mpv.exe -File -Recurse -ErrorAction SilentlyContinue | | |
| Sort-Object FullName | | |
| Select-Object -First 1 | |
| if (-not $mpvBinary) { | |
| throw "Unable to locate mpv.exe after extracting $archiveName" | |
| } | |
| $sourceDir = Split-Path $mpvBinary.FullName -Parent | |
| $targetDir = Join-Path $PWD ("resources\" + $targetDirName) | |
| if (Test-Path $targetDir) { | |
| Remove-Item -LiteralPath $targetDir -Recurse -Force | |
| } | |
| New-Item -ItemType Directory -Path $targetDir -Force | Out-Null | |
| Get-ChildItem -LiteralPath $sourceDir -Force | ForEach-Object { | |
| Copy-Item -LiteralPath $_.FullName -Destination $targetDir -Recurse -Force | |
| } | |
| $preparedBinary = Join-Path $targetDir 'mpv.exe' | |
| if (-not (Test-Path $preparedBinary)) { | |
| throw "Prepared Windows mpv runtime missing mpv.exe at $preparedBinary" | |
| } | |
| Write-Host "Prepared Windows mpv runtime from: $sourceDir" | |
| Get-ChildItem -LiteralPath $targetDir -Force | |
| - name: Provision macOS mpv runtime | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| case "${{ matrix.arch }}" in | |
| arm64) | |
| MPV_URL="https://laboratory.stolendata.net/~djinn/mpv_osx/mpv-arm64-0.39.0.tar.gz" | |
| MPV_SHA256="9c81c5cf2e756cf9c2fef97a00627140ea7c6b46079469ce3b1a13b4cd4f5b2b" | |
| ;; | |
| x64) | |
| MPV_URL="https://laboratory.stolendata.net/~djinn/mpv_osx/mpv-0.39.0.tar.gz" | |
| MPV_SHA256="35ec81ad86a97b24956a8d0f4fa1ba2690b44ae7741c920e923620bcd7bd402a" | |
| ;; | |
| *) | |
| echo "Unsupported macOS architecture for mpv provisioning: ${{ matrix.arch }}" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| ARCHIVE_PATH="$RUNNER_TEMP/mpv-${{ matrix.arch }}.tar.gz" | |
| EXTRACT_DIR="$RUNNER_TEMP/mpv-extract-${{ matrix.arch }}" | |
| rm -rf "$EXTRACT_DIR" | |
| mkdir -p "$EXTRACT_DIR" | |
| curl -L --fail "$MPV_URL" -o "$ARCHIVE_PATH" | |
| echo "$MPV_SHA256 $ARCHIVE_PATH" | shasum -a 256 -c - | |
| tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" | |
| APP_PATH="$(find "$EXTRACT_DIR" -maxdepth 2 -type d -name 'mpv.app' | head -n 1 || true)" | |
| if [ -z "$APP_PATH" ]; then | |
| echo "Unable to locate mpv.app after extracting $MPV_URL" >&2 | |
| exit 1 | |
| fi | |
| TARGET_DIR="$PWD/resources/mpv-0.39.0" | |
| rm -rf "$TARGET_DIR" | |
| mkdir -p "$TARGET_DIR" | |
| cp -R "$APP_PATH" "$TARGET_DIR/mpv.app" | |
| test -f "$TARGET_DIR/mpv.app/Contents/MacOS/mpv" | |
| file "$TARGET_DIR/mpv.app/Contents/MacOS/mpv" || true | |
| echo "Prepared macOS mpv runtime from: $MPV_URL" | |
| - name: Prepare CI helper files | |
| run: | | |
| New-Item -ItemType Directory -Path .github/workflows/.tmp -Force | Out-Null | |
| @' | |
| import { defineConfig, loadEnv } from 'vite' | |
| import vue from '@vitejs/plugin-vue' | |
| import AutoImport from 'unplugin-auto-import/vite' | |
| import Components from 'unplugin-vue-components/vite' | |
| import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' | |
| import path from 'node:path' | |
| export default defineConfig(async ({ mode }) => { | |
| const env = loadEnv(mode, process.cwd(), '') | |
| const _tailwind = await import('@tailwindcss/vite') | |
| const tailwindcss = _tailwind && ('default' in _tailwind ? _tailwind.default : _tailwind) | |
| return { | |
| plugins: [ | |
| vue({ template: { compilerOptions: { hoistStatic: false } } }), | |
| AutoImport({ | |
| imports: [ | |
| 'vue', | |
| { | |
| 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'], | |
| }, | |
| ], | |
| }), | |
| Components({ | |
| resolvers: [NaiveUiResolver()], | |
| }), | |
| tailwindcss(), | |
| ], | |
| base: './', | |
| resolve: { | |
| alias: { | |
| '@': path.resolve(process.cwd(), 'src'), | |
| }, | |
| }, | |
| server: { | |
| proxy: { | |
| '/api': { | |
| target: env.BACKEND_SERVICE || 'http://localhost:8082', | |
| changeOrigin: true, | |
| rewrite: (value) => value.replace(/^\/api/, ''), | |
| }, | |
| }, | |
| }, | |
| } | |
| }) | |
| '@ | Set-Content -Path .github/workflows/.tmp/vite.ci.config.mjs -Encoding utf8 | |
| @' | |
| const childProcess = require('node:child_process') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const platformName = process.env.NSMUSICS_ELECTRON_PLATFORM | |
| const archName = process.env.NSMUSICS_ELECTRON_ARCH | |
| const key = `${platformName}:${archName}` | |
| if (platformName === 'win' && archName !== 'x64') { | |
| console.log( | |
| JSON.stringify( | |
| { | |
| platformName, | |
| archName, | |
| note: 'Skipping frontend native package repair for cross-arch Windows packaging; renderer toolchain binaries run on the x64 Windows host runner.', | |
| }, | |
| null, | |
| 2 | |
| ) | |
| ) | |
| process.exit(0) | |
| } | |
| const packageSets = { | |
| 'win:x64': [ | |
| '@esbuild/win32-x64', | |
| '@rollup/rollup-win32-x64-msvc', | |
| '@tailwindcss/oxide-win32-x64-msvc', | |
| 'lightningcss-win32-x64-msvc', | |
| ], | |
| 'linux:x64': [ | |
| '@esbuild/linux-x64', | |
| '@rollup/rollup-linux-x64-gnu', | |
| '@tailwindcss/oxide-linux-x64-gnu', | |
| 'lightningcss-linux-x64-gnu', | |
| ], | |
| 'linux:arm64': [ | |
| '@esbuild/linux-arm64', | |
| '@rollup/rollup-linux-arm64-gnu', | |
| '@tailwindcss/oxide-linux-arm64-gnu', | |
| 'lightningcss-linux-arm64-gnu', | |
| ], | |
| 'mac:x64': [ | |
| '@esbuild/darwin-x64', | |
| '@rollup/rollup-darwin-x64', | |
| '@tailwindcss/oxide-darwin-x64', | |
| 'lightningcss-darwin-x64', | |
| ], | |
| 'mac:arm64': [ | |
| '@esbuild/darwin-arm64', | |
| '@rollup/rollup-darwin-arm64', | |
| '@tailwindcss/oxide-darwin-arm64', | |
| 'lightningcss-darwin-arm64', | |
| ], | |
| } | |
| const requiredPackages = packageSets[key] || [] | |
| const packageExists = (packageName) => | |
| fs.existsSync(path.join(process.cwd(), 'node_modules', ...packageName.split('/'), 'package.json')) | |
| const missingPackages = requiredPackages.filter((packageName) => !packageExists(packageName)) | |
| console.log( | |
| JSON.stringify( | |
| { | |
| key, | |
| platformName, | |
| archName, | |
| requiredPackages, | |
| missingPackages, | |
| }, | |
| null, | |
| 2 | |
| ) | |
| ) | |
| if (missingPackages.length > 0) { | |
| const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' | |
| const install = childProcess.spawnSync( | |
| npmCommand, | |
| ['install', '--no-save', '--package-lock=false', '--no-audit', '--no-fund', ...missingPackages], | |
| { | |
| stdio: 'inherit', | |
| cwd: process.cwd(), | |
| env: process.env, | |
| } | |
| ) | |
| if (install.status !== 0) { | |
| throw new Error(`Failed to install missing frontend native packages for ${key}`) | |
| } | |
| } | |
| for (const packageName of requiredPackages) { | |
| if (!packageExists(packageName)) { | |
| throw new Error(`Required frontend native package is still missing after repair: ${packageName}`) | |
| } | |
| } | |
| '@ | Set-Content -Path .github/workflows/.tmp/ensure-platform-frontend-packages.cjs -Encoding utf8 | |
| @' | |
| const electronBuilder = require('electron-builder') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const platformName = process.env.NSMUSICS_ELECTRON_PLATFORM | |
| const archName = process.env.NSMUSICS_ELECTRON_ARCH | |
| const mode = process.argv[2] || 'package' | |
| const targetOverride = (process.env.NSMUSICS_ELECTRON_TARGETS || '') | |
| .split(',') | |
| .map((value) => value.trim()) | |
| .filter(Boolean) | |
| const buildWindowsMpvFilters = () => [ | |
| '**/*', | |
| '!7z{,/**}', | |
| '!doc{,/**}', | |
| '!installer{,/**}', | |
| '!**/*.7z', | |
| '!**/*.zip', | |
| '!**/*.pdf', | |
| '!**/*.ignore', | |
| '!**/chocolatey*', | |
| '!**/updater.bat', | |
| '!**/settings.xml', | |
| ] | |
| const resolveWindowsMpvResourceDirectory = () => { | |
| const archAliases = { | |
| x64: ['x86_64'], | |
| ia32: ['i686'], | |
| arm64: ['aarch64', 'arm64'], | |
| } | |
| const aliases = archAliases[archName] || [archName] | |
| const resourcesRoot = path.join(process.cwd(), 'resources') | |
| const candidates = fs | |
| .readdirSync(resourcesRoot, { withFileTypes: true }) | |
| .filter((entry) => entry.isDirectory() && /^mpv[-_]/i.test(entry.name)) | |
| .map((entry) => entry.name) | |
| .filter((name) => aliases.some((alias) => name.toLowerCase().includes(alias))) | |
| .sort((left, right) => right.localeCompare(left)) | |
| if (candidates.length === 0) { | |
| throw new Error(`Missing Windows mpv runtime directory for arch ${archName} under ${resourcesRoot}`) | |
| } | |
| return candidates[0] | |
| } | |
| const electronLanguageWhitelist = [ | |
| 'zh-CN', | |
| 'zh-TW', | |
| 'en-US', | |
| 'es', | |
| 'fa', | |
| 'fr', | |
| 'ja', | |
| 'pl', | |
| 'de', | |
| 'it', | |
| 'ru', | |
| 'pt-BR', | |
| 'sr', | |
| 'sv', | |
| 'cs', | |
| 'nl', | |
| ] | |
| const buildExtraResources = (platformName) => { | |
| const resources = [ | |
| { from: './resources/better_sqlite3.node', to: 'better_sqlite3.node' }, | |
| { from: './resources/navidrome.db', to: 'navidrome.db' }, | |
| { from: './resources/nsmusics.db', to: 'nsmusics.db' }, | |
| { from: './resources/icons', to: 'icons' }, | |
| { from: './resources/config/NSMusicS.ico', to: 'config/NSMusicS.ico' }, | |
| { from: './resources/config/png/256x256.png', to: 'config/png/256x256.png' }, | |
| ] | |
| if (platformName === 'win') { | |
| const mpvDir = resolveWindowsMpvResourceDirectory() | |
| resources.push({ | |
| from: `./resources/${mpvDir}`, | |
| to: mpvDir, | |
| filter: buildWindowsMpvFilters(), | |
| }) | |
| } | |
| if (platformName === 'mac') { | |
| resources.push({ | |
| from: './resources/mpv-0.39.0', | |
| to: 'mpv-0.39.0', | |
| }) | |
| } | |
| return resources | |
| } | |
| const archMap = { | |
| x64: electronBuilder.Arch.x64, | |
| ia32: electronBuilder.Arch.ia32, | |
| x86: electronBuilder.Arch.ia32, | |
| arm64: electronBuilder.Arch.arm64, | |
| } | |
| const platformMap = { | |
| win: electronBuilder.Platform.WINDOWS, | |
| linux: electronBuilder.Platform.LINUX, | |
| mac: electronBuilder.Platform.MAC, | |
| } | |
| const targetMap = { | |
| dir: ['dir'], | |
| win: ['nsis', 'zip'], | |
| linux: ['AppImage', 'deb', 'rpm', 'tar.gz'], | |
| mac: ['dmg', 'zip'], | |
| } | |
| const builderArch = archMap[archName] | |
| const builderPlatform = platformMap[platformName] | |
| if (builderArch === undefined) { | |
| throw new Error(`Unsupported NSMUSICS_ELECTRON_ARCH: ${archName}`) | |
| } | |
| if (!builderPlatform) { | |
| throw new Error(`Unsupported NSMUSICS_ELECTRON_PLATFORM: ${platformName}`) | |
| } | |
| const requestedTargets = | |
| mode === 'dir' | |
| ? targetMap.dir | |
| : targetOverride.length > 0 | |
| ? targetOverride | |
| : targetMap[platformName] | |
| const targets = builderPlatform.createTarget(requestedTargets, builderArch) | |
| const diagnosticsDir = path.join(process.cwd(), '.github', 'workflows', '.tmp', 'diagnostics') | |
| const safeTargetKey = requestedTargets.join('_').replace(/[^a-zA-Z0-9._-]+/g, '_') || 'default' | |
| const buildReportPath = path.join( | |
| diagnosticsDir, | |
| `electron-builder-${platformName}-${archName}-${mode}-${safeTargetKey}.json` | |
| ) | |
| const baseConfig = { | |
| appId: 'github.com.nsmusics.xiang.cheng', | |
| productName: 'NSMusicS', | |
| electronLanguages: electronLanguageWhitelist, | |
| directories: { | |
| output: path.join(process.cwd(), 'release'), | |
| app: path.join(process.cwd(), 'dist'), | |
| }, | |
| asar: true, | |
| extraResources: buildExtraResources(platformName), | |
| } | |
| const builderConfig = { | |
| ...baseConfig, | |
| win: { | |
| icon: 'resources/config/NSMusicS.ico', | |
| ...(mode === 'dir' | |
| ? {} | |
| : { | |
| target: ['nsis', 'zip'], | |
| artifactName: '${productName}-Win-${version}-${arch}.${ext}', | |
| }), | |
| }, | |
| linux: { | |
| icon: 'resources/config/png', | |
| desktop: { | |
| Icon: '/usr/share/icons/hicolor/512x512/apps/nsmusics.png', | |
| }, | |
| category: 'Audio', | |
| maintainer: 'Xiang Cheng 1774148579@qq.com', | |
| ...(mode === 'dir' | |
| ? {} | |
| : { | |
| target: ['AppImage', 'deb', 'rpm', 'tar.gz'], | |
| artifactName: '${productName}-Linux-${version}-${arch}.${ext}', | |
| }), | |
| }, | |
| mac: { | |
| icon: 'resources/config/NSMusicS.icns', | |
| hardenedRuntime: true, | |
| gatekeeperAssess: false, | |
| entitlements: 'build/entitlements.mac.plist', | |
| entitlementsInherit: 'build/entitlements.mac.plist', | |
| identity: null, | |
| ...(mode === 'dir' | |
| ? {} | |
| : { | |
| target: ['dmg', 'zip'], | |
| artifactName: '${productName}-Mac-${version}-${arch}.${ext}', | |
| }), | |
| }, | |
| ...(mode === 'dir' | |
| ? {} | |
| : { | |
| deb: { | |
| depends: ['mpv'], | |
| }, | |
| rpm: { | |
| depends: ['mpv'], | |
| }, | |
| nsis: { | |
| oneClick: false, | |
| perMachine: true, | |
| allowElevation: true, | |
| allowToChangeInstallationDirectory: true, | |
| installerIcon: 'resources/config/NSMusicS.ico', | |
| uninstallerIcon: 'resources/config/NSMusicS.ico', | |
| installerHeaderIcon: 'resources/config/NSMusicS.ico', | |
| createDesktopShortcut: true, | |
| createStartMenuShortcut: true, | |
| shortcutName: 'NSMusicS', | |
| }, | |
| }), | |
| } | |
| const walkFiles = (dirPath) => { | |
| if (!fs.existsSync(dirPath)) { | |
| return [] | |
| } | |
| return fs.readdirSync(dirPath, { withFileTypes: true }).flatMap((entry) => { | |
| const fullPath = path.join(dirPath, entry.name) | |
| return entry.isDirectory() ? walkFiles(fullPath) : [fullPath] | |
| }) | |
| } | |
| const validateReleaseInventory = (releaseFiles) => { | |
| const relativeFiles = releaseFiles.map((targetPath) => | |
| path.relative(process.cwd(), targetPath).replace(/\\/g, '/') | |
| ) | |
| const windowsMpvResourcePattern = /\/resources\/mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\//i | |
| const isForbiddenReleaseFile = (relativePath) => { | |
| if ( | |
| relativePath.includes('/resources/node/linux/') || | |
| relativePath.includes('/resources/node/macos/') || | |
| relativePath.includes('/resources/node/win/') | |
| ) { | |
| return true | |
| } | |
| if (platformName !== 'win' && windowsMpvResourcePattern.test(relativePath)) { | |
| return true | |
| } | |
| if (platformName !== 'mac' && relativePath.includes('/resources/mpv-0.39.0/')) { | |
| return true | |
| } | |
| if (!windowsMpvResourcePattern.test(relativePath)) { | |
| return false | |
| } | |
| return ( | |
| /\/resources\/mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/(7z|doc|installer)\//i.test( | |
| relativePath | |
| ) || | |
| /\/resources\/mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/.*\.(7z|pdf|ignore)$/i.test( | |
| relativePath | |
| ) || | |
| /\/resources\/mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/chocolatey/i.test( | |
| relativePath | |
| ) || | |
| /\/resources\/mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/(updater\.bat|settings\.xml)$/i.test( | |
| relativePath | |
| ) | |
| ) | |
| } | |
| const forbiddenFiles = relativeFiles.filter(isForbiddenReleaseFile) | |
| if (forbiddenFiles.length > 0) { | |
| throw new Error( | |
| `Packaged release contains forbidden resources for ${platformName}-${archName}: ${forbiddenFiles.join( | |
| ', ' | |
| )}` | |
| ) | |
| } | |
| } | |
| const expectedArtifactMatchers = { | |
| nsis: (value) => value.endsWith('.exe') && !value.endsWith('.exe.blockmap'), | |
| zip: (value) => value.endsWith('.zip'), | |
| AppImage: (value) => value.endsWith('.AppImage'), | |
| deb: (value) => value.endsWith('.deb'), | |
| rpm: (value) => value.endsWith('.rpm'), | |
| 'tar.gz': (value) => value.endsWith('.tar.gz'), | |
| dmg: (value) => value.endsWith('.dmg') && !value.endsWith('.dmg.blockmap'), | |
| } | |
| console.log( | |
| `[ci-electron-builder] mode=${mode} platform=${platformName} arch=${archName} targets=${requestedTargets.join(',')}` | |
| ) | |
| console.log( | |
| JSON.stringify( | |
| { | |
| mode, | |
| platformName, | |
| archName, | |
| targetOverride, | |
| configuredTargets: { | |
| win: builderConfig.win.target || null, | |
| linux: builderConfig.linux.target || null, | |
| mac: builderConfig.mac.target || null, | |
| }, | |
| }, | |
| null, | |
| 2 | |
| ) | |
| ) | |
| async function main() { | |
| fs.mkdirSync(diagnosticsDir, { recursive: true }) | |
| const artifactPaths = (await electronBuilder.build({ | |
| publish: 'never', | |
| targets, | |
| config: builderConfig, | |
| })).map((targetPath) => path.resolve(targetPath)) | |
| const releaseDir = path.join(process.cwd(), 'release') | |
| const releaseFiles = walkFiles(releaseDir).map((targetPath) => path.resolve(targetPath)) | |
| validateReleaseInventory(releaseFiles) | |
| const missingRequestedTargets = | |
| mode === 'dir' | |
| ? [] | |
| : requestedTargets.filter((target) => { | |
| const matcher = expectedArtifactMatchers[target] | |
| if (!matcher) { | |
| throw new Error(`No expected artifact matcher configured for target: ${target}`) | |
| } | |
| return !artifactPaths.some((targetPath) => matcher(path.basename(targetPath))) | |
| }) | |
| const buildReport = { | |
| mode, | |
| platformName, | |
| archName, | |
| targetOverride, | |
| requestedTargets, | |
| configuredTargets: { | |
| win: builderConfig.win.target || null, | |
| linux: builderConfig.linux.target || null, | |
| mac: builderConfig.mac.target || null, | |
| }, | |
| artifactPaths, | |
| releaseFiles, | |
| missingRequestedTargets, | |
| } | |
| fs.writeFileSync(buildReportPath, JSON.stringify(buildReport, null, 2) + '\n') | |
| console.log(`[ci-electron-builder] wrote build report: ${buildReportPath}`) | |
| console.log( | |
| JSON.stringify( | |
| { | |
| artifactPaths, | |
| releaseFiles, | |
| missingRequestedTargets, | |
| }, | |
| null, | |
| 2 | |
| ) | |
| ) | |
| if (missingRequestedTargets.length > 0) { | |
| throw new Error( | |
| `electron-builder completed without requested target artifacts for ${missingRequestedTargets.join( | |
| ', ' | |
| )}. Returned artifacts: ${artifactPaths.join(', ') || '(none)'}. Release files: ${ | |
| releaseFiles.join(', ') || '(none)' | |
| }` | |
| ) | |
| } | |
| } | |
| main().catch((error) => { | |
| const errorText = (error && (error.stack || error.message)) || String(error) | |
| const commandSafe = errorText | |
| .replace(/%/g, '%25') | |
| .replace(/\r/g, '%0D') | |
| .replace(/\n/g, '%0A') | |
| console.error( | |
| `::error title=Electron Builder ${platformName}-${archName} ${mode}::${commandSafe}` | |
| ) | |
| console.error(error) | |
| process.exit(1) | |
| }) | |
| '@ | Set-Content -Path .github/workflows/.tmp/run-electron-builder.cjs -Encoding utf8 | |
| @' | |
| const childProcess = require('node:child_process') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const productName = 'NSMusicS' | |
| const rootDir = process.cwd() | |
| const releaseDir = path.join(rootDir, 'release') | |
| const packageJsonPath = path.join(rootDir, 'package.json') | |
| const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) | |
| const version = packageJson.version | |
| const platformName = process.env.NSMUSICS_ELECTRON_PLATFORM | |
| const platformLabel = process.env.NSMUSICS_PLATFORM_NAME || platformName | |
| const archName = process.env.NSMUSICS_ELECTRON_ARCH | |
| const buildMode = process.env.NSMUSICS_BUILD_MODE || 'supported-only' | |
| const artifactPrefix = process.env.NSMUSICS_ARTIFACT_PREFIX || 'nsmusics-electron' | |
| const supported = process.env.NSMUSICS_TARGET_SUPPORTED || '' | |
| const supportReason = process.env.NSMUSICS_SUPPORT_REASON || '' | |
| const bundleRoot = path.join( | |
| rootDir, | |
| '.github', | |
| 'workflows', | |
| '.out', | |
| buildMode, | |
| `${platformLabel}-${archName}` | |
| ) | |
| const publishDir = path.join(bundleRoot, 'publish') | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const megabyte = 1024 * 1024 | |
| const appendSummary = (message) => { | |
| if (!summaryPath) { | |
| return | |
| } | |
| fs.appendFileSync(summaryPath, `${message}\n`) | |
| } | |
| const fail = (message) => { | |
| appendSummary(`- FAIL ${message}`) | |
| console.error(`::error title=Publish Asset Staging::${message.replace(/\r?\n/g, ' ')}`) | |
| throw new Error(message) | |
| } | |
| const runCommand = (command, args) => { | |
| const result = childProcess.spawnSync(command, args, { | |
| cwd: rootDir, | |
| encoding: 'utf8', | |
| }) | |
| if (result.status !== 0) { | |
| fail( | |
| `Command failed: ${command} ${args.join(' ')}\n${(result.stderr || result.stdout || '').trim()}` | |
| ) | |
| } | |
| return result.stdout || '' | |
| } | |
| const walkFiles = (dirPath) => { | |
| if (!fs.existsSync(dirPath)) { | |
| fail(`release output directory not found: ${dirPath}`) | |
| } | |
| return fs.readdirSync(dirPath, { withFileTypes: true }).flatMap((entry) => { | |
| const fullPath = path.join(dirPath, entry.name) | |
| return entry.isDirectory() ? walkFiles(fullPath) : [fullPath] | |
| }) | |
| } | |
| const relativeToRoot = (targetPath) => path.relative(rootDir, targetPath).replace(/\\/g, '/') | |
| const relativeToBundle = (targetPath) => path.relative(bundleRoot, targetPath).replace(/\\/g, '/') | |
| const fileBasename = (targetPath) => path.basename(targetPath) | |
| // Reference bytes are anchored to the published NSMusicS-v2.3.0 release assets. | |
| // The hard floors are intentionally looser than the warning band so successful | |
| // package-size reductions warn for review instead of failing the whole release. | |
| const expectedSizeProfiles = { | |
| win: { | |
| x64: { | |
| exe: { | |
| referenceBytes: 163438490, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.2, | |
| failBelowFactor: 0.73, | |
| failAboveFactor: 1.35, | |
| }, | |
| exe_blockmap: { | |
| referenceBytes: 171899, | |
| warnBelowFactor: 0.6, | |
| warnAboveFactor: 1.7, | |
| failBelowFactor: 0.3, | |
| failAboveFactor: 3.0, | |
| }, | |
| zip: { | |
| referenceBytes: 216856608, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.72, | |
| failAboveFactor: 1.35, | |
| }, | |
| }, | |
| ia32: { | |
| exe: { | |
| referenceBytes: 163438490, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.68, | |
| failAboveFactor: 1.35, | |
| }, | |
| exe_blockmap: { | |
| referenceBytes: 171899, | |
| warnBelowFactor: 0.5, | |
| warnAboveFactor: 1.7, | |
| failBelowFactor: 0.25, | |
| failAboveFactor: 3.0, | |
| }, | |
| zip: { | |
| referenceBytes: 216856608, | |
| warnBelowFactor: 0.72, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.66, | |
| failAboveFactor: 1.35, | |
| }, | |
| }, | |
| arm64: { | |
| exe: { | |
| referenceBytes: 163438490, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.68, | |
| failAboveFactor: 1.35, | |
| }, | |
| exe_blockmap: { | |
| referenceBytes: 171899, | |
| warnBelowFactor: 0.5, | |
| warnAboveFactor: 1.7, | |
| failBelowFactor: 0.25, | |
| failAboveFactor: 3.0, | |
| }, | |
| zip: { | |
| referenceBytes: 216856608, | |
| warnBelowFactor: 0.72, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.66, | |
| failAboveFactor: 1.35, | |
| }, | |
| }, | |
| }, | |
| linux: { | |
| x64: { | |
| deb: { | |
| referenceBytes: 104383034, | |
| warnBelowFactor: 0.8, | |
| warnAboveFactor: 1.18, | |
| failBelowFactor: 0.72, | |
| failAboveFactor: 1.35, | |
| }, | |
| rpm: { | |
| referenceBytes: 104383034, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.2, | |
| failBelowFactor: 0.7, | |
| failAboveFactor: 1.35, | |
| }, | |
| targz: { | |
| referenceBytes: 161745939, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.67, | |
| failAboveFactor: 1.3, | |
| }, | |
| appimage: { | |
| referenceBytes: 162956976, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.67, | |
| failAboveFactor: 1.3, | |
| }, | |
| }, | |
| arm64: { | |
| deb: { | |
| referenceBytes: 100 * megabyte, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.35, | |
| failBelowFactor: 0.6, | |
| failAboveFactor: 1.7, | |
| }, | |
| rpm: { | |
| referenceBytes: 100 * megabyte, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.35, | |
| failBelowFactor: 0.6, | |
| failAboveFactor: 1.7, | |
| }, | |
| targz: { | |
| referenceBytes: 154 * megabyte, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.3, | |
| failBelowFactor: 0.65, | |
| failAboveFactor: 1.6, | |
| }, | |
| appimage: { | |
| referenceBytes: 155 * megabyte, | |
| warnBelowFactor: 0.75, | |
| warnAboveFactor: 1.3, | |
| failBelowFactor: 0.65, | |
| failAboveFactor: 1.6, | |
| }, | |
| }, | |
| }, | |
| mac: { | |
| x64: { | |
| dmg: { | |
| referenceBytes: 205706082, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.65, | |
| failAboveFactor: 1.25, | |
| }, | |
| dmg_blockmap: { | |
| referenceBytes: 214898, | |
| warnBelowFactor: 0.6, | |
| warnAboveFactor: 1.7, | |
| failBelowFactor: 0.3, | |
| failAboveFactor: 3.0, | |
| }, | |
| zip: { | |
| referenceBytes: 205445399, | |
| warnBelowFactor: 0.78, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.65, | |
| failAboveFactor: 1.25, | |
| }, | |
| }, | |
| arm64: { | |
| dmg: { | |
| referenceBytes: 176798083, | |
| warnBelowFactor: 0.82, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.74, | |
| failAboveFactor: 1.25, | |
| }, | |
| dmg_blockmap: { | |
| referenceBytes: 182756, | |
| warnBelowFactor: 0.6, | |
| warnAboveFactor: 1.7, | |
| failBelowFactor: 0.3, | |
| failAboveFactor: 3.0, | |
| }, | |
| zip: { | |
| referenceBytes: 176469444, | |
| warnBelowFactor: 0.82, | |
| warnAboveFactor: 1.15, | |
| failBelowFactor: 0.74, | |
| failAboveFactor: 1.25, | |
| }, | |
| }, | |
| }, | |
| } | |
| const resolveSizeProfile = (platformName, archName, kind) => { | |
| const platformProfiles = expectedSizeProfiles[platformName] | |
| if (!platformProfiles) { | |
| throw new Error(`Missing size profile platform: ${platformName}`) | |
| } | |
| const archProfiles = platformProfiles[archName] | |
| if (!archProfiles) { | |
| throw new Error(`Missing size profile arch: ${platformName}-${archName}`) | |
| } | |
| const profile = archProfiles[kind] | |
| if (!profile) { | |
| throw new Error(`Missing size profile kind: ${platformName}-${archName}-${kind}`) | |
| } | |
| return profile | |
| } | |
| const roundByte = (value) => Math.round(value) | |
| const baseSpecs = { | |
| win: { | |
| packageLabel: 'Win', | |
| assets: [ | |
| { | |
| kind: 'exe', | |
| matches: (value) => value.endsWith('.exe') && !value.endsWith('.exe.blockmap'), | |
| outputName: () => `${productName}-Win-${version}-${archName}.exe`, | |
| }, | |
| { | |
| kind: 'exe_blockmap', | |
| matches: (value) => value.endsWith('.exe.blockmap'), | |
| outputName: () => `${productName}-Win-${version}-${archName}.exe.blockmap`, | |
| }, | |
| { | |
| kind: 'zip', | |
| matches: (value) => value.endsWith('.zip'), | |
| outputName: () => `${productName}-Win-${version}-${archName}.zip`, | |
| }, | |
| ], | |
| }, | |
| linux: { | |
| packageLabel: 'Linux', | |
| assets: [ | |
| { | |
| kind: 'deb', | |
| matches: (value) => value.endsWith('.deb'), | |
| outputName: () => | |
| `${productName}-Linux-${version}-${archName === 'x64' ? 'amd64' : archName}.deb`, | |
| }, | |
| { | |
| kind: 'rpm', | |
| matches: (value) => value.endsWith('.rpm'), | |
| outputName: () => | |
| `${productName}-Linux-${version}-${archName === 'x64' ? 'x86_64' : archName}.rpm`, | |
| }, | |
| { | |
| kind: 'targz', | |
| matches: (value) => value.endsWith('.tar.gz'), | |
| outputName: () => `${productName}-Linux-${version}-${archName}.tar.gz`, | |
| }, | |
| { | |
| kind: 'appimage', | |
| matches: (value) => value.endsWith('.AppImage'), | |
| outputName: () => | |
| `${productName}-Linux-${version}-${archName === 'x64' ? 'x86_64' : archName}.AppImage`, | |
| }, | |
| ], | |
| }, | |
| mac: { | |
| packageLabel: 'Mac', | |
| assets: [ | |
| { | |
| kind: 'dmg', | |
| matches: (value) => value.endsWith('.dmg') && !value.endsWith('.dmg.blockmap'), | |
| outputName: () => `${productName}-Mac-${version}-${archName}.dmg`, | |
| }, | |
| { | |
| kind: 'dmg_blockmap', | |
| matches: (value) => value.endsWith('.dmg.blockmap'), | |
| outputName: () => `${productName}-Mac-${version}-${archName}.dmg.blockmap`, | |
| }, | |
| { | |
| kind: 'zip', | |
| matches: (value) => value.endsWith('.zip'), | |
| outputName: () => `${productName}-Mac-${version}-${archName}.zip`, | |
| }, | |
| ], | |
| }, | |
| } | |
| const validateLinuxDebDepends = (debPath) => { | |
| if (platformName !== 'linux') { | |
| return | |
| } | |
| const dependsLine = runCommand('dpkg-deb', ['-f', debPath, 'Depends']).trim() | |
| if (!/\bmpv\b/.test(dependsLine)) { | |
| fail(`Linux deb package does not declare required mpv dependency: ${relativeToRoot(debPath)}`) | |
| } | |
| appendSummary(`- PASS Linux deb dependency metadata includes mpv: ${path.basename(debPath)}`) | |
| } | |
| const validateLinuxRpmDepends = (rpmPath) => { | |
| if (platformName !== 'linux') { | |
| return | |
| } | |
| const dependsOutput = runCommand('rpm', ['-qpR', rpmPath]).trim() | |
| if (!/(^|\r?\n)mpv(?:\s|[<>=()]|$)/m.test(dependsOutput)) { | |
| fail(`Linux rpm package does not declare required mpv dependency: ${relativeToRoot(rpmPath)}`) | |
| } | |
| appendSummary(`- PASS Linux rpm dependency metadata includes mpv: ${path.basename(rpmPath)}`) | |
| } | |
| const spec = baseSpecs[platformName] | |
| if (!spec) { | |
| fail(`Unsupported publish asset staging platform label: ${platformLabel}`) | |
| } | |
| const releaseFiles = walkFiles(releaseDir) | |
| if (releaseFiles.length === 0) { | |
| fail(`release output is empty under ${releaseDir}`) | |
| } | |
| const rootFiles = releaseFiles.filter((targetPath) => path.dirname(targetPath) === releaseDir) | |
| const rootEntries = fs | |
| .readdirSync(releaseDir, { withFileTypes: true }) | |
| .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name)) | |
| .sort() | |
| const packageLikeFiles = releaseFiles | |
| .filter((targetPath) => | |
| /(\.AppImage|\.deb|\.rpm|\.tar\.gz|\.zip|\.dmg|\.dmg\.blockmap|\.exe|\.exe\.blockmap)$/i.test( | |
| fileBasename(targetPath) | |
| ) | |
| ) | |
| .map(relativeToRoot) | |
| .sort() | |
| fs.rmSync(bundleRoot, { recursive: true, force: true }) | |
| fs.mkdirSync(publishDir, { recursive: true }) | |
| const stagedAssets = [] | |
| for (const assetSpec of spec.assets) { | |
| let matches = rootFiles.filter((targetPath) => assetSpec.matches(fileBasename(targetPath))) | |
| if (matches.length === 0) { | |
| matches = releaseFiles.filter((targetPath) => assetSpec.matches(fileBasename(targetPath))) | |
| } | |
| if (matches.length !== 1) { | |
| fail( | |
| `Expected exactly one ${platformLabel}-${archName} ${assetSpec.kind} asset, found ${matches.length}. Candidates: ${matches | |
| .map((targetPath) => relativeToRoot(targetPath)) | |
| .join(', ')}. Root entries: ${rootEntries.join(', ')}. Package-like files: ${packageLikeFiles.join(', ')}` | |
| ) | |
| } | |
| const sourcePath = matches[0] | |
| const outputName = assetSpec.outputName() | |
| const destinationPath = path.join(publishDir, outputName) | |
| const sizeBytes = fs.statSync(sourcePath).size | |
| const sizeProfile = resolveSizeProfile(platformName, archName, assetSpec.kind) | |
| const warnMinBytes = roundByte(sizeProfile.referenceBytes * sizeProfile.warnBelowFactor) | |
| const warnMaxBytes = roundByte(sizeProfile.referenceBytes * sizeProfile.warnAboveFactor) | |
| const failMinBytes = roundByte(sizeProfile.referenceBytes * sizeProfile.failBelowFactor) | |
| const failMaxBytes = roundByte(sizeProfile.referenceBytes * sizeProfile.failAboveFactor) | |
| if (sizeBytes <= 0) { | |
| fail(`Publish asset is empty: ${relativeToRoot(sourcePath)}`) | |
| } | |
| if (sizeBytes < failMinBytes) { | |
| fail( | |
| `Publish asset ${relativeToRoot(sourcePath)} is ${sizeBytes} bytes, which is below the ${failMinBytes} byte hard minimum for ${platformLabel}-${archName} ${assetSpec.kind}. Reference=${sizeProfile.referenceBytes}.` | |
| ) | |
| } | |
| if (sizeBytes > failMaxBytes) { | |
| fail( | |
| `Publish asset ${relativeToRoot(sourcePath)} is ${sizeBytes} bytes, which exceeds the ${failMaxBytes} byte hard maximum for ${platformLabel}-${archName} ${assetSpec.kind}. Reference=${sizeProfile.referenceBytes}.` | |
| ) | |
| } | |
| if (sizeBytes < warnMinBytes || sizeBytes > warnMaxBytes) { | |
| appendSummary( | |
| `- WARN Asset size drift: ${outputName} is ${sizeBytes} bytes, reference ${sizeProfile.referenceBytes}, warn range ${warnMinBytes}-${warnMaxBytes}.` | |
| ) | |
| } | |
| if (assetSpec.kind === 'deb') { | |
| validateLinuxDebDepends(sourcePath) | |
| } | |
| if (assetSpec.kind === 'rpm') { | |
| validateLinuxRpmDepends(sourcePath) | |
| } | |
| if (path.dirname(sourcePath) !== releaseDir) { | |
| appendSummary( | |
| `- NOTE Packaged asset discovered below release root: ${relativeToRoot(sourcePath)}` | |
| ) | |
| } | |
| fs.copyFileSync(sourcePath, destinationPath) | |
| stagedAssets.push({ | |
| kind: assetSpec.kind, | |
| source_name: fileBasename(sourcePath), | |
| file_name: outputName, | |
| size_bytes: sizeBytes, | |
| reference_bytes: sizeProfile.referenceBytes, | |
| warn_min_bytes: warnMinBytes, | |
| warn_max_bytes: warnMaxBytes, | |
| fail_min_bytes: failMinBytes, | |
| fail_max_bytes: failMaxBytes, | |
| relative_path: relativeToBundle(destinationPath), | |
| }) | |
| } | |
| const manifest = { | |
| artifact_name: `${artifactPrefix}-${buildMode}-${platformLabel}-${archName}`, | |
| build_mode: buildMode, | |
| version, | |
| platform: platformLabel, | |
| platform_id: platformName, | |
| arch: archName, | |
| supported, | |
| support_reason: supportReason, | |
| ref: process.env.GITHUB_REF || '', | |
| sha: process.env.GITHUB_SHA || '', | |
| run_id: process.env.GITHUB_RUN_ID || '', | |
| release_files: releaseFiles.map(relativeToRoot).sort(), | |
| publish_assets: stagedAssets.map((asset) => asset.relative_path), | |
| publish_asset_details: stagedAssets, | |
| } | |
| const inventoryPath = path.join(bundleRoot, 'publish-assets.txt') | |
| const manifestPath = path.join(bundleRoot, 'build-manifest.json') | |
| fs.writeFileSync( | |
| inventoryPath, | |
| stagedAssets | |
| .map((asset) => `${asset.file_name}\t${asset.size_bytes}\t${asset.kind}`) | |
| .join('\n') + '\n' | |
| ) | |
| fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n') | |
| appendSummary(`- Upload artifact bundle: ${manifest.artifact_name}`) | |
| appendSummary(`- Publishable assets: ${stagedAssets.length}`) | |
| for (const asset of stagedAssets) { | |
| appendSummary( | |
| `- Asset: ${asset.file_name} (${asset.size_bytes} bytes, reference ${asset.reference_bytes}, warn ${asset.warn_min_bytes}-${asset.warn_max_bytes}, fail ${asset.fail_min_bytes}-${asset.fail_max_bytes})` | |
| ) | |
| } | |
| '@ | Set-Content -Path .github/workflows/.tmp/stage-publish-assets.cjs -Encoding utf8 | |
| @' | |
| const childProcess = require('node:child_process') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const mode = process.argv[2] || 'packaged' | |
| const platformName = process.env.NSMUSICS_ELECTRON_PLATFORM | |
| const archName = process.env.NSMUSICS_ELECTRON_ARCH | |
| const productName = 'NSMusicS' | |
| const rootDir = process.cwd() | |
| const releaseDir = path.join(rootDir, 'release') | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const fail = (message) => { | |
| appendSummary(`- FAIL ${message}`) | |
| console.error(`::error title=Native Validation::${message.replace(/\r?\n/g, ' ')}`) | |
| console.error(`[native-validate] ${message}`) | |
| process.exit(1) | |
| } | |
| const note = (message) => { | |
| console.log(`[native-validate] ${message}`) | |
| } | |
| const appendSummary = (message) => { | |
| if (!summaryPath) { | |
| return | |
| } | |
| fs.appendFileSync(summaryPath, `${message}\n`) | |
| } | |
| const ensureExists = (targetPath, description) => { | |
| if (!fs.existsSync(targetPath)) { | |
| fail(`${description} not found: ${targetPath}`) | |
| } | |
| return targetPath | |
| } | |
| const resolveCommandPath = (commandName) => { | |
| const probe = childProcess.spawnSync('bash', ['-lc', `command -v ${commandName}`], { | |
| encoding: 'utf8', | |
| }) | |
| if (probe.status !== 0) { | |
| fail( | |
| `Unable to resolve required command from PATH: ${commandName}\n${(probe.stderr || '').trim()}` | |
| ) | |
| } | |
| const resolved = (probe.stdout || '') | |
| .split(/\r?\n/) | |
| .map((value) => value.trim()) | |
| .find(Boolean) | |
| if (!resolved) { | |
| fail(`command -v returned no path for required command: ${commandName}`) | |
| } | |
| return fs.realpathSync(resolved) | |
| } | |
| const dedupe = (values) => [...new Set(values)] | |
| const cpuTypeToArch = (cpuType) => { | |
| switch (cpuType >>> 0) { | |
| case 0x00000007: | |
| return 'ia32' | |
| case 0x01000007: | |
| return 'x64' | |
| case 0x0100000c: | |
| return 'arm64' | |
| default: | |
| return `unknown-0x${(cpuType >>> 0).toString(16)}` | |
| } | |
| } | |
| const machineToArch = (machine) => { | |
| switch (machine) { | |
| case 0x014c: | |
| return 'ia32' | |
| case 0x8664: | |
| return 'x64' | |
| case 0xaa64: | |
| return 'arm64' | |
| case 0x0003: | |
| return 'ia32' | |
| case 0x003e: | |
| return 'x64' | |
| case 0x00b7: | |
| return 'arm64' | |
| default: | |
| return `unknown-0x${machine.toString(16)}` | |
| } | |
| } | |
| const detectBinaryInfo = (targetPath) => { | |
| const buffer = fs.readFileSync(targetPath) | |
| if (buffer.length < 64) { | |
| fail(`Binary too small to inspect: ${targetPath}`) | |
| } | |
| if (buffer[0] === 0x4d && buffer[1] === 0x5a) { | |
| const peOffset = buffer.readUInt32LE(0x3c) | |
| if (peOffset + 6 > buffer.length) { | |
| fail(`Invalid PE header offset for ${targetPath}`) | |
| } | |
| if (buffer.readUInt32LE(peOffset) !== 0x00004550) { | |
| fail(`Invalid PE signature for ${targetPath}`) | |
| } | |
| const machine = buffer.readUInt16LE(peOffset + 4) | |
| return { format: 'pe', arches: [machineToArch(machine)] } | |
| } | |
| if ( | |
| buffer[0] === 0x7f && | |
| buffer[1] === 0x45 && | |
| buffer[2] === 0x4c && | |
| buffer[3] === 0x46 | |
| ) { | |
| const machine = buffer.readUInt16LE(18) | |
| return { format: 'elf', arches: [machineToArch(machine)] } | |
| } | |
| const magicBE = buffer.readUInt32BE(0) | |
| if (magicBE === 0xcefaedfe || magicBE === 0xcffaedfe) { | |
| return { format: 'mach-o', arches: [cpuTypeToArch(buffer.readUInt32LE(4))] } | |
| } | |
| if (magicBE === 0xfeedface || magicBE === 0xfeedfacf) { | |
| return { format: 'mach-o', arches: [cpuTypeToArch(buffer.readUInt32BE(4))] } | |
| } | |
| if (magicBE === 0xcafebabe) { | |
| const count = buffer.readUInt32BE(4) | |
| const arches = [] | |
| for (let index = 0; index < count; index += 1) { | |
| const offset = 8 + index * 20 | |
| if (offset + 20 > buffer.length) { | |
| fail(`Invalid fat Mach-O header for ${targetPath}`) | |
| } | |
| arches.push(cpuTypeToArch(buffer.readUInt32BE(offset))) | |
| } | |
| return { format: 'mach-o-fat', arches: dedupe(arches) } | |
| } | |
| if (magicBE === 0xcafebabf) { | |
| const count = buffer.readUInt32BE(4) | |
| const arches = [] | |
| for (let index = 0; index < count; index += 1) { | |
| const offset = 8 + index * 32 | |
| if (offset + 32 > buffer.length) { | |
| fail(`Invalid fat64 Mach-O header for ${targetPath}`) | |
| } | |
| arches.push(cpuTypeToArch(buffer.readUInt32BE(offset))) | |
| } | |
| return { format: 'mach-o-fat64', arches: dedupe(arches) } | |
| } | |
| fail(`Unsupported binary format: ${targetPath}`) | |
| } | |
| const assertBinaryArch = (targetPath, expectedArch, description) => { | |
| const info = detectBinaryInfo(targetPath) | |
| if (!info.arches.includes(expectedArch)) { | |
| fail( | |
| `${description} architecture mismatch. expected=${expectedArch} actual=${info.arches.join(',')} format=${info.format} path=${targetPath}` | |
| ) | |
| } | |
| note(`${description}: ${info.format} ${info.arches.join(',')} ${targetPath}`) | |
| } | |
| const findDirectory = (baseDir, matcher) => { | |
| const entries = fs.readdirSync(baseDir, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| if (entry.isDirectory() && matcher.test(entry.name)) { | |
| return path.join(baseDir, entry.name) | |
| } | |
| } | |
| return null | |
| } | |
| const resolveUnpackedRoot = () => { | |
| ensureExists(releaseDir, 'release output directory') | |
| if (platformName === 'win') { | |
| const result = findDirectory(releaseDir, /^win.*unpacked$/i) | |
| if (result) return result | |
| } else if (platformName === 'linux') { | |
| const result = findDirectory(releaseDir, /^linux.*unpacked$/i) | |
| if (result) return result | |
| } else if (platformName === 'mac') { | |
| const result = findDirectory(releaseDir, /^mac($|-)/i) | |
| if (result) return result | |
| } | |
| fail(`Unable to resolve unpacked directory for platform=${platformName}`) | |
| } | |
| const resolveMacBundle = (unpackedRoot) => { | |
| const direct = path.join(unpackedRoot, `${productName}.app`) | |
| if (fs.existsSync(direct)) { | |
| return direct | |
| } | |
| const entries = fs.readdirSync(unpackedRoot, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| if (entry.isDirectory() && entry.name.endsWith('.app')) { | |
| return path.join(unpackedRoot, entry.name) | |
| } | |
| } | |
| fail(`Unable to locate packaged macOS app bundle under ${unpackedRoot}`) | |
| } | |
| const resolveLinuxExecutable = (unpackedRoot) => { | |
| const preferred = [ | |
| path.join(unpackedRoot, productName), | |
| path.join(unpackedRoot, productName.toLowerCase()), | |
| ] | |
| for (const candidate of preferred) { | |
| if (fs.existsSync(candidate)) { | |
| return candidate | |
| } | |
| } | |
| const entries = fs | |
| .readdirSync(unpackedRoot, { withFileTypes: true }) | |
| .filter((entry) => entry.isFile()) | |
| .map((entry) => path.join(unpackedRoot, entry.name)) | |
| .filter((candidate) => { | |
| const basename = path.basename(candidate) | |
| const extension = path.extname(candidate).toLowerCase() | |
| if ( | |
| [ | |
| '.pak', | |
| '.dll', | |
| '.so', | |
| '.dylib', | |
| '.json', | |
| '.yaml', | |
| '.yml', | |
| '.bin', | |
| '.dat', | |
| '.blockmap', | |
| ].includes(extension) | |
| ) { | |
| return false | |
| } | |
| if (basename === 'chrome-sandbox') { | |
| return false | |
| } | |
| return (fs.statSync(candidate).mode & 0o111) !== 0 | |
| }) | |
| if (entries.length === 1) { | |
| return entries[0] | |
| } | |
| fail(`Unable to uniquely identify packaged Linux executable under ${unpackedRoot}`) | |
| } | |
| const resolveWindowsMpvRuntimeDir = (baseDir) => { | |
| const archAliases = { | |
| x64: ['x86_64'], | |
| ia32: ['i686'], | |
| arm64: ['aarch64', 'arm64'], | |
| } | |
| const aliases = archAliases[archName] || [archName] | |
| const candidates = fs | |
| .readdirSync(baseDir, { withFileTypes: true }) | |
| .filter((entry) => entry.isDirectory() && /^mpv[-_]/i.test(entry.name)) | |
| .map((entry) => entry.name) | |
| .filter((name) => aliases.some((alias) => name.toLowerCase().includes(alias))) | |
| .sort((left, right) => right.localeCompare(left)) | |
| if (candidates.length === 0) { | |
| fail(`Unable to locate Windows mpv runtime directory for ${archName} under ${baseDir}`) | |
| } | |
| return path.join(baseDir, candidates[0]) | |
| } | |
| const validateWindowsMpv = (baseDir) => { | |
| const mpvRuntimeDir = resolveWindowsMpvRuntimeDir(baseDir) | |
| const mpvPath = ensureExists(path.join(mpvRuntimeDir, 'mpv.exe'), 'Windows mpv runtime') | |
| assertBinaryArch(mpvPath, archName, 'Windows mpv runtime') | |
| } | |
| const validateMacMpv = (baseDir) => { | |
| const mpvPath = ensureExists( | |
| path.join(baseDir, 'mpv-0.39.0', 'mpv.app', 'Contents', 'MacOS', 'mpv'), | |
| 'macOS mpv runtime' | |
| ) | |
| assertBinaryArch(mpvPath, archName, 'macOS mpv runtime') | |
| } | |
| const auditRepositorySupport = () => { | |
| appendSummary(`### Repository Compatibility Audit: ${platformName}-${archName}`) | |
| if (platformName === 'win') { | |
| const backgroundSource = fs.readFileSync(path.join(rootDir, 'src', 'background.ts'), 'utf8') | |
| if ( | |
| !backgroundSource.includes('function resolveWindowsMpvBinary()') || | |
| !backgroundSource.includes('binary: resolveWindowsMpvBinary()') | |
| ) { | |
| fail( | |
| `Repository support audit failed for ${platformName}-${archName}: src/background.ts must dynamically resolve the packaged Windows mpv runtime by process.arch.` | |
| ) | |
| } | |
| validateWindowsMpv(path.join(rootDir, 'resources')) | |
| appendSummary(`- PASS Windows repository runtime resolves and validates the prepared mpv runtime for ${archName}.`) | |
| note(`Repository compatibility audit passed for ${platformName}-${archName}`) | |
| return | |
| } | |
| if (platformName === 'mac') { | |
| const macMpvPath = path.join( | |
| rootDir, | |
| 'resources', | |
| 'mpv-0.39.0', | |
| 'mpv.app', | |
| 'Contents', | |
| 'MacOS', | |
| 'mpv' | |
| ) | |
| if (!fs.existsSync(macMpvPath)) { | |
| fail( | |
| `Repository support audit failed for ${platformName}-${archName}: src/background.ts expects resources/mpv-0.39.0/mpv.app/Contents/MacOS/mpv but the workflow did not prepare that runtime.` | |
| ) | |
| } | |
| appendSummary(`- PASS macOS source path contract is consistent with the prepared CI mpv.app runtime for ${archName}.`) | |
| note(`Repository compatibility audit passed for ${platformName}-${archName}`) | |
| return | |
| } | |
| if (platformName === 'linux') { | |
| const backgroundSource = fs.readFileSync( | |
| path.join(rootDir, 'src', 'background.ts'), | |
| 'utf8' | |
| ) | |
| if ( | |
| !backgroundSource.includes('process.env.NSMUSICS_MPV_BINARY') || | |
| !backgroundSource.includes("binary: linuxMpvBinary") | |
| ) { | |
| fail( | |
| `Repository support audit failed for ${platformName}-${archName}: src/background.ts must resolve Linux mpv from NSMUSICS_MPV_BINARY or system PATH.` | |
| ) | |
| } | |
| appendSummary( | |
| `- PASS Linux repository runtime is configured to resolve mpv from NSMUSICS_MPV_BINARY or the system PATH.` | |
| ) | |
| note(`Repository compatibility audit passed for ${platformName}-${archName}`) | |
| return | |
| } | |
| fail(`Unsupported repository compatibility audit platform: ${platformName}`) | |
| } | |
| const validatePreflight = () => { | |
| const betterSqlitePath = ensureExists( | |
| path.join(rootDir, 'resources', 'better_sqlite3.node'), | |
| 'Prepared better_sqlite3 runtime binary' | |
| ) | |
| assertBinaryArch( | |
| betterSqlitePath, | |
| archName, | |
| 'Prepared better_sqlite3 runtime binary' | |
| ) | |
| if (platformName === 'win') { | |
| validateWindowsMpv(path.join(rootDir, 'resources')) | |
| } else if (platformName === 'mac') { | |
| validateMacMpv(path.join(rootDir, 'resources')) | |
| } else if (platformName === 'linux') { | |
| const linuxMpvPath = resolveCommandPath('mpv') | |
| assertBinaryArch(linuxMpvPath, archName, 'System Linux mpv runtime') | |
| appendSummary(`- PASS Linux system mpv runtime found at ${linuxMpvPath}.`) | |
| } else { | |
| fail(`Unsupported preflight validation platform: ${platformName}`) | |
| } | |
| } | |
| const validatePackaged = () => { | |
| const unpackedRoot = resolveUnpackedRoot() | |
| note(`Resolved unpacked directory: ${unpackedRoot}`) | |
| if (platformName === 'win') { | |
| const executablePath = ensureExists( | |
| path.join(unpackedRoot, `${productName}.exe`), | |
| 'Packaged Windows executable' | |
| ) | |
| assertBinaryArch(executablePath, archName, 'Packaged Windows executable') | |
| const resourcesDir = ensureExists( | |
| path.join(unpackedRoot, 'resources'), | |
| 'Packaged Windows resources directory' | |
| ) | |
| const betterSqlitePath = ensureExists( | |
| path.join(resourcesDir, 'better_sqlite3.node'), | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| assertBinaryArch( | |
| betterSqlitePath, | |
| archName, | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| validateWindowsMpv(resourcesDir) | |
| return | |
| } | |
| if (platformName === 'linux') { | |
| const executablePath = resolveLinuxExecutable(unpackedRoot) | |
| assertBinaryArch(executablePath, archName, 'Packaged Linux executable') | |
| const resourcesDir = ensureExists( | |
| path.join(unpackedRoot, 'resources'), | |
| 'Packaged Linux resources directory' | |
| ) | |
| const betterSqlitePath = ensureExists( | |
| path.join(resourcesDir, 'better_sqlite3.node'), | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| assertBinaryArch( | |
| betterSqlitePath, | |
| archName, | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| appendSummary( | |
| '- PASS Packaged Linux artifact is validated against system mpv runtime usage; the application resolves `mpv` from PATH at runtime.' | |
| ) | |
| return | |
| } | |
| if (platformName === 'mac') { | |
| const appBundlePath = resolveMacBundle(unpackedRoot) | |
| const executablePath = ensureExists( | |
| path.join(appBundlePath, 'Contents', 'MacOS', productName), | |
| 'Packaged macOS executable' | |
| ) | |
| assertBinaryArch(executablePath, archName, 'Packaged macOS executable') | |
| const resourcesDir = ensureExists( | |
| path.join(appBundlePath, 'Contents', 'Resources'), | |
| 'Packaged macOS resources directory' | |
| ) | |
| const betterSqlitePath = ensureExists( | |
| path.join(resourcesDir, 'better_sqlite3.node'), | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| assertBinaryArch( | |
| betterSqlitePath, | |
| archName, | |
| 'Packaged better_sqlite3 runtime binary' | |
| ) | |
| validateMacMpv(resourcesDir) | |
| return | |
| } | |
| fail(`Unsupported validation platform: ${platformName}`) | |
| } | |
| if (mode === 'repo-audit') { | |
| auditRepositorySupport() | |
| appendSummary(`- PASS Repository compatibility audit finished for ${platformName}-${archName}.`) | |
| note('Repository compatibility audit passed.') | |
| } else if (mode === 'preflight') { | |
| validatePreflight() | |
| appendSummary(`- PASS Native preflight validation finished for ${platformName}-${archName}.`) | |
| note('Preflight native validation passed.') | |
| } else if (mode === 'packaged') { | |
| validatePackaged() | |
| appendSummary(`- PASS Packaged native validation finished for ${platformName}-${archName}.`) | |
| note('Packaged native validation passed.') | |
| } else { | |
| fail(`Unknown validation mode: ${mode}`) | |
| } | |
| '@ | Set-Content -Path .github/workflows/.tmp/validate-packaged-native.cjs -Encoding utf8 | |
| - name: Repository compatibility audit | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| run: node .github/workflows/.tmp/validate-packaged-native.cjs repo-audit | |
| - name: Install project dependencies | |
| run: npm ci --include=optional --no-audit --no-fund | |
| - name: Ensure platform frontend native packages | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| run: node .github/workflows/.tmp/ensure-platform-frontend-packages.cjs | |
| - name: Record frontend dependency inventory | |
| run: | | |
| $diagnosticsDir = '.github/workflows/.tmp/diagnostics' | |
| New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null | |
| $logPath = Join-Path $diagnosticsDir 'frontend-dependency-inventory.log' | |
| npm ls --depth=0 vite esbuild rollup lightningcss @tailwindcss/vite @tailwindcss/oxide *>&1 | Tee-Object -FilePath $logPath | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| - name: Clean previous release output | |
| run: | | |
| node -e "const fs=require('fs'); for (const dir of ['dist', 'release']) fs.rmSync(dir, { recursive: true, force: true });" | |
| - name: Rebuild native Electron dependencies for target arch | |
| run: npx electron-builder install-app-deps --platform ${{ matrix.electron_platform }} --arch ${{ matrix.arch }} | |
| - name: Stage runtime native resources | |
| run: | | |
| node -e "const fs=require('fs'); const path=require('path'); const root=process.cwd(); const direct=path.join(root,'node_modules','better-sqlite3','build','Release','better_sqlite3.node'); const fallback=[]; const walk=(dir)=>{ for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ const full=path.join(dir,entry.name); if(entry.isDirectory()) walk(full); else if(entry.isFile() && entry.name==='better_sqlite3.node') fallback.push(full); } }; if(!fs.existsSync(direct)){ const packageRoot=path.join(root,'node_modules','better-sqlite3'); if(fs.existsSync(packageRoot)) walk(packageRoot); } const source=fs.existsSync(direct)?direct:fallback[0]; if(!source) throw new Error('Unable to locate rebuilt better_sqlite3.node under node_modules/better-sqlite3 after install-app-deps.'); const target=path.join(root,'resources','better_sqlite3.node'); fs.copyFileSync(source,target); console.log(JSON.stringify({copiedFrom:source,copiedTo:target},null,2));" | |
| - name: Strict native preflight validation | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| run: node .github/workflows/.tmp/validate-packaged-native.cjs preflight | |
| - name: Build renderer bundle | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=6144 | |
| run: | | |
| $diagnosticsDir = '.github/workflows/.tmp/diagnostics' | |
| New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null | |
| $logPath = Join-Path $diagnosticsDir 'vite-build.log' | |
| npx vite build --config .github/workflows/.tmp/vite.ci.config.mjs *>&1 | Tee-Object -FilePath $logPath | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| - name: Build Electron main process bundle | |
| run: | | |
| node -e "require('esbuild').buildSync({ entryPoints: ['src/background.ts'], bundle: true, outfile: 'dist/background.js', platform: 'node', target: 'node12', external: ['electron'] })" | |
| node -e "const fs=require('fs'); const path=require('path'); const source=JSON.parse(fs.readFileSync('package.json','utf-8')); const runtimeDependencyAllowlist=['axios','better-sqlite3','fast-xml-parser','moment','node-cache','node-mpv','node-taglib-sharp','spark-md5','uuid']; const sourceDependencies=source.dependencies || {}; const runtimeDependencies=Object.fromEntries(runtimeDependencyAllowlist.flatMap((packageName)=>sourceDependencies[packageName] ? [[packageName, sourceDependencies[packageName]]] : [])); const runtime={ name:source.name, version:source.version, homepage:source.homepage, author:source.author, description:source.description, license:source.license, main:'background.js', dependencies:runtimeDependencies }; fs.mkdirSync(path.join(process.cwd(),'dist','node_modules'), { recursive: true }); fs.writeFileSync(path.join(process.cwd(),'dist','package.json'), JSON.stringify(runtime, null, 2));" | |
| - name: Build distributable artifacts | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| USE_SYSTEM_FPM: ${{ runner.os == 'Linux' && 'true' || '' }} | |
| FPM_DEBUG: ${{ runner.os == 'Linux' && '1' || '' }} | |
| run: | | |
| $diagnosticsDir = '.github/workflows/.tmp/diagnostics' | |
| $collectedAssetsDir = '.github/workflows/.tmp/collected-release-assets' | |
| New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null | |
| if (Test-Path $collectedAssetsDir) { | |
| Remove-Item -LiteralPath $collectedAssetsDir -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| New-Item -ItemType Directory -Path $collectedAssetsDir -Force | Out-Null | |
| $logPath = Join-Path $diagnosticsDir 'electron-builder-package.log' | |
| function Repair-MacDmgBusyFailure([string]$targetLogPath, [int]$attempt, [int]$maxAttempts) { | |
| if (-not (Test-Path $targetLogPath)) { | |
| return $false | |
| } | |
| $logContent = Get-Content -LiteralPath $targetLogPath -Raw | |
| $isBusyDetach = $logContent -match "hdiutil: couldn't eject" -and $logContent -match 'Resource busy' | |
| if (-not $isBusyDetach -or $attempt -ge $maxAttempts) { | |
| return $false | |
| } | |
| Write-Host "::warning title=macOS DMG retry::Transient hdiutil detach busy failure detected on attempt $attempt of $maxAttempts. Retrying dmg packaging." | |
| Write-Host '::group::macOS disk image state' | |
| & hdiutil info *>&1 | ForEach-Object { Write-Host $_ } | |
| Write-Host '::endgroup::' | |
| $disks = [regex]::Matches($logContent, '/dev/disk\d+') | | |
| ForEach-Object { $_.Value } | | |
| Select-Object -Unique | |
| foreach ($disk in $disks) { | |
| Write-Host "::group::Cleanup $disk" | |
| try { | |
| & hdiutil detach -force $disk *>&1 | ForEach-Object { Write-Host $_ } | |
| } catch { | |
| Write-Host $_ | |
| } | |
| $diskName = $disk -replace '^/dev/', '' | |
| try { | |
| & diskutil eject $diskName *>&1 | ForEach-Object { Write-Host $_ } | |
| } catch { | |
| Write-Host $_ | |
| } | |
| Write-Host '::endgroup::' | |
| } | |
| if (Test-Path 'release') { | |
| Get-ChildItem -Path 'release' -Recurse -File -ErrorAction SilentlyContinue | | |
| Where-Object { | |
| $_.Name -like '*.dmg' -or | |
| $_.Name -like '*.dmg.blockmap' | |
| } | | |
| ForEach-Object { | |
| Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| Start-Sleep -Seconds (5 * $attempt) | |
| return $true | |
| } | |
| function Collect-ReleaseArtifacts([string]$label, [string[]]$patterns) { | |
| foreach ($pattern in $patterns) { | |
| $matches = Get-ChildItem -Path 'release' -Recurse -File -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Name -like $pattern } | | |
| Sort-Object FullName | | |
| Select-Object -ExpandProperty FullName | |
| $matches = @($matches | Select-Object -Unique) | |
| if ($matches.Count -ne 1) { | |
| $candidateList = if ($matches.Count -eq 0) { '' } else { ($matches -join ', ') } | |
| Write-Host "::error title=Collected packaged artifact missing::Expected exactly one $label file matching $pattern, found $($matches.Count). Candidates: $candidateList" | |
| exit 1 | |
| } | |
| $sourcePath = $matches[0] | |
| $destinationPath = Join-Path $collectedAssetsDir (Split-Path -Leaf $sourcePath) | |
| Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force | |
| } | |
| } | |
| function Restore-CollectedArtifacts() { | |
| if (-not (Test-Path 'release')) { | |
| New-Item -ItemType Directory -Path 'release' -Force | Out-Null | |
| } | |
| Get-ChildItem -Path $collectedAssetsDir -File -ErrorAction SilentlyContinue | | |
| ForEach-Object { | |
| $destinationPath = Join-Path 'release' $_.Name | |
| Copy-Item -LiteralPath $_.FullName -Destination $destinationPath -Force | |
| } | |
| } | |
| function Reset-ReleaseDirectory() { | |
| if (Test-Path 'release') { | |
| Remove-Item -LiteralPath 'release' -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| New-Item -ItemType Directory -Path 'release' -Force | Out-Null | |
| } | |
| if ('${{ matrix.platform }}' -eq 'linux' -and '${{ matrix.arch }}' -eq 'arm64') { | |
| $targets = @('AppImage', 'deb', 'rpm', 'tar.gz') | |
| foreach ($target in $targets) { | |
| $safeTarget = $target.Replace('.', '_').Replace('/', '_') | |
| $targetLogPath = Join-Path $diagnosticsDir ("electron-builder-$safeTarget.log") | |
| Reset-ReleaseDirectory | |
| $env:NSMUSICS_ELECTRON_TARGETS = $target | |
| node .github/workflows/.tmp/run-electron-builder.cjs package *>&1 | Tee-Object -FilePath $targetLogPath | |
| $targetExitCode = $LASTEXITCODE | |
| if ($targetExitCode -ne 0) { | |
| exit $targetExitCode | |
| } | |
| switch ($target) { | |
| 'AppImage' { | |
| Collect-ReleaseArtifacts -label 'linux-arm64 AppImage' -patterns @('*.AppImage') | |
| } | |
| 'deb' { | |
| Collect-ReleaseArtifacts -label 'linux-arm64 deb' -patterns @('*.deb') | |
| } | |
| 'rpm' { | |
| Collect-ReleaseArtifacts -label 'linux-arm64 rpm' -patterns @('*.rpm') | |
| } | |
| 'tar.gz' { | |
| Collect-ReleaseArtifacts -label 'linux-arm64 tar.gz' -patterns @('*.tar.gz') | |
| } | |
| } | |
| } | |
| Restore-CollectedArtifacts | |
| Remove-Item Env:NSMUSICS_ELECTRON_TARGETS -ErrorAction SilentlyContinue | |
| } elseif ('${{ matrix.platform }}' -eq 'mac') { | |
| $macTargets = @('zip', 'dmg') | |
| foreach ($target in $macTargets) { | |
| $maxAttempts = if ($target -eq 'dmg') { 3 } else { 1 } | |
| $targetSucceeded = $false | |
| for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { | |
| $logSuffix = if ($maxAttempts -gt 1) { "$target-attempt$attempt" } else { $target } | |
| $targetLogPath = Join-Path $diagnosticsDir ("electron-builder-$logSuffix.log") | |
| Reset-ReleaseDirectory | |
| $env:NSMUSICS_ELECTRON_TARGETS = $target | |
| node .github/workflows/.tmp/run-electron-builder.cjs package *>&1 | Tee-Object -FilePath $targetLogPath | |
| $targetExitCode = $LASTEXITCODE | |
| if ($targetExitCode -eq 0) { | |
| $targetSucceeded = $true | |
| break | |
| } | |
| $shouldRetry = Repair-MacDmgBusyFailure -targetLogPath $targetLogPath -attempt $attempt -maxAttempts $maxAttempts | |
| if (-not $shouldRetry) { | |
| exit $targetExitCode | |
| } | |
| } | |
| if (-not $targetSucceeded) { | |
| exit 1 | |
| } | |
| switch ($target) { | |
| 'zip' { | |
| Collect-ReleaseArtifacts -label 'macOS zip' -patterns @('*.zip') | |
| } | |
| 'dmg' { | |
| Collect-ReleaseArtifacts -label 'macOS dmg' -patterns @('*.dmg', '*.dmg.blockmap') | |
| } | |
| } | |
| } | |
| Restore-CollectedArtifacts | |
| Remove-Item Env:NSMUSICS_ELECTRON_TARGETS -ErrorAction SilentlyContinue | |
| } else { | |
| node .github/workflows/.tmp/run-electron-builder.cjs package *>&1 | Tee-Object -FilePath $logPath | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| } | |
| - name: Strict packaged native validation | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| run: node .github/workflows/.tmp/validate-packaged-native.cjs packaged | |
| - name: List release files | |
| run: | | |
| node -e "const fs=require('fs'); const path=require('path'); const root='release'; const walk=(dir)=>fs.readdirSync(dir,{withFileTypes:true}).flatMap((entry)=>{const full=path.join(dir,entry.name); return entry.isDirectory()?walk(full):[full]}); if(!fs.existsSync(root)){throw new Error('release output not found')} const files=walk(root); if(files.length===0){throw new Error('release output is empty')} console.log(files.join('\n'));" | |
| - name: Stage upload bundle | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_PLATFORM_NAME: ${{ matrix.platform_name }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| NSMUSICS_BUILD_MODE: ${{ env.BUILD_MODE }} | |
| NSMUSICS_ARTIFACT_PREFIX: ${{ env.ARTIFACT_PREFIX }} | |
| NSMUSICS_TARGET_SUPPORTED: ${{ matrix.supported }} | |
| NSMUSICS_SUPPORT_REASON: ${{ matrix.support_reason }} | |
| run: node .github/workflows/.tmp/stage-publish-assets.cjs | |
| - name: Inspect packaged publish contents | |
| env: | |
| NSMUSICS_ELECTRON_PLATFORM: ${{ matrix.platform }} | |
| NSMUSICS_PLATFORM_NAME: ${{ matrix.platform_name }} | |
| NSMUSICS_ELECTRON_ARCH: ${{ matrix.arch }} | |
| NSMUSICS_BUILD_MODE: ${{ env.BUILD_MODE }} | |
| run: | | |
| $bundleLabel = "$($env:NSMUSICS_PLATFORM_NAME)-$($env:NSMUSICS_ELECTRON_ARCH)" | |
| $bundleRoot = Join-Path (Join-Path '.github/workflows/.out' $env:NSMUSICS_BUILD_MODE) $bundleLabel | |
| $publishDir = Join-Path $bundleRoot 'publish' | |
| $inspectionRoot = Join-Path $bundleRoot 'inspection' | |
| $scratchRoot = Join-Path (Join-Path '.github/workflows/.tmp/package-inspection' $env:NSMUSICS_BUILD_MODE) $bundleLabel | |
| $scriptPath = '.github/workflows/.tmp/summarize-package-contents.cjs' | |
| if (-not (Test-Path $publishDir)) { | |
| throw "Publish directory not found: $publishDir" | |
| } | |
| if (Test-Path $inspectionRoot) { | |
| Remove-Item -LiteralPath $inspectionRoot -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| if (Test-Path $scratchRoot) { | |
| Remove-Item -LiteralPath $scratchRoot -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| New-Item -ItemType Directory -Path $inspectionRoot -Force | Out-Null | |
| New-Item -ItemType Directory -Path $scratchRoot -Force | Out-Null | |
| @' | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const [rootPathArg, outputPathArg, platformArg, archArg, kindArg, sourceFileArg] = process.argv.slice(2) | |
| if (!rootPathArg || !outputPathArg || !platformArg || !archArg || !kindArg || !sourceFileArg) { | |
| throw new Error('Expected arguments: <rootPath> <outputPath> <platform> <arch> <kind> <sourceFile>') | |
| } | |
| const normalize = (value) => value.split(path.sep).join('/') | |
| const formatBytes = (value) => { | |
| const units = ['B', 'KB', 'MB', 'GB'] | |
| let size = value | |
| let unitIndex = 0 | |
| while (size >= 1024 && unitIndex < units.length - 1) { | |
| size /= 1024 | |
| unitIndex += 1 | |
| } | |
| return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}` | |
| } | |
| const maybeCollapseSingleDirectory = (inputDir) => { | |
| let current = path.resolve(inputDir) | |
| while (true) { | |
| const entries = fs | |
| .readdirSync(current, { withFileTypes: true }) | |
| .filter((entry) => entry.name !== '.DS_Store') | |
| if (entries.length !== 1 || !entries[0].isDirectory()) { | |
| return current | |
| } | |
| current = path.join(current, entries[0].name) | |
| } | |
| } | |
| const rootPath = maybeCollapseSingleDirectory(rootPathArg) | |
| const fileEntries = [] | |
| const dirSizes = new Map() | |
| let fileCount = 0 | |
| let dirCount = 0 | |
| let symlinkCount = 0 | |
| let totalBytes = 0 | |
| let localesTotalBytes = 0 | |
| let localesCount = 0 | |
| let resourcesTotalBytes = 0 | |
| let resourcesNodeTotalBytes = 0 | |
| let mpvTotalBytes = 0 | |
| let electronLocalesTotalBytes = 0 | |
| let appAsarBytes = 0 | |
| let betterSqliteBytes = 0 | |
| const forbiddenPaths = [] | |
| const addDirSize = (relativeFilePath, bytes) => { | |
| const directory = path.dirname(relativeFilePath) | |
| const segments = directory === '.' ? [] : directory.split('/') | |
| for (let depth = 1; depth <= Math.min(segments.length, 4); depth += 1) { | |
| const prefix = segments.slice(0, depth).join('/') | |
| dirSizes.set(prefix, (dirSizes.get(prefix) || 0) + bytes) | |
| } | |
| } | |
| const walk = (currentDir) => { | |
| const entries = fs.readdirSync(currentDir, { withFileTypes: true }) | |
| for (const entry of entries) { | |
| const fullPath = path.join(currentDir, entry.name) | |
| const stat = fs.lstatSync(fullPath) | |
| if (stat.isSymbolicLink()) { | |
| symlinkCount += 1 | |
| continue | |
| } | |
| if (stat.isDirectory()) { | |
| dirCount += 1 | |
| walk(fullPath) | |
| continue | |
| } | |
| if (!stat.isFile()) { | |
| continue | |
| } | |
| const relativePath = normalize(path.relative(rootPath, fullPath)) | |
| const lowerPath = relativePath.toLowerCase() | |
| const bytes = stat.size | |
| fileCount += 1 | |
| totalBytes += bytes | |
| fileEntries.push({ path: relativePath, bytes }) | |
| addDirSize(relativePath, bytes) | |
| if ( | |
| /(^|\/)(locales|locales_[^/]+)\//i.test(relativePath) || | |
| /\.lproj\/locale\.pak$/i.test(relativePath) || | |
| /(^|\/)locales\/[^/]+\.pak$/i.test(relativePath) | |
| ) { | |
| localesTotalBytes += bytes | |
| localesCount += 1 | |
| } | |
| if (/(^|\/)(resources|Resources)\//.test(relativePath)) { | |
| resourcesTotalBytes += bytes | |
| } | |
| if (/(^|\/)(resources|Resources)\/node\//.test(relativePath)) { | |
| resourcesNodeTotalBytes += bytes | |
| } | |
| if (/(^|\/)(resources|Resources)\/mpv[^/]*\//.test(relativePath)) { | |
| mpvTotalBytes += bytes | |
| } | |
| if (/(^|\/)locales\/[^/]+\.pak$/i.test(relativePath) || /\.lproj\/locale\.pak$/i.test(relativePath)) { | |
| electronLocalesTotalBytes += bytes | |
| } | |
| if (/\/app\.asar$/i.test(relativePath)) { | |
| appAsarBytes += bytes | |
| } | |
| if (/better_sqlite3\.node$/i.test(relativePath)) { | |
| betterSqliteBytes += bytes | |
| } | |
| if ( | |
| /(^|\/)(resources|Resources)\/node\/(linux|macos|win)\//.test(lowerPath) || | |
| /mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/(7z|doc|installer)\//.test(lowerPath) || | |
| /mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/.*\.(7z|pdf|ignore)$/.test(lowerPath) || | |
| /mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/chocolatey/.test(lowerPath) || | |
| /mpv[-_][^/]*(x86_64|i686|aarch64|arm64|x86)[^/]*\/(updater\.bat|settings\.xml)$/.test(lowerPath) | |
| ) { | |
| forbiddenPaths.push(relativePath) | |
| } | |
| } | |
| } | |
| walk(rootPath) | |
| fileEntries.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)) | |
| const topDirectories = [...dirSizes.entries()] | |
| .map(([dirPath, bytes]) => ({ path: dirPath, bytes })) | |
| .sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)) | |
| .slice(0, 25) | |
| const summary = { | |
| platform: platformArg, | |
| arch: archArg, | |
| kind: kindArg, | |
| source_file: path.basename(sourceFileArg), | |
| source_path: normalize(sourceFileArg), | |
| analyzed_root: normalize(rootPath), | |
| file_count: fileCount, | |
| dir_count: dirCount, | |
| symlink_count: symlinkCount, | |
| total_bytes: totalBytes, | |
| app_asar_bytes: appAsarBytes, | |
| locales_total_bytes: localesTotalBytes, | |
| locales_count: localesCount, | |
| electron_locales_total_bytes: electronLocalesTotalBytes, | |
| resources_total_bytes: resourcesTotalBytes, | |
| resources_node_total_bytes: resourcesNodeTotalBytes, | |
| mpv_total_bytes: mpvTotalBytes, | |
| better_sqlite3_bytes: betterSqliteBytes, | |
| forbidden_paths: forbiddenPaths.sort(), | |
| top_files: fileEntries.slice(0, 25), | |
| top_directories: topDirectories, | |
| } | |
| const markdownLines = [ | |
| `# Packaged Content Summary: ${platformArg}-${archArg} ${kindArg}`, | |
| '', | |
| `- Source file: ${summary.source_file}`, | |
| `- Analyzed root: ${summary.analyzed_root}`, | |
| `- Files: ${summary.file_count}`, | |
| `- Directories: ${summary.dir_count}`, | |
| `- Symlinks: ${summary.symlink_count}`, | |
| `- Total extracted bytes: ${formatBytes(summary.total_bytes)}`, | |
| `- app.asar: ${formatBytes(summary.app_asar_bytes)}`, | |
| `- Resources total: ${formatBytes(summary.resources_total_bytes)}`, | |
| `- Electron locales total: ${formatBytes(summary.electron_locales_total_bytes)}`, | |
| `- Locale entries counted: ${summary.locales_count}`, | |
| `- mpv payload total: ${formatBytes(summary.mpv_total_bytes)}`, | |
| `- better_sqlite3.node: ${formatBytes(summary.better_sqlite3_bytes)}`, | |
| `- Forbidden paths: ${summary.forbidden_paths.length}`, | |
| '', | |
| '## Top Directories', | |
| '| Path | Size |', | |
| '| --- | ---: |', | |
| ...summary.top_directories.map((entry) => `| ${entry.path} | ${formatBytes(entry.bytes)} |`), | |
| '', | |
| '## Top Files', | |
| '| Path | Size |', | |
| '| --- | ---: |', | |
| ...summary.top_files.map((entry) => `| ${entry.path} | ${formatBytes(entry.bytes)} |`), | |
| ] | |
| if (summary.forbidden_paths.length > 0) { | |
| markdownLines.push('', '## Forbidden Paths', ...summary.forbidden_paths.map((entry) => `- ${entry}`)) | |
| } | |
| fs.mkdirSync(path.dirname(outputPathArg), { recursive: true }) | |
| fs.writeFileSync(outputPathArg, JSON.stringify(summary, null, 2) + '\n') | |
| fs.writeFileSync(outputPathArg.replace(/\.json$/i, '.md'), markdownLines.join('\n') + '\n') | |
| '@ | Set-Content -Path $scriptPath -Encoding utf8 | |
| function Get-SinglePublishAsset([string]$pattern, [string]$label) { | |
| $matches = Get-ChildItem -Path $publishDir -File -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Name -like $pattern } | | |
| Sort-Object Name | |
| $matches = @($matches) | |
| if ($matches.Count -ne 1) { | |
| $candidates = if ($matches.Count -eq 0) { '' } else { ($matches.FullName -join ', ') } | |
| throw "Expected exactly one $label matching $pattern under $publishDir. Found $($matches.Count). Candidates: $candidates" | |
| } | |
| return $matches[0].FullName | |
| } | |
| function Write-InspectionSummary([string]$kind, [string]$rootPath, [string]$sourcePath) { | |
| $kindDir = Join-Path $inspectionRoot $kind | |
| New-Item -ItemType Directory -Path $kindDir -Force | Out-Null | |
| $summaryPath = Join-Path $kindDir 'package-content-summary.json' | |
| node $scriptPath $rootPath $summaryPath $env:NSMUSICS_PLATFORM_NAME $env:NSMUSICS_ELECTRON_ARCH $kind $sourcePath | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Package content summarizer failed for $kind" | |
| } | |
| } | |
| function Resolve-AppAsarPath([string]$rootPath) { | |
| $matches = Get-ChildItem -Path $rootPath -Recurse -File -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Name -eq 'app.asar' } | | |
| Sort-Object FullName | |
| $matches = @($matches) | |
| if ($matches.Count -ne 1) { | |
| $candidates = if ($matches.Count -eq 0) { '' } else { ($matches.FullName -join ', ') } | |
| throw "Expected exactly one app.asar under $rootPath. Found $($matches.Count). Candidates: $candidates" | |
| } | |
| return $matches[0].FullName | |
| } | |
| function Write-AsarInspectionSummary([string]$kind, [string]$rootPath) { | |
| $asarPath = Resolve-AppAsarPath -rootPath $rootPath | |
| $asarExtractDir = Join-Path $scratchRoot "$kind-app-asar" | |
| if (Test-Path $asarExtractDir) { | |
| Remove-Item -LiteralPath $asarExtractDir -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| New-Item -ItemType Directory -Path $asarExtractDir -Force | Out-Null | |
| & npx asar extract $asarPath $asarExtractDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "asar extraction failed for $asarPath" | |
| } | |
| Write-InspectionSummary -kind "$kind-app.asar" -rootPath $asarExtractDir -sourcePath $asarPath | |
| } | |
| if ($env:NSMUSICS_ELECTRON_PLATFORM -eq 'win') { | |
| $zipPath = Get-SinglePublishAsset '*.zip' 'Windows packaged zip' | |
| $zipExtractDir = Join-Path $scratchRoot 'zip-extracted' | |
| Expand-Archive -LiteralPath $zipPath -DestinationPath $zipExtractDir -Force | |
| Write-InspectionSummary -kind 'zip' -rootPath $zipExtractDir -sourcePath $zipPath | |
| Write-AsarInspectionSummary -kind 'zip' -rootPath $zipExtractDir | |
| } elseif ($env:NSMUSICS_ELECTRON_PLATFORM -eq 'linux') { | |
| $tarGzPath = Get-SinglePublishAsset '*.tar.gz' 'Linux tar.gz package' | |
| $tarExtractDir = Join-Path $scratchRoot 'tar-gz-extracted' | |
| New-Item -ItemType Directory -Path $tarExtractDir -Force | Out-Null | |
| & tar -xzf $tarGzPath -C $tarExtractDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "tar extraction failed for $tarGzPath" | |
| } | |
| Write-InspectionSummary -kind 'tar.gz' -rootPath $tarExtractDir -sourcePath $tarGzPath | |
| Write-AsarInspectionSummary -kind 'tar.gz' -rootPath $tarExtractDir | |
| $debPath = Get-SinglePublishAsset '*.deb' 'Linux deb package' | |
| $debExtractDir = Join-Path $scratchRoot 'deb-extracted' | |
| New-Item -ItemType Directory -Path $debExtractDir -Force | Out-Null | |
| & dpkg-deb -x $debPath $debExtractDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "dpkg-deb extraction failed for $debPath" | |
| } | |
| Write-InspectionSummary -kind 'deb' -rootPath $debExtractDir -sourcePath $debPath | |
| Write-AsarInspectionSummary -kind 'deb' -rootPath $debExtractDir | |
| $rpmPath = Get-SinglePublishAsset '*.rpm' 'Linux rpm package' | |
| $rpmExtractDir = Join-Path $scratchRoot 'rpm-extracted' | |
| New-Item -ItemType Directory -Path $rpmExtractDir -Force | Out-Null | |
| & bsdtar -xf $rpmPath -C $rpmExtractDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "rpm extraction failed for $rpmPath" | |
| } | |
| Write-InspectionSummary -kind 'rpm' -rootPath $rpmExtractDir -sourcePath $rpmPath | |
| Write-AsarInspectionSummary -kind 'rpm' -rootPath $rpmExtractDir | |
| $appImagePath = Get-SinglePublishAsset '*.AppImage' 'Linux AppImage package' | |
| $appImageExtractDir = Join-Path $scratchRoot 'appimage-extracted' | |
| New-Item -ItemType Directory -Path $appImageExtractDir -Force | Out-Null | |
| Push-Location $appImageExtractDir | |
| try { | |
| & chmod +x $appImagePath | |
| & $appImagePath --appimage-extract | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "AppImage extraction failed for $appImagePath" | |
| } | |
| } finally { | |
| Pop-Location | |
| } | |
| $appImageRoot = Join-Path $appImageExtractDir 'squashfs-root' | |
| if (-not (Test-Path $appImageRoot)) { | |
| throw "AppImage extraction output not found: $appImageRoot" | |
| } | |
| Write-InspectionSummary -kind 'AppImage' -rootPath $appImageRoot -sourcePath $appImagePath | |
| Write-AsarInspectionSummary -kind 'AppImage' -rootPath $appImageRoot | |
| } elseif ($env:NSMUSICS_ELECTRON_PLATFORM -eq 'mac') { | |
| $zipPath = Get-SinglePublishAsset '*.zip' 'macOS packaged zip' | |
| $zipExtractDir = Join-Path $scratchRoot 'zip-extracted' | |
| New-Item -ItemType Directory -Path $zipExtractDir -Force | Out-Null | |
| & ditto -x -k $zipPath $zipExtractDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "ditto extraction failed for $zipPath" | |
| } | |
| Write-InspectionSummary -kind 'zip' -rootPath $zipExtractDir -sourcePath $zipPath | |
| Write-AsarInspectionSummary -kind 'zip' -rootPath $zipExtractDir | |
| $dmgPath = Get-SinglePublishAsset '*.dmg' 'macOS dmg package' | |
| $dmgMountPath = Join-Path $scratchRoot 'dmg-mount' | |
| New-Item -ItemType Directory -Path $dmgMountPath -Force | Out-Null | |
| try { | |
| & hdiutil attach $dmgPath -nobrowse -mountpoint $dmgMountPath | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "hdiutil attach failed for $dmgPath" | |
| } | |
| Write-InspectionSummary -kind 'dmg' -rootPath $dmgMountPath -sourcePath $dmgPath | |
| } finally { | |
| & hdiutil detach $dmgMountPath -force | Out-Null | |
| } | |
| } else { | |
| throw "Unsupported inspection platform: $($env:NSMUSICS_ELECTRON_PLATFORM)" | |
| } | |
| node -e "const fs=require('fs'); const path=require('path'); const root=path.resolve(process.argv[1]); const files=[]; const walk=(dir)=>{ for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ const full=path.join(dir,entry.name); if(entry.isDirectory()) walk(full); else if(entry.isFile() && entry.name==='package-content-summary.json') files.push(full); } }; walk(root); const summaries=files.sort().map((filePath)=>JSON.parse(fs.readFileSync(filePath,'utf8'))); fs.writeFileSync(path.join(root,'package-content-summaries.json'), JSON.stringify(summaries,null,2)+'\n');" $inspectionRoot | |
| Remove-Item -LiteralPath $scratchRoot -Recurse -Force -ErrorAction SilentlyContinue | |
| - name: Upload packaged artifacts | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ env.ARTIFACT_PREFIX }}-${{ env.BUILD_MODE }}-bundle-${{ matrix.platform_name }}-${{ matrix.arch }} | |
| path: | | |
| NSMusicS-Electron/.github/workflows/.out/${{ env.BUILD_MODE }}/${{ matrix.platform_name }}-${{ matrix.arch }}/build-manifest.json | |
| NSMusicS-Electron/.github/workflows/.out/${{ env.BUILD_MODE }}/${{ matrix.platform_name }}-${{ matrix.arch }}/publish-assets.txt | |
| NSMusicS-Electron/.github/workflows/.out/${{ env.BUILD_MODE }}/${{ matrix.platform_name }}-${{ matrix.arch }}/inspection/** | |
| NSMusicS-Electron/.github/workflows/.out/${{ env.BUILD_MODE }}/${{ matrix.platform_name }}-${{ matrix.arch }}/publish/** | |
| if-no-files-found: error | |
| include-hidden-files: true | |
| overwrite: true | |
| compression-level: 0 | |
| retention-days: 21 | |
| - name: Surface failure diagnostics in job log | |
| if: ${{ failure() }} | |
| run: | | |
| $summaryPath = $env:GITHUB_STEP_SUMMARY | |
| function Append-Summary([string]$message) { | |
| if ($summaryPath) { | |
| Add-Content -Path $summaryPath -Value $message | |
| } | |
| } | |
| function Show-LogTail([string]$title, [string]$path) { | |
| Write-Host "::group::$title" | |
| Get-Content -LiteralPath $path -Tail 160 | |
| Write-Host "::endgroup::" | |
| } | |
| $paths = @() | |
| if (Test-Path '.github/workflows/.tmp/diagnostics') { | |
| $paths += Get-ChildItem -Path '.github/workflows/.tmp/diagnostics' -Recurse -File | | |
| Sort-Object FullName | | |
| Select-Object -ExpandProperty FullName | |
| } | |
| if (Test-Path 'release') { | |
| $paths += Get-ChildItem -Path 'release' -Recurse -File -ErrorAction SilentlyContinue | | |
| Where-Object { | |
| $_.Name -match '\.log$' -or | |
| $_.Name -eq 'builder-debug.yml' -or | |
| $_.Name -eq 'builder-effective-config.yaml' | |
| } | | |
| Sort-Object FullName | | |
| Select-Object -ExpandProperty FullName | |
| } | |
| $paths = @($paths | Select-Object -Unique) | |
| if ($paths.Count -eq 0) { | |
| Write-Host '::warning title=No failure diagnostics captured::No diagnostic log files were found in the workspace.' | |
| Append-Summary('### Failure Diagnostics') | |
| Append-Summary('- WARN No diagnostic log files were found in the workspace.') | |
| exit 0 | |
| } | |
| Write-Host "::error title=Build failure diagnostics surfaced::See the grouped log tails below and the uploaded diagnostics artifact for the full files." | |
| Write-Host '::group::Failure diagnostics inventory' | |
| $paths | ForEach-Object { Write-Host $_ } | |
| Write-Host '::endgroup::' | |
| Append-Summary('### Failure Diagnostics') | |
| Append-Summary("- Surfaced logs: $($paths.Count)") | |
| foreach ($path in $paths) { | |
| Append-Summary("- $(Split-Path -Leaf $path)") | |
| Show-LogTail -title ("Failure log: " + (Split-Path -Leaf $path)) -path $path | |
| } | |
| - name: Upload failure diagnostics | |
| if: ${{ failure() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ env.ARTIFACT_PREFIX }}-failure-${{ env.BUILD_MODE }}-${{ matrix.platform_name }}-${{ matrix.arch }} | |
| path: | | |
| NSMusicS-Electron/.github/workflows/.tmp/diagnostics/** | |
| NSMusicS-Electron/release/**/*.log | |
| NSMusicS-Electron/release/**/builder-debug.yml | |
| NSMusicS-Electron/release/**/builder-effective-config.yaml | |
| if-no-files-found: warn | |
| include-hidden-files: true | |
| overwrite: true | |
| compression-level: 0 | |
| retention-days: 14 | |
| report: | |
| name: Build Report | |
| if: ${{ always() }} | |
| needs: | |
| - plan | |
| - policy | |
| - build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Download build artifacts | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: ${{ env.ARTIFACT_PREFIX }}-${{ needs.plan.outputs.build_mode }}-* | |
| path: report-artifacts | |
| - name: Generate workflow report | |
| shell: bash | |
| env: | |
| BUILD_MODE_VALUE: ${{ needs.plan.outputs.build_mode }} | |
| POLICY_RESULT: ${{ needs.policy.result }} | |
| ALL_TARGETS_JSON: ${{ needs.plan.outputs.all_targets }} | |
| BUILD_RESULT: ${{ needs.build.result }} | |
| run: | | |
| mkdir -p report-output | |
| node - <<'NODE' | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const buildMode = process.env.BUILD_MODE_VALUE | |
| const policyResult = process.env.POLICY_RESULT | |
| const buildResult = process.env.BUILD_RESULT | |
| const githubRef = process.env.GITHUB_REF | |
| const githubSha = process.env.GITHUB_SHA | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const formatBytes = (value) => { | |
| if (!Number.isFinite(value) || value <= 0) { | |
| return '0 B' | |
| } | |
| const units = ['B', 'KB', 'MB', 'GB'] | |
| let size = value | |
| let unitIndex = 0 | |
| while (size >= 1024 && unitIndex < units.length - 1) { | |
| size /= 1024 | |
| unitIndex += 1 | |
| } | |
| return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}` | |
| } | |
| const targets = JSON.parse(process.env.ALL_TARGETS_JSON).map((target) => ({ | |
| platform: target.platform_name, | |
| arch: target.arch, | |
| supported: target.supported, | |
| support_reason: target.support_reason, | |
| })) | |
| const artifactRoot = path.resolve('report-artifacts') | |
| const manifests = [] | |
| const inspectionFiles = [] | |
| const walk = (dir) => { | |
| if (!fs.existsSync(dir)) { | |
| return | |
| } | |
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | |
| const full = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| walk(full) | |
| } else if (entry.isFile() && entry.name === 'build-manifest.json') { | |
| manifests.push(full) | |
| } else if (entry.isFile() && entry.name === 'package-content-summary.json') { | |
| inspectionFiles.push(full) | |
| } | |
| } | |
| } | |
| walk(artifactRoot) | |
| const manifestMap = new Map() | |
| for (const filePath of manifests) { | |
| const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) | |
| manifestMap.set(`${data.platform}-${data.arch}`, data) | |
| } | |
| const inspectionMap = new Map() | |
| for (const filePath of inspectionFiles) { | |
| const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) | |
| const key = `${data.platform}-${data.arch}` | |
| const list = inspectionMap.get(key) || [] | |
| list.push(data) | |
| inspectionMap.set(key, list) | |
| } | |
| for (const [key, list] of inspectionMap.entries()) { | |
| inspectionMap.set( | |
| key, | |
| list.sort((left, right) => left.kind.localeCompare(right.kind)) | |
| ) | |
| } | |
| const rows = targets.map((target) => { | |
| const key = `${target.platform}-${target.arch}` | |
| const planned = buildMode === 'strict-all' || target.supported | |
| const artifactProduced = manifestMap.has(key) | |
| const releaseEligible = | |
| buildMode === 'supported-only' && | |
| githubRef.startsWith('refs/tags/') && | |
| target.supported && | |
| artifactProduced | |
| let outcome = 'not-scheduled' | |
| if (planned && artifactProduced) { | |
| outcome = 'artifact-produced' | |
| } else if (planned && !artifactProduced && buildMode === 'strict-all' && !target.supported) { | |
| outcome = 'expected-hard-fail' | |
| } else if (planned && !artifactProduced) { | |
| outcome = 'missing-artifact' | |
| } | |
| return { | |
| ...target, | |
| planned, | |
| artifactProduced, | |
| releaseEligible, | |
| outcome, | |
| artifactName: manifestMap.get(key)?.artifact_name || '', | |
| } | |
| }) | |
| const missingSupported = rows.filter( | |
| (row) => row.supported && row.planned && !row.artifactProduced | |
| ) | |
| const missingInspections = rows.filter( | |
| (row) => row.artifactProduced && (inspectionMap.get(`${row.platform}-${row.arch}`) || []).length === 0 | |
| ) | |
| const notes = [ | |
| '# Workflow Report', | |
| '', | |
| `- Build mode: ${buildMode}`, | |
| `- Policy result: ${policyResult}`, | |
| `- Aggregate build result: ${buildResult}`, | |
| `- Git ref: ${githubRef}`, | |
| `- Commit: ${githubSha}`, | |
| `- Produced artifacts: ${rows.filter((row) => row.artifactProduced).length}`, | |
| '', | |
| '| Target | Supported | Planned | Artifact | Release | Outcome |', | |
| '| --- | --- | --- | --- | --- | --- |', | |
| ] | |
| for (const row of rows) { | |
| notes.push( | |
| `| ${row.platform}-${row.arch} | ${row.supported ? 'yes' : 'no'} | ${row.planned ? 'yes' : 'no'} | ${row.artifactProduced ? 'yes' : 'no'} | ${row.releaseEligible ? 'yes' : 'no'} | ${row.outcome} |` | |
| ) | |
| notes.push(`Reason: ${row.support_reason}`) | |
| if (row.artifactName) { | |
| notes.push(`Artifact: ${row.artifactName}`) | |
| } | |
| } | |
| const contentAuditRows = [] | |
| notes.push('', '## Packaged Content Audit') | |
| if (inspectionFiles.length === 0) { | |
| notes.push('- WARN No package content summaries were discovered.') | |
| } else { | |
| notes.push('| Target | Kind | Files | app.asar | Electron Locales | Resources | mpv | Forbidden |') | |
| notes.push('| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |') | |
| for (const row of rows) { | |
| const summaries = inspectionMap.get(`${row.platform}-${row.arch}`) || [] | |
| for (const summary of summaries) { | |
| contentAuditRows.push({ | |
| target: `${row.platform}-${row.arch}`, | |
| kind: summary.kind, | |
| source_file: summary.source_file, | |
| file_count: summary.file_count, | |
| total_bytes: summary.total_bytes, | |
| app_asar_bytes: summary.app_asar_bytes, | |
| electron_locales_total_bytes: summary.electron_locales_total_bytes, | |
| resources_total_bytes: summary.resources_total_bytes, | |
| mpv_total_bytes: summary.mpv_total_bytes, | |
| forbidden_path_count: (summary.forbidden_paths || []).length, | |
| top_files: summary.top_files || [], | |
| top_directories: summary.top_directories || [], | |
| }) | |
| notes.push( | |
| `| ${row.platform}-${row.arch} | ${summary.kind} | ${summary.file_count} | ${formatBytes(summary.app_asar_bytes)} | ${formatBytes(summary.electron_locales_total_bytes)} | ${formatBytes(summary.resources_total_bytes)} | ${formatBytes(summary.mpv_total_bytes)} | ${(summary.forbidden_paths || []).length} |` | |
| ) | |
| } | |
| } | |
| } | |
| fs.writeFileSync(path.join('report-output', 'workflow-report.md'), `${notes.join('\n')}\n`) | |
| fs.writeFileSync( | |
| path.join('report-output', 'workflow-report.json'), | |
| JSON.stringify( | |
| { | |
| build_mode: buildMode, | |
| build_result: buildResult, | |
| git_ref: githubRef, | |
| git_sha: githubSha, | |
| rows, | |
| content_audit_rows: contentAuditRows, | |
| }, | |
| null, | |
| 2 | |
| ) + '\n' | |
| ) | |
| fs.writeFileSync( | |
| path.join('report-output', 'package-content-audit.json'), | |
| JSON.stringify(contentAuditRows, null, 2) + '\n' | |
| ) | |
| fs.writeFileSync( | |
| path.join('report-output', 'package-content-audit.md'), | |
| `${notes.slice(notes.indexOf('## Packaged Content Audit')).join('\n')}\n` | |
| ) | |
| if (summaryPath) { | |
| fs.appendFileSync(summaryPath, `${notes.join('\n')}\n`) | |
| } | |
| if (missingSupported.length > 0 || missingInspections.length > 0) { | |
| const messages = [] | |
| if (missingSupported.length > 0) { | |
| messages.push( | |
| `Supported targets missing artifacts: ${missingSupported | |
| .map((row) => `${row.platform}-${row.arch}`) | |
| .join(', ')}` | |
| ) | |
| } | |
| if (missingInspections.length > 0) { | |
| messages.push( | |
| `Produced targets missing content inspections: ${missingInspections | |
| .map((row) => `${row.platform}-${row.arch}`) | |
| .join(', ')}` | |
| ) | |
| } | |
| console.error( | |
| messages.join('\n') | |
| ) | |
| process.exit(1) | |
| } | |
| NODE | |
| - name: Upload workflow report artifact | |
| if: ${{ always() }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ env.ARTIFACT_PREFIX }}-report-${{ needs.plan.outputs.build_mode }} | |
| path: report-output/** | |
| overwrite: true | |
| if-no-files-found: error | |
| retention-days: 21 | |
| publish-release: | |
| name: Publish GitHub Release | |
| if: ${{ startsWith(github.ref, 'refs/tags/') && needs.plan.outputs.build_mode == 'supported-only' && needs.policy.result == 'success' && needs.build.result == 'success' && needs.report.result == 'success' }} | |
| needs: | |
| - plan | |
| - policy | |
| - build | |
| - report | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 40 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: ${{ env.ARTIFACT_PREFIX }}-${{ needs.plan.outputs.build_mode }}-* | |
| path: release-artifacts | |
| - name: List downloaded assets | |
| run: find release-artifacts -type f -print | sort | |
| - name: Verify GitHub CLI release context | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| gh --version | |
| gh repo view "${GH_REPO}" --json name >/dev/null | |
| - name: Build release notes and asset manifest | |
| shell: bash | |
| run: | | |
| node - <<'NODE' | |
| const crypto = require('node:crypto') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const formatBytes = (value) => { | |
| if (value < 1024) { | |
| return `${value} B` | |
| } | |
| const units = ['KB', 'MB', 'GB'] | |
| let size = value | |
| let unitIndex = -1 | |
| do { | |
| size /= 1024 | |
| unitIndex += 1 | |
| } while (size >= 1024 && unitIndex < units.length - 1) | |
| return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}` | |
| } | |
| const formatPercent = (value) => { | |
| const sign = value > 0 ? '+' : '' | |
| return `${sign}${value.toFixed(1)}%` | |
| } | |
| const classifyAssetSize = (asset) => { | |
| if (asset.size_bytes < asset.fail_min_bytes || asset.size_bytes > asset.fail_max_bytes) { | |
| return 'FAIL' | |
| } | |
| if (asset.size_bytes < asset.warn_min_bytes || asset.size_bytes > asset.warn_max_bytes) { | |
| return 'WARN' | |
| } | |
| return 'PASS' | |
| } | |
| const root = path.resolve('release-artifacts') | |
| const manifests = [] | |
| const walk = (dir) => { | |
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | |
| const full = path.join(dir, entry.name) | |
| if (entry.isDirectory()) { | |
| walk(full) | |
| } else if (entry.isFile() && entry.name === 'build-manifest.json') { | |
| manifests.push(full) | |
| } | |
| } | |
| } | |
| walk(root) | |
| if (manifests.length === 0) { | |
| throw new Error('No build-manifest.json files were downloaded.') | |
| } | |
| const entries = manifests | |
| .map((filePath) => { | |
| const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) | |
| const artifactRoot = path.dirname(filePath) | |
| const publishAssets = (data.publish_assets || []).map((relativePath) => | |
| path.join(artifactRoot, relativePath) | |
| ) | |
| const publishAssetDetails = (data.publish_asset_details || []).map((asset) => ({ | |
| ...asset, | |
| absolute_path: path.join(artifactRoot, asset.relative_path), | |
| })) | |
| return { ...data, artifactRoot, publishAssets, publishAssetDetails } | |
| }) | |
| .sort((left, right) => | |
| `${left.platform}-${left.arch}`.localeCompare(`${right.platform}-${right.arch}`) | |
| ) | |
| const assetLines = [] | |
| const notes = [ | |
| '# NSMusicS Electron Release', | |
| '', | |
| `- Build mode: ${process.env.BUILD_MODE}`, | |
| `- Git ref: ${process.env.GITHUB_REF}`, | |
| `- Commit: ${process.env.GITHUB_SHA}`, | |
| '', | |
| '## Included Targets', | |
| ] | |
| for (const entry of entries) { | |
| notes.push(`- ${entry.platform}-${entry.arch}`) | |
| notes.push(` Support note: ${entry.support_reason}`) | |
| if (!entry.publishAssets.length) { | |
| throw new Error(`No publish assets were declared for ${entry.platform}-${entry.arch}`) | |
| } | |
| for (const asset of entry.publishAssetDetails) { | |
| notes.push(` Asset: ${asset.file_name} (${formatBytes(asset.size_bytes)})`) | |
| } | |
| for (const assetPath of entry.publishAssets) { | |
| if (!fs.existsSync(assetPath)) { | |
| throw new Error(`Declared publish asset missing: ${assetPath}`) | |
| } | |
| assetLines.push(assetPath) | |
| } | |
| } | |
| const dedupedAssets = [...new Set(assetLines)].sort((left, right) => | |
| path.basename(left).localeCompare(path.basename(right)) | |
| ) | |
| const basenameCounts = new Map() | |
| for (const assetPath of dedupedAssets) { | |
| const base = path.basename(assetPath) | |
| basenameCounts.set(base, (basenameCounts.get(base) || 0) + 1) | |
| } | |
| const duplicateBasenames = [...basenameCounts.entries()] | |
| .filter(([, count]) => count > 1) | |
| .map(([base]) => base) | |
| if (duplicateBasenames.length > 0) { | |
| throw new Error( | |
| `Release asset basenames must be unique. Duplicates: ${duplicateBasenames.join(', ')}` | |
| ) | |
| } | |
| const checksumLines = dedupedAssets.map((assetPath) => { | |
| const digest = crypto.createHash('sha256').update(fs.readFileSync(assetPath)).digest('hex') | |
| return `${digest} ${path.basename(assetPath)}` | |
| }) | |
| const checksumPath = path.join(root, 'SHA256SUMS.txt') | |
| fs.writeFileSync(checksumPath, checksumLines.join('\n') + '\n') | |
| dedupedAssets.push(checksumPath) | |
| notes.push('') | |
| notes.push('## Integrity') | |
| notes.push('- Release assets include `SHA256SUMS.txt` for checksum verification.') | |
| const auditRows = entries.flatMap((entry) => | |
| entry.publishAssetDetails.map((asset) => { | |
| const deltaPercent = ((asset.size_bytes - asset.reference_bytes) / asset.reference_bytes) * 100 | |
| return { | |
| target: `${entry.platform}-${entry.arch}`, | |
| name: asset.file_name, | |
| kind: asset.kind, | |
| size_bytes: asset.size_bytes, | |
| size_human: formatBytes(asset.size_bytes), | |
| reference_bytes: asset.reference_bytes, | |
| reference_human: formatBytes(asset.reference_bytes), | |
| delta_percent: deltaPercent, | |
| status: classifyAssetSize(asset), | |
| warn_range_human: `${formatBytes(asset.warn_min_bytes)} - ${formatBytes(asset.warn_max_bytes)}`, | |
| fail_range_human: `${formatBytes(asset.fail_min_bytes)} - ${formatBytes(asset.fail_max_bytes)}`, | |
| } | |
| }) | |
| ) | |
| const auditLines = [ | |
| '# Release Asset Audit', | |
| '', | |
| `- Build mode: ${process.env.BUILD_MODE}`, | |
| `- Git ref: ${process.env.GITHUB_REF}`, | |
| `- Commit: ${process.env.GITHUB_SHA}`, | |
| `- Assets: ${auditRows.length}`, | |
| '', | |
| '| Target | File | Kind | Size | Reference | Delta | Status |', | |
| '| --- | --- | --- | ---: | ---: | ---: | --- |', | |
| ...auditRows.map( | |
| (row) => | |
| `| ${row.target} | ${row.name} | ${row.kind} | ${row.size_human} | ${row.reference_human} | ${formatPercent(row.delta_percent)} | ${row.status} |` | |
| ), | |
| '', | |
| '## Thresholds', | |
| '| File | Warn Range | Fail Range |', | |
| '| --- | --- | --- |', | |
| ...auditRows.map( | |
| (row) => `| ${row.name} | ${row.warn_range_human} | ${row.fail_range_human} |` | |
| ), | |
| ] | |
| fs.writeFileSync(path.join(root, 'release-notes.md'), `${notes.join('\n')}\n`) | |
| fs.writeFileSync(path.join(root, 'publish-assets.txt'), dedupedAssets.join('\n') + '\n') | |
| fs.writeFileSync(path.join(root, 'release-asset-audit.md'), `${auditLines.join('\n')}\n`) | |
| fs.writeFileSync(path.join(root, 'release-asset-audit.json'), `${JSON.stringify(auditRows, null, 2)}\n`) | |
| if (summaryPath) { | |
| fs.appendFileSync(summaryPath, `${auditLines.join('\n')}\n`) | |
| } | |
| NODE | |
| - name: Create or update release | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| retry_gh() { | |
| local attempt=1 | |
| local max_attempts=5 | |
| local sleep_seconds=15 | |
| while true; do | |
| if gh "$@"; then | |
| return 0 | |
| fi | |
| if [ "${attempt}" -ge "${max_attempts}" ]; then | |
| echo "gh command failed after ${attempt} attempts: gh $*" >&2 | |
| return 1 | |
| fi | |
| echo "gh command failed on attempt ${attempt}/${max_attempts}: gh $*" >&2 | |
| echo "Retrying in ${sleep_seconds}s..." >&2 | |
| sleep "${sleep_seconds}" | |
| attempt=$((attempt + 1)) | |
| done | |
| } | |
| release_exists() { | |
| local target_tag="$1" | |
| local attempt=1 | |
| local max_attempts=5 | |
| local sleep_seconds=10 | |
| local response_file | |
| local headers_file | |
| local api_url | |
| local http_code | |
| local curl_exit | |
| response_file="$(mktemp)" | |
| headers_file="$(mktemp)" | |
| api_url="https://api.github.com/repos/${GH_REPO}/releases/tags/${target_tag}" | |
| while true; do | |
| curl_exit=0 | |
| http_code="$( | |
| curl --silent --show-error --location \ | |
| --output "${response_file}" \ | |
| --dump-header "${headers_file}" \ | |
| --write-out "%{http_code}" \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "${api_url}" | |
| )" || curl_exit=$? | |
| if [ "${curl_exit}" -eq 0 ] && [ "${http_code}" = "200" ]; then | |
| rm -f "${response_file}" "${headers_file}" | |
| return 0 | |
| fi | |
| if [ "${curl_exit}" -eq 0 ] && [ "${http_code}" = "404" ]; then | |
| rm -f "${response_file}" "${headers_file}" | |
| return 1 | |
| fi | |
| if [ "${attempt}" -ge "${max_attempts}" ]; then | |
| if [ -s "${headers_file}" ]; then | |
| cat "${headers_file}" >&2 | |
| fi | |
| if [ -s "${response_file}" ]; then | |
| cat "${response_file}" >&2 | |
| fi | |
| rm -f "${response_file}" "${headers_file}" | |
| echo "Unable to determine whether release ${target_tag} exists after ${attempt} attempts." >&2 | |
| return 2 | |
| fi | |
| echo "Release lookup attempt ${attempt}/${max_attempts} for ${target_tag} returned curl_exit=${curl_exit} http_code=${http_code:-unset}." >&2 | |
| if [ -s "${headers_file}" ]; then | |
| cat "${headers_file}" >&2 | |
| fi | |
| if [ -s "${response_file}" ]; then | |
| cat "${response_file}" >&2 | |
| fi | |
| echo "Retrying release existence check for ${target_tag} in ${sleep_seconds}s..." >&2 | |
| sleep "${sleep_seconds}" | |
| : > "${response_file}" | |
| : > "${headers_file}" | |
| attempt=$((attempt + 1)) | |
| done | |
| } | |
| tag="${GITHUB_REF_NAME}" | |
| release_is_validation=false | |
| if [[ "${tag}" == validate-* ]]; then | |
| release_is_validation=true | |
| fi | |
| mapfile -t files < release-artifacts/publish-assets.txt | |
| if [ "${#files[@]}" -eq 0 ]; then | |
| echo "No publish assets were prepared." >&2 | |
| exit 1 | |
| fi | |
| expected_asset_names=() | |
| for file_path in "${files[@]}"; do | |
| expected_asset_names+=("$(basename "${file_path}")") | |
| done | |
| if release_exists "${tag}"; then | |
| release_status=0 | |
| else | |
| release_status=$? | |
| fi | |
| if [ "${release_status}" -eq 0 ]; then | |
| if [ "${release_is_validation}" = true ]; then | |
| retry_gh release edit "${tag}" --draft --notes-file release-artifacts/release-notes.md --title "${tag}" | |
| else | |
| retry_gh release edit "${tag}" --draft=false --prerelease=false --notes-file release-artifacts/release-notes.md --title "${tag}" | |
| fi | |
| retry_gh release upload "${tag}" "${files[@]}" --clobber | |
| current_asset_names="$(retry_gh release view "${tag}" --json assets --jq '.assets[].name')" | |
| while IFS= read -r asset_name; do | |
| keep_asset=false | |
| for expected_name in "${expected_asset_names[@]}"; do | |
| if [ "${expected_name}" = "${asset_name}" ]; then | |
| keep_asset=true | |
| break | |
| fi | |
| done | |
| if [ -n "${asset_name}" ] && [ "${keep_asset}" = false ]; then | |
| retry_gh release delete-asset "${tag}" "${asset_name}" --yes | |
| fi | |
| done <<< "${current_asset_names}" | |
| elif [ "${release_status}" -eq 1 ]; then | |
| if [ "${release_is_validation}" = true ]; then | |
| retry_gh release create "${tag}" "${files[@]}" --notes-file release-artifacts/release-notes.md --title "${tag}" --draft | |
| else | |
| retry_gh release create "${tag}" "${files[@]}" --notes-file release-artifacts/release-notes.md --title "${tag}" | |
| fi | |
| else | |
| echo "Failed to resolve release existence state for ${tag}." >&2 | |
| exit 1 | |
| fi | |
| { | |
| echo "## Release Publish" | |
| echo "- Tag: ${tag}" | |
| echo "- Validation lane: ${release_is_validation}" | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| - name: Verify published release assets | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| run: | | |
| node - <<'NODE' | |
| const crypto = require('node:crypto') | |
| const fs = require('node:fs') | |
| const path = require('node:path') | |
| const childProcess = require('node:child_process') | |
| const tag = process.env.GITHUB_REF_NAME | |
| const repo = process.env.GITHUB_REPOSITORY | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY | |
| const publishListPath = path.join('release-artifacts', 'publish-assets.txt') | |
| const maxAttempts = 36 | |
| const sleepSeconds = 10 | |
| const stringifyError = (error) => { | |
| const chunks = [] | |
| for (const value of [error?.message, error?.stderr, error?.stdout]) { | |
| if (!value) { | |
| continue | |
| } | |
| const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value) | |
| if (text.trim()) { | |
| chunks.push(text.trim()) | |
| } | |
| } | |
| return chunks.join('\n') | |
| } | |
| const ghApiJson = (endpoint) => { | |
| const payload = childProcess.execFileSync( | |
| 'gh', | |
| ['api', '-H', 'Accept: application/vnd.github+json', endpoint], | |
| { encoding: 'utf8' } | |
| ) | |
| return JSON.parse(payload) | |
| } | |
| const ghApiMaybeJson = (endpoint) => { | |
| try { | |
| return ghApiJson(endpoint) | |
| } catch (error) { | |
| const detail = stringifyError(error) | |
| if (/HTTP 404|Not Found/i.test(detail)) { | |
| return null | |
| } | |
| throw new Error(`gh api ${endpoint} failed.\n${detail}`) | |
| } | |
| } | |
| const resolveRelease = () => { | |
| const direct = ghApiMaybeJson(`repos/${repo}/releases/tags/${tag}`) | |
| if (direct) { | |
| return direct | |
| } | |
| for (let page = 1; page <= 5; page += 1) { | |
| const releases = ghApiJson(`repos/${repo}/releases?per_page=100&page=${page}`) | |
| const match = releases.find((release) => release.tag_name === tag) | |
| if (match) { | |
| return match | |
| } | |
| if (releases.length < 100) { | |
| break | |
| } | |
| } | |
| return null | |
| } | |
| if (!fs.existsSync(publishListPath)) { | |
| throw new Error(`Expected publish asset list not found: ${publishListPath}`) | |
| } | |
| const localPaths = fs | |
| .readFileSync(publishListPath, 'utf8') | |
| .split(/\r?\n/) | |
| .map((value) => value.trim()) | |
| .filter(Boolean) | |
| if (localPaths.length === 0) { | |
| throw new Error('No expected publish assets were found for verification.') | |
| } | |
| const localAssets = localPaths.map((filePath) => { | |
| if (!fs.existsSync(filePath)) { | |
| throw new Error(`Expected local release asset missing: ${filePath}`) | |
| } | |
| const stat = fs.statSync(filePath) | |
| const digest = crypto | |
| .createHash('sha256') | |
| .update(fs.readFileSync(filePath)) | |
| .digest('hex') | |
| return { | |
| name: path.basename(filePath), | |
| size: stat.size, | |
| digest: `sha256:${digest}`, | |
| } | |
| }) | |
| const expectedByName = new Map(localAssets.map((asset) => [asset.name, asset])) | |
| const expectedNames = [...expectedByName.keys()].sort() | |
| const sleep = (seconds) => { | |
| childProcess.execFileSync('sleep', [String(seconds)], { stdio: 'inherit' }) | |
| } | |
| const validateRemoteAssets = (remoteAssets) => { | |
| const actualByName = new Map(remoteAssets.map((asset) => [asset.name, asset])) | |
| const actualNames = [...actualByName.keys()].sort() | |
| if (expectedNames.join('\n') !== actualNames.join('\n')) { | |
| throw new Error( | |
| `Published release asset names do not match expected assets.\nExpected: ${expectedNames.join(', ')}\nActual: ${actualNames.join(', ')}` | |
| ) | |
| } | |
| for (const expected of localAssets) { | |
| const actual = actualByName.get(expected.name) | |
| if (!actual) { | |
| throw new Error(`Published release asset missing: ${expected.name}`) | |
| } | |
| if (!actual.digest) { | |
| throw new Error(`Published release asset digest missing from GitHub API response: ${expected.name}`) | |
| } | |
| if (expected.size !== actual.size) { | |
| throw new Error( | |
| `Published release asset size mismatch for ${expected.name}. expected=${expected.size} actual=${actual.size}` | |
| ) | |
| } | |
| if (expected.digest !== actual.digest) { | |
| throw new Error( | |
| `Published release asset digest mismatch for ${expected.name}. expected=${expected.digest} actual=${actual.digest}` | |
| ) | |
| } | |
| } | |
| } | |
| let verifiedAssets = null | |
| let lastError = null | |
| let lastReleaseMeta = null | |
| let lastRemoteAssets = [] | |
| for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | |
| try { | |
| const release = resolveRelease() | |
| if (!release) { | |
| throw new Error(`Published release ${tag} is not visible through the release API yet.`) | |
| } | |
| lastReleaseMeta = { | |
| id: release.id, | |
| draft: Boolean(release.draft), | |
| prerelease: Boolean(release.prerelease), | |
| assetCount: Array.isArray(release.assets) ? release.assets.length : 0, | |
| } | |
| const remoteAssets = (release.assets || []).map((asset) => ({ | |
| name: asset.name, | |
| size: asset.size, | |
| digest: asset.digest || '', | |
| })) | |
| lastRemoteAssets = remoteAssets | |
| validateRemoteAssets(remoteAssets) | |
| verifiedAssets = remoteAssets | |
| lastError = null | |
| break | |
| } catch (error) { | |
| lastError = error | |
| if (attempt === maxAttempts) { | |
| break | |
| } | |
| console.log( | |
| `Release verification attempt ${attempt}/${maxAttempts} did not pass yet: ${error.message}` | |
| ) | |
| console.log(`Waiting ${sleepSeconds}s for GitHub release asset indexing to settle...`) | |
| sleep(sleepSeconds) | |
| } | |
| } | |
| if (lastError) { | |
| const diagnostics = [ | |
| `Release verification exhausted ${maxAttempts} attempts for ${tag}.`, | |
| lastReleaseMeta | |
| ? `Last visible release state: id=${lastReleaseMeta.id} draft=${lastReleaseMeta.draft} prerelease=${lastReleaseMeta.prerelease} assets=${lastReleaseMeta.assetCount}` | |
| : 'Last visible release state: release not resolved.', | |
| ] | |
| if (lastRemoteAssets.length > 0) { | |
| diagnostics.push( | |
| `Last remote asset inventory: ${lastRemoteAssets | |
| .map((asset) => `${asset.name} [size=${asset.size}, digest=${asset.digest || 'missing'}]`) | |
| .join('; ')}` | |
| ) | |
| } | |
| const diagnosticText = diagnostics.join('\n') | |
| console.error(diagnosticText) | |
| if (summaryPath) { | |
| fs.appendFileSync( | |
| summaryPath, | |
| [ | |
| '## Release Verification Failure', | |
| `- Tag: ${tag}`, | |
| `- Attempts: ${maxAttempts}`, | |
| `- Last error: ${lastError.message}`, | |
| `- Last release state: ${ | |
| lastReleaseMeta | |
| ? `id=${lastReleaseMeta.id} draft=${lastReleaseMeta.draft} prerelease=${lastReleaseMeta.prerelease} assets=${lastReleaseMeta.assetCount}` | |
| : 'release not resolved' | |
| }`, | |
| `- Last remote asset inventory: ${ | |
| lastRemoteAssets.length > 0 | |
| ? lastRemoteAssets | |
| .map((asset) => `${asset.name} [size=${asset.size}, digest=${asset.digest || 'missing'}]`) | |
| .join('; ') | |
| : 'none' | |
| }`, | |
| ].join('\n') + '\n' | |
| ) | |
| } | |
| throw lastError | |
| } | |
| if (summaryPath) { | |
| fs.appendFileSync( | |
| summaryPath, | |
| [ | |
| '## Release Verification', | |
| `- Tag: ${tag}`, | |
| `- Verified assets: ${verifiedAssets.length}`, | |
| '- Validation: name + size + sha256 digest', | |
| `- Retry window: up to ${maxAttempts * sleepSeconds} seconds for GitHub release indexing`, | |
| ].join('\n') + '\n' | |
| ) | |
| } | |
| NODE | |
| - name: Cleanup validation release | |
| if: ${{ startsWith(github.ref, 'refs/tags/validate-') && success() }} | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| retry_delete() { | |
| local attempt=1 | |
| local max_attempts=5 | |
| local sleep_seconds=10 | |
| while true; do | |
| if gh release delete "${GITHUB_REF_NAME}" --yes; then | |
| return 0 | |
| fi | |
| if [ "${attempt}" -ge "${max_attempts}" ]; then | |
| echo "Failed to delete validation release ${GITHUB_REF_NAME} after ${attempt} attempts." >&2 | |
| return 1 | |
| fi | |
| echo "Validation release delete failed on attempt ${attempt}/${max_attempts}; retrying in ${sleep_seconds}s..." >&2 | |
| sleep "${sleep_seconds}" | |
| attempt=$((attempt + 1)) | |
| done | |
| } | |
| retry_delete | |
| { | |
| echo "## Validation Cleanup" | |
| echo "- Deleted draft validation release: ${GITHUB_REF_NAME}" | |
| } >> "${GITHUB_STEP_SUMMARY}" |