name: Release (build, sign, publish auto-update) # Triggered when you publish a (non-prerelease) GitHub Release. The job builds # the signed NSIS installer, generates the tauri-updater `latest.json`, attaches # both to that release, then submits the winget update. Existing installs poll # `releases/latest/download/latest.json` and offer the one-click update. # # Required repo secrets: # TAURI_SIGNING_PRIVATE_KEY contents of src-tauri/.tauri/voicewave-updater.key # TAURI_SIGNING_PRIVATE_KEY_PASSWORD the key password ("" if generated with --ci) # WINGET classic PAT with public_repo (for winget-pkgs PR) # # Optional repo secrets (Windows Authenticode signing via Azure Trusted Signing; # the build is unsigned and these steps are skipped unless ALL six are set; # setup in docs/code-signing.md): # AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET service principal # AZURE_TRUSTED_SIGNING_ENDPOINT e.g. https://eus.codesigning.azure.net # AZURE_TRUSTED_SIGNING_ACCOUNT Trusted Signing account name # AZURE_TRUSTED_SIGNING_PROFILE certificate profile name on: release: types: [released] workflow_dispatch: inputs: tag: description: "Release tag to (re)build, e.g. v0.4.0" required: true type: string concurrency: group: release-${{ github.event.inputs.tag || github.event.release.tag_name }} cancel-in-progress: false jobs: build-release: runs-on: windows-latest permissions: contents: write env: TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }} VIBE_SAFE_TARGET_DIR: C:\vw-target GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VITE_TELEMETRYDECK_APP_ID: ${{ secrets.TELEMETRYDECK_APP_ID }} steps: - name: Checkout at release tag uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.tag || github.event.release.tag_name }} - name: Derive version from tag id: ver shell: pwsh run: | $version = "$env:TAG" -replace '^v', '' "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Building version $version from tag $env:TAG" - name: Verify source versions match release tag shell: pwsh run: | $expected = "${{ steps.ver.outputs.version }}" $tauri = (Get-Content "src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json).version $package = (Get-Content "package.json" -Raw | ConvertFrom-Json).version $cargo = (Select-String -Path "src-tauri/Cargo.toml" -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value $versions = @{ "tauri.conf.json" = $tauri; "package.json" = $package; "Cargo.toml" = $cargo } $mismatches = @($versions.GetEnumerator() | Where-Object { $_.Value -ne $expected }) if ($mismatches.Count -gt 0) { $actual = ($mismatches | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' throw "Release tag v$expected does not match source versions: $actual" } Write-Host "Release source versions match v$expected." - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - name: Setup Rust (GNU toolchain) uses: dtolnay/rust-toolchain@stable with: targets: x86_64-pc-windows-gnu - name: Install MinGW toolchain (gcc, make, ninja) shell: pwsh run: | # windows-latest ships MSYS2 at C:\msys64. Install the GNU toolchain # into the known C:\msys64\mingw64 prefix so VOICEWAVE_MINGW_BIN below # points at a deterministic path containing gcc + the runtime DLLs. & C:\msys64\usr\bin\pacman.exe -S --noconfirm --needed ` mingw-w64-x86_64-gcc mingw-w64-x86_64-make mingw-w64-x86_64-ninja if ($LASTEXITCODE -ne 0) { throw "pacman toolchain install failed" } if (-not (Test-Path "C:\msys64\mingw64\bin\gcc.exe")) { throw "gcc not found after install" } - name: Install NSIS run: choco install nsis -y --no-progress - name: Install npm dependencies run: npm install --no-audit --no-fund - name: Cache embedded faster-whisper runtime id: cache-fw-runtime uses: actions/cache@v4 with: path: src-tauri/windows/faster-whisper/python # Rebuild only when the runtime recipe changes. The download + # pip install (~400 MB) is otherwise reused across release runs. # The wheelhouse MUST be part of the key: the llama-cpp-python wheel # is baked into the runtime, so swapping the wheel without touching # the script would otherwise restore a stale runtime forever. The # prune script is part of the key for the same reason: it decides # what the cached (already pruned) tree contains. key: fw-runtime-${{ runner.os }}-${{ hashFiles('scripts/faster_whisper/embedded-runtime-requirements.txt', 'scripts/faster_whisper/build-embedded-runtime.ps1', 'scripts/faster_whisper/prune-embedded-runtime.ps1', 'scripts/llm-polish/wheelhouse/*.whl') }} - name: Build embedded faster-whisper runtime (CPU) if: steps.cache-fw-runtime.outputs.cache-hit != 'true' shell: pwsh run: ./scripts/faster_whisper/build-embedded-runtime.ps1 - name: Verify embedded runtime is present shell: pwsh run: | # The bundle step (beforeBundleCommand) maps this tree into the # installer at /faster-whisper/python. If it is missing, # a fresh install cannot transcribe the default fw-small.en model. $py = "src-tauri/windows/faster-whisper/python/python.exe" if (-not (Test-Path $py)) { throw "Embedded faster-whisper runtime missing at $py" } # Both ASR (faster-whisper) and AI polish (llama_cpp) run on this one # bundled runtime, so verify both import before we build the installer. & $py -c "from faster_whisper import WhisperModel; import ctranslate2; from llama_cpp import Llama; print('embedded runtime OK (faster-whisper + llama_cpp)')" if ($LASTEXITCODE -ne 0) { throw "Embedded runtime import check failed" } if (-not (Test-Path "src-tauri/windows/llm-polish/polish_worker.py")) { throw "AI-polish worker script missing at src-tauri/windows/llm-polish/polish_worker.py" } # whisper.cpp speech workers (plan 019): built with MSVC, independent of # the app's GNU Rust toolchain, and bundled as resources # (asr-worker/vk, asr-worker/cpu). Only this build machine needs the # Vulkan SDK; users need nothing beyond their GPU driver. - name: Install Vulkan SDK (pinned, cached) # v1.7.0, pinned by commit SHA. uses: jakoch/install-vulkan-sdk-action@d17de7e2f5e50efb5d2123efed18497d3c58c533 with: vulkan_version: 1.4.341.1 install_runtime: false cache: true stripdown: true - name: Build speech workers (MSVC) shell: pwsh run: ./scripts/asr-worker/build-asr-worker.ps1 -BuildRoot "$env:RUNNER_TEMP\asr-worker-build" - name: Verify speech worker dependencies (dumpbin) shell: pwsh # Fails if the CPU worker imports vulkan-1.dll, the Vulkan worker # imports it at load time instead of delay-loading it, or either one # imports a VC++ runtime DLL (they must run without the redistributable). run: ./scripts/asr-worker/check-asr-worker-deps.ps1 - name: Smoke test speech workers (no GPU on the runner) shell: pwsh run: | # Pinned tiny model, only to prove the CPU worker loads and decodes here. $model = Join-Path $env:RUNNER_TEMP "ggml-tiny.en.bin" Invoke-WebRequest -UseBasicParsing -OutFile $model ` -Uri "https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.en.bin" $hash = (Get-FileHash -Algorithm SHA256 $model).Hash.ToLowerInvariant() if ($hash -ne "921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f") { throw "ggml-tiny.en.bin hash mismatch: $hash" } ./scripts/asr-worker/smoke-asr-worker.ps1 -ModelPath $model # Windows Authenticode signing (separate from the Tauri updater key). # A no-op until every Azure Trusted Signing secret exists; see # docs/code-signing.md. When enabled, a Tauri config override sets # bundle.windows.signCommand, so Tauri signs the app exe and the NSIS # installer, and computes the updater .sig over the final SIGNED installer. - name: Configure code signing (skipped without Azure secrets) id: codesign shell: pwsh env: AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} AZURE_TRUSTED_SIGNING_ACCOUNT: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT }} AZURE_TRUSTED_SIGNING_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_PROFILE }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | $required = "AZURE_TRUSTED_SIGNING_ENDPOINT", "AZURE_TRUSTED_SIGNING_ACCOUNT", "AZURE_TRUSTED_SIGNING_PROFILE", "AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET" $missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) if ($missing.Count -gt 0) { Write-Host "Code signing not configured (missing: $($missing -join ', ')). Building an UNSIGNED installer." "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append return } # trusted-signing-cli authenticates from AZURE_TENANT_ID/CLIENT_ID/CLIENT_SECRET. cargo install trusted-signing-cli --locked if ($LASTEXITCODE -ne 0) { throw "trusted-signing-cli install failed" } $signCommand = "trusted-signing-cli -e $env:AZURE_TRUSTED_SIGNING_ENDPOINT -a $env:AZURE_TRUSTED_SIGNING_ACCOUNT -c $env:AZURE_TRUSTED_SIGNING_PROFILE -d VoiceWave %1" $config = Join-Path $env:RUNNER_TEMP "tauri.signing.conf.json" @{ bundle = @{ windows = @{ signCommand = $signCommand } } } | ConvertTo-Json -Depth 5 | Set-Content -Path $config -Encoding utf8 "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "config=$config" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Code signing enabled via Azure Trusted Signing." - name: Build installer + updater artifacts shell: pwsh env: # The wrapper prepends this dir to PATH so the bundle step finds the # MinGW runtime DLLs (libstdc++, libgcc, libwinpthread) reliably. VOICEWAVE_MINGW_BIN: C:\msys64\mingw64\bin TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # Read by trusted-signing-cli when Tauri invokes signCommand. AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} SIGNING_CONFIG: ${{ steps.codesign.outputs.config }} run: | $signingArgs = @() if ($env:SIGNING_CONFIG) { $signingArgs = @("--config", $env:SIGNING_CONFIG) } npm run tauri:build -- --bundles nsis @signingArgs if ($LASTEXITCODE -ne 0) { throw "tauri build failed" } - name: Verify installer is Authenticode-signed if: steps.codesign.outputs.enabled == 'true' shell: pwsh run: | $exe = Get-ChildItem -Path "$env:VIBE_SAFE_TARGET_DIR" -Recurse -Filter "*_x64-setup.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $sig = Get-AuthenticodeSignature -FilePath $exe.FullName if ($sig.Status -ne "Valid") { throw "Installer signature is '$($sig.Status)', expected Valid: $($exe.FullName)" } Write-Host "Installer signed by: $($sig.SignerCertificate.Subject)" - name: Verify bundled C++ runtime shell: pwsh run: | $dll = "src-tauri/windows/libstdc++-6.dll" if (-not (Test-Path $dll)) { throw "Bundled libstdc++-6.dll missing at $dll" } $text = [System.Text.Encoding]::ASCII.GetString([System.IO.File]::ReadAllBytes($dll)) if ($text -notmatch "do_unshiftERiPcS2") { throw "Bundled libstdc++-6.dll lacks modern std::codecvt symbols — a stale/wrong MinGW runtime was bundled. The app would fail at launch with 'Entry Point Not Found'." } Write-Host "Bundled libstdc++-6.dll OK (has modern std::codecvt symbols)." - name: Verify embedded runtime was packaged into build output shell: pwsh run: | # Tauri copies bundle.resources next to the compiled binary in the # target dir (so both dev and the installer resolve them). If the # resources map for the python tree silently copied nothing, this # catches it here instead of shipping an installer whose fresh # users still cannot transcribe. $roots = @( "$env:VIBE_SAFE_TARGET_DIR\x86_64-pc-windows-gnu\release\faster-whisper\python\python.exe", "$env:VIBE_SAFE_TARGET_DIR\release\faster-whisper\python\python.exe" ) $found = $roots | Where-Object { Test-Path $_ } | Select-Object -First 1 if (-not $found) { throw "Embedded python runtime was NOT packaged into the build output. Checked: $($roots -join '; '). The bundle.resources map for 'windows/faster-whisper/python' did not resolve." } Write-Host "Embedded runtime packaged at: $found" # The AI-polish worker rides next to the runtime; confirm it packaged too. $workerRoots = @( "$env:VIBE_SAFE_TARGET_DIR\x86_64-pc-windows-gnu\release\llm-polish\polish_worker.py", "$env:VIBE_SAFE_TARGET_DIR\release\llm-polish\polish_worker.py" ) if (-not ($workerRoots | Where-Object { Test-Path $_ })) { throw "AI-polish worker was NOT packaged into the build output. Checked: $($workerRoots -join '; ')." } Write-Host "AI-polish worker packaged OK." # Speech workers, each in its own directory, plus the qualification clips. foreach ($worker in "vk\voicewave-asr-vk.exe", "cpu\voicewave-asr-cpu.exe", "qualify\short.wav", "qualify\long.wav") { $asrRoots = @( "$env:VIBE_SAFE_TARGET_DIR\x86_64-pc-windows-gnu\release\asr-worker\$worker", "$env:VIBE_SAFE_TARGET_DIR\release\asr-worker\$worker" ) if (-not ($asrRoots | Where-Object { Test-Path $_ })) { throw "Speech worker was NOT packaged into the build output. Checked: $($asrRoots -join '; ')." } } Write-Host "Speech workers packaged OK." - name: Locate installer + signature id: artifact shell: pwsh run: | $exe = Get-ChildItem -Path "$env:VIBE_SAFE_TARGET_DIR" -Recurse -Filter "*_x64-setup.exe" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $exe) { throw "No *_x64-setup.exe found under $env:VIBE_SAFE_TARGET_DIR" } $sig = "$($exe.FullName).sig" if (-not (Test-Path $sig)) { throw "Signature not found: $sig. Was TAURI_SIGNING_PRIVATE_KEY set?" } $version = "${{ steps.ver.outputs.version }}" if ($exe.Name -notmatch [regex]::Escape($version)) { throw "Installer '$($exe.Name)' does not match tag version '$version'. Bump version in tauri.conf.json / Cargo.toml / package.json to match the tag." } # Tauri names the NSIS output from the product name, which contains # spaces ("VoiceWave Local Core_..."). Normalize spaces -> dots so the # published asset matches the winget installers-regex and the website # download URL, and avoids %20 in the link. Renaming does not affect # the signature (it is over the file bytes, which are unchanged). $cleanName = ($exe.Name -replace ' ', '.') $cleanExe = Join-Path $exe.DirectoryName $cleanName if ($cleanExe -ne $exe.FullName) { Move-Item -Force -LiteralPath $exe.FullName -Destination $cleanExe Move-Item -Force -LiteralPath $sig -Destination "$cleanExe.sig" } "exe=$cleanExe" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Installer (normalized): $cleanExe" - name: Generate latest.json shell: pwsh run: | $notes = gh release view "$env:TAG" --json body -q .body if ([string]::IsNullOrWhiteSpace($notes)) { $notes = "VoiceWave ${{ steps.ver.outputs.version }}" } ./scripts/release/generate-latest-json.ps1 ` -Version "${{ steps.ver.outputs.version }}" ` -InstallerPath "${{ steps.artifact.outputs.exe }}" ` -Notes "$notes" - name: Attach installer + latest.json to the release shell: pwsh run: gh release upload "$env:TAG" "${{ steps.artifact.outputs.exe }}" "latest.json" --clobber publish-winget: needs: build-release runs-on: windows-latest steps: - name: Derive winget version from tag id: ver shell: pwsh run: | # winget package versions carry no "v" prefix (the existing # VoiceWave.LocalCore manifest is versioned 0.3.1, not v0.3.1). $version = "${{ github.event.inputs.tag || github.event.release.tag_name }}" -replace '^v', '' "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Preflight — validate WINGET PAT (fail loudly) shell: pwsh env: WINGET_TOKEN: ${{ secrets.WINGET }} run: | # A missing/expired/under-scoped WINGET PAT is exactly how winget # publishing rotted silently across releases 0.4.x-0.5.x. Catch it # here with a hard, actionable failure BEFORE the submit step, so this # job turns red (and emails on failure) instead of appearing to pass. $token = $env:WINGET_TOKEN if ([string]::IsNullOrWhiteSpace($token)) { throw "The 'WINGET' repository secret is empty or unset. Create a CLASSIC PAT with the 'public_repo' scope and save it as the 'WINGET' Actions secret (repo Settings > Secrets and variables > Actions)." } $headers = @{ Authorization = "token $token"; "User-Agent" = "voicewave-release"; Accept = "application/vnd.github+json" } try { $resp = Invoke-WebRequest -Uri "https://api.github.com/user" -Headers $headers -Method Get -UseBasicParsing } catch { throw "The 'WINGET' PAT failed to authenticate against the GitHub API (likely expired or invalid). Regenerate a CLASSIC PAT with 'public_repo' and update the 'WINGET' secret. Details: $($_.Exception.Message)" } $login = ($resp.Content | ConvertFrom-Json).login $scopes = ($resp.Headers["X-OAuth-Scopes"] -join ', ') Write-Host "WINGET PAT authenticated as '$login'. Scopes: '$scopes'" if ($scopes -notmatch '(?:^|,\s*)(?:public_repo|repo)(?:\s*,|$)') { throw "The 'WINGET' PAT is missing the 'public_repo' (or 'repo') scope required to fork microsoft/winget-pkgs and open a PR. Current scopes: '$scopes'. Regenerate a CLASSIC PAT with 'public_repo' and update the 'WINGET' secret." } Write-Host "WINGET PAT preflight OK." - name: Submit manifest to winget-pkgs uses: vedantmgoyal9/winget-releaser@v2 with: identifier: VoiceWave.LocalCore installers-regex: 'VoiceWave\.Local\.Core_.*_x64-setup\.exe$' version: ${{ steps.ver.outputs.version }} release-tag: ${{ github.event.inputs.tag || github.event.release.tag_name }} token: ${{ secrets.WINGET }}