name: SignPath Windows Release on: workflow_dispatch: permissions: actions: read contents: write jobs: resolve-releases: if: vars.SIGNPATH_ENABLED == 'true' name: Resolve releases runs-on: ubuntu-latest outputs: releases: ${{ steps.releases.outputs.releases }} steps: - name: Resolve latest stable and prerelease id: releases env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail releases=$(gh release list \ -R "${{ github.repository }}" \ --exclude-drafts \ --limit 50 \ --json tagName,isPrerelease,publishedAt) targets=$(jq -c ' map(select(.tagName | test("^v[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.]+)?$"))) | [ (map(select(.isPrerelease == false)) | sort_by(.publishedAt) | reverse | first | select(. != null) | { tag: .tagName, version: (.tagName | sub("^v"; "")), channel: "stable" }), (map(select(.isPrerelease == true)) | sort_by(.publishedAt) | reverse | first | select(. != null) | { tag: .tagName, version: (.tagName | sub("^v"; "")), channel: "prerelease" }) ] ' <<< "$releases") if [ "$(jq 'length' <<< "$targets")" -eq 0 ]; then echo "No published releases found." >&2 exit 1 fi echo "releases=$targets" >> "$GITHUB_OUTPUT" echo "Resolved releases: $targets" sign: name: Sign Windows installers needs: resolve-releases runs-on: windows-2025-vs2026 steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install Node dependencies run: pnpm install --frozen-lockfile - name: Prepare signing bundle shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASES_JSON: ${{ needs.resolve-releases.outputs.releases }} run: | $ErrorActionPreference = "Stop" New-Item -ItemType Directory -Force -Path signpath-input, unsigned | Out-Null $releases = $env:RELEASES_JSON | ConvertFrom-Json $installers = @( @{ Arch = "x64"; Suffix = "x64-setup.exe" }, @{ Arch = "arm64"; Suffix = "arm64-setup.exe" } ) $manifest = @() $skipped = @() foreach ($release in $releases) { foreach ($installer in $installers) { $asset = "Rayburst_$($release.version)_$($installer.Suffix)" $targetDir = Join-Path "unsigned" "$($release.channel)-$($installer.Arch)" New-Item -ItemType Directory -Force -Path $targetDir | Out-Null gh release download $release.tag -R $env:GITHUB_REPOSITORY -p $asset -D $targetDir 2>$null if ($LASTEXITCODE -eq 0 -and (Test-Path (Join-Path $targetDir $asset))) { Copy-Item (Join-Path $targetDir $asset) (Join-Path "signpath-input" $asset) $manifest += [pscustomobject]@{ tag = $release.tag version = $release.version channel = $release.channel arch = $installer.Arch asset = $asset } } else { $skipped += [pscustomobject]@{ tag = $release.tag channel = $release.channel arch = $installer.Arch asset = $asset reason = "release asset not found" } } } } if ($manifest.Count -eq 0) { throw "No Windows installer assets were found in the resolved releases." } [System.IO.File]::WriteAllText( (Join-Path $PWD "signing-manifest.json"), (($manifest | ConvertTo-Json -Depth 4 -Compress) + "`n"), [System.Text.UTF8Encoding]::new($false) ) [System.IO.File]::WriteAllText( (Join-Path $PWD "signing-skipped.json"), (($skipped | ConvertTo-Json -Depth 4 -Compress) + "`n"), [System.Text.UTF8Encoding]::new($false) ) "Prepared $($manifest.Count) installer(s) for one SignPath request." if ($skipped.Count -gt 0) { "Skipped $($skipped.Count) missing installer asset(s)." } - name: Upload unsigned installers id: unsigned-artifact uses: actions/upload-artifact@v7 with: name: signpath-windows-release-unsigned-${{ github.run_id }}-${{ github.run_attempt }} path: signpath-input/*.exe if-no-files-found: error retention-days: 1 - name: Submit SignPath request id: signpath timeout-minutes: 180 uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} signing-policy-slug: release-signing artifact-configuration-slug: ${{ vars.SIGNPATH_RELEASE_ARTIFACT_CONFIGURATION_SLUG }} github-artifact-id: ${{ steps.unsigned-artifact.outputs.artifact-id }} wait-for-completion: true wait-for-completion-timeout-in-seconds: 10800 output-artifact-directory: signed - name: Verify signed installers shell: pwsh run: | $ErrorActionPreference = "Stop" $manifest = Get-Content signing-manifest.json -Raw | ConvertFrom-Json New-Item -ItemType Directory -Force -Path signed-flat | Out-Null foreach ($item in $manifest) { $signedFile = Get-ChildItem signed -Recurse -Filter $item.asset | Select-Object -First 1 if (-not $signedFile) { throw "Signed installer not found: $($item.asset)" } $signature = Get-AuthenticodeSignature -FilePath $signedFile.FullName if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate) { throw "Invalid Authenticode signature: $($item.asset) ($($signature.Status))" } $expectedSubject = "CN=SignPath Foundation" if (-not $signature.SignerCertificate.Subject.Contains($expectedSubject)) { throw "Unexpected signer subject for $($item.asset): $($signature.SignerCertificate.Subject)" } Copy-Item $signedFile.FullName (Join-Path "signed-flat" $item.asset) "Verified $($item.asset): $($signature.SignerCertificate.Subject)" } "Signing request: ${{ steps.signpath.outputs.signing-request-web-url }}" - name: Generate updater signatures shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail jq -r '.[].asset' signing-manifest.json | while IFS= read -r asset; do asset="${asset%$'\r'}" cp "signed-flat/$asset" "$asset" signer_output=$(pnpm --silent tauri signer sign "$asset") signature=$(awk '/^Public signature:$/ { getline; print; exit }' <<< "$signer_output") if [ -z "$signature" ]; then echo "Failed to extract public updater signature for $asset" >&2 exit 1 fi printf '%s' "$signature" > "$asset.sig" done - name: Upload patched release assets shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail upload_and_replace() { local tag="$1" local file="$2" local tmp_name="$file.signed-tmp-${{ github.run_id }}-${{ github.run_attempt }}" cp "$file" "$tmp_name" gh release upload "$tag" "$tmp_name" -R "$GITHUB_REPOSITORY" local release_id old_asset_id tmp_asset_id release_id=$(gh release view "$tag" -R "$GITHUB_REPOSITORY" --json databaseId -q .databaseId) old_asset_id=$(gh api "repos/$GITHUB_REPOSITORY/releases/$release_id/assets" \ --paginate \ --jq ".[] | select(.name == \"$file\") | .id" | head -n 1) tmp_asset_id=$(gh api "repos/$GITHUB_REPOSITORY/releases/$release_id/assets" \ --paginate \ --jq ".[] | select(.name == \"$tmp_name\") | .id" | head -n 1) if [ -z "$tmp_asset_id" ]; then echo "Temporary asset not found: $tmp_name" >&2 exit 1 fi if [ -n "$old_asset_id" ]; then gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$old_asset_id" --silent fi gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/assets/$tmp_asset_id" \ -f "name=$file" \ -f "label=$file" \ --silent } jq -c '.[]' signing-manifest.json | while IFS= read -r item; do tag=$(jq -r '.tag' <<< "$item") asset=$(jq -r '.asset' <<< "$item") tag="${tag%$'\r'}" asset="${asset%$'\r'}" upload_and_replace "$tag" "$asset" upload_and_replace "$tag" "$asset.sig" done - name: Refresh updater JSON shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail jq -r '[.[].tag] | unique[]' signing-manifest.json | while IFS= read -r tag; do tag="${tag%$'\r'}" ./.github/scripts/refresh-updater-json.sh "$tag" done - name: Write signing report if: always() shell: bash env: SIGNING_REQUEST_URL: ${{ steps.signpath.outputs.signing-request-web-url }} RELEASES_JSON: ${{ needs.resolve-releases.outputs.releases }} run: | set -euo pipefail { echo "## SignPath Windows Release" echo echo "**Signing request:** ${SIGNING_REQUEST_URL:-not created}" echo echo "### Resolved releases" echo echo "| Channel | Tag |" echo "| --- | --- |" jq -r '.[] | "| \(.channel) | \(.tag) |"' <<< "$RELEASES_JSON" if [ -f signing-manifest.json ]; then echo echo "### Signed assets" echo echo "| Channel | Tag | Architecture | Asset |" echo "| --- | --- | --- | --- |" jq -r '.[] | "| \(.channel) | \(.tag) | \(.arch) | `\(.asset)` |"' signing-manifest.json fi if [ -f signing-skipped.json ] && [ "$(jq 'length' signing-skipped.json)" -gt 0 ]; then echo echo "### Skipped assets" echo echo "| Channel | Tag | Architecture | Asset | Reason |" echo "| --- | --- | --- | --- | --- |" jq -r '.[] | "| \(.channel) | \(.tag) | \(.arch) | `\(.asset)` | \(.reason) |"' signing-skipped.json fi } >> "$GITHUB_STEP_SUMMARY"