feat: support gender filtering for SecAgent draws #231
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: Build & Publish | |
| on: | |
| push: | |
| branches: | |
| - '**' | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: "发布标签(例如 v2.3.100)" | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| jobs: | |
| build_desktop: | |
| name: Build_${{ matrix.os }}_${{ matrix.arch }} | |
| runs-on: ${{ matrix.os == 'linux' && 'ubuntu-24.04' || (matrix.os == 'macos' && 'macos-15' || 'windows-2022') }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [windows, linux, macos] | |
| arch: [x64, x86, arm64] | |
| exclude: | |
| - os: linux | |
| arch: x86 | |
| - os: macos | |
| arch: x86 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }} | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Restore | |
| shell: pwsh | |
| env: | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| PLATFORM: ${{ matrix.os == 'windows' && 'Windows' || (matrix.os == 'linux' && 'Linux' || 'MacOs') }} | |
| run: | | |
| dotnet restore SecRandom.Desktop/SecRandom.Desktop.csproj -r $env:RID -p:SecRandomPlatform=$env:PLATFORM | |
| dotnet restore SecRandom.Launcher/SecRandom.Launcher.csproj -r $env:RID | |
| - name: Get Latest Tag | |
| id: get_tag | |
| if: ${{ github.event_name != 'workflow_dispatch' }} | |
| shell: pwsh | |
| run: | | |
| $latestTag = git describe --tags --abbrev=0 | |
| echo "LATEST_TAG=$latestTag" >> $env:GITHUB_OUTPUT | |
| echo "Latest tag: $latestTag" | |
| - name: Publish Desktop | |
| shell: pwsh | |
| env: | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| PLATFORM: ${{ matrix.os == 'windows' && 'Windows' || (matrix.os == 'linux' && 'Linux' || 'MacOs') }} | |
| run: | | |
| $releaseRoot = 'artifacts/release' | |
| $fullDir = "$releaseRoot/publish/full/$env:RID" | |
| $lightDir = "$releaseRoot/publish/light/$env:RID" | |
| New-Item -ItemType Directory -Path $fullDir, $lightDir -Force | Out-Null | |
| $publishArguments = @( | |
| 'publish', 'SecRandom.Desktop/SecRandom.Desktop.csproj', | |
| '-c', 'Release', | |
| '-r', $env:RID, | |
| "-p:SecRandomPlatform=$env:PLATFORM", | |
| '-p:PublishTrimmed=false', | |
| '-p:UseAppHost=true', | |
| '-p:DebugType=None', | |
| '-p:DebugSymbols=false', | |
| '-p:BuildInParallel=false', | |
| '-p:UseSharedCompilation=false', | |
| '--no-restore' | |
| ) | |
| & dotnet @publishArguments --self-contained -o $fullDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Full desktop publish failed with exit code $LASTEXITCODE." | |
| } | |
| & dotnet @publishArguments --self-contained false -o $lightDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Light desktop publish failed with exit code $LASTEXITCODE." | |
| } | |
| - name: Verify bundled audio runtime | |
| shell: pwsh | |
| env: | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| run: | | |
| $nativeAsset = if ($env:RID.StartsWith('win-')) { 'miniaudio.dll' } elseif ($env:RID.StartsWith('linux-')) { 'libminiaudio.so' } else { 'libminiaudio.dylib' } | |
| $assetPath = Join-Path "artifacts/release/publish/full/$env:RID" $nativeAsset | |
| if (!(Test-Path -LiteralPath $assetPath)) { | |
| throw "SoundFlow native audio runtime was not published: $assetPath" | |
| } | |
| - name: Pack Portable Zips (Windows) | |
| if: ${{ matrix.os == 'windows' }} | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }} | |
| WINDOWS_CODESIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PFX_PASSWORD }} | |
| WINDOWS_CODESIGN_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODESIGN_TIMESTAMP_URL }} | |
| run: | | |
| function Write-Marker([string]$path, [string]$runtimeKind) { | |
| @{ schemaVersion = 1; product = 'SecRandom'; rid = $env:RID; packageKind = 'portable-zip'; runtimeKind = $runtimeKind } | | |
| ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $path 'SecRandom.package.json') -Encoding utf8NoBOM | |
| } | |
| function Publish-Launcher([string]$path, [bool]$selfContained) { | |
| $assemblyVersion = $env:VERSION.TrimStart('v', 'V') | |
| $arguments = @('publish', 'SecRandom.Launcher/SecRandom.Launcher.csproj', '-c', 'Release', '-r', $env:RID, '-p:UseAppHost=true', '-p:PublishSingleFile=true', '-p:IncludeNativeLibrariesForSelfExtract=true', '-p:DebugType=None', '-p:DebugSymbols=false', "-p:Version=$assemblyVersion", '--no-restore', '-o', $path) | |
| if ($selfContained) { $arguments += '--self-contained' } else { $arguments += @('--self-contained', 'false') } | |
| & dotnet @arguments | |
| if ($LASTEXITCODE -ne 0) { throw "Launcher publish failed with exit code $LASTEXITCODE." } | |
| } | |
| function Sign-File([string]$path, [string]$signTool, [string]$certificatePath) { | |
| $arguments = @('sign', '/fd', 'SHA256', '/f', $certificatePath, '/p', $env:WINDOWS_CODESIGN_PFX_PASSWORD) | |
| if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_TIMESTAMP_URL)) { | |
| $arguments += @('/tr', $env:WINDOWS_CODESIGN_TIMESTAMP_URL, '/td', 'SHA256') | |
| } | |
| $arguments += $path | |
| & $signTool @arguments | |
| if ($LASTEXITCODE -ne 0) { throw "signtool failed for $path with exit code $LASTEXITCODE." } | |
| } | |
| $releaseRoot = 'artifacts/release' | |
| $zipRoot = "$releaseRoot/portable/$env:RID" | |
| $fullRoot = "$zipRoot/full" | |
| $lightRoot = "$zipRoot/light" | |
| Remove-Item $zipRoot -Recurse -Force -ErrorAction SilentlyContinue | |
| New-Item -ItemType Directory -Path $fullRoot, $lightRoot, "$releaseRoot/dist" -Force | Out-Null | |
| $fullApp = Join-Path $fullRoot "app-$env:VERSION-0" | |
| $lightApp = Join-Path $lightRoot "app-$env:VERSION-0" | |
| Copy-Item "$releaseRoot/publish/full/$env:RID" $fullApp -Recurse | |
| Copy-Item "$releaseRoot/publish/light/$env:RID" $lightApp -Recurse | |
| Write-Marker $fullApp 'self-contained' | |
| Write-Marker $lightApp 'framework-dependent' | |
| Publish-Launcher $fullRoot $true | |
| Publish-Launcher $lightRoot $false | |
| if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_BASE64) -and -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_PASSWORD)) { | |
| $certificatePath = Join-Path $env:RUNNER_TEMP 'secrandom-portable.pfx' | |
| try { | |
| [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String(($env:WINDOWS_CODESIGN_PFX_BASE64 -replace '[^A-Za-z0-9+/=]', ''))) | |
| $signTool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName | |
| if ([string]::IsNullOrWhiteSpace($signTool)) { throw 'signtool.exe was not found on the Windows runner.' } | |
| foreach ($file in @( | |
| (Join-Path $fullRoot 'SecRandomLauncher.exe'), | |
| (Join-Path $lightRoot 'SecRandomLauncher.exe'), | |
| (Join-Path $fullApp 'SecRandom.Desktop.exe'), | |
| (Join-Path $lightApp 'SecRandom.Desktop.exe') | |
| )) { | |
| Sign-File $file $signTool $certificatePath | |
| } | |
| } | |
| finally { | |
| Remove-Item $certificatePath -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| else { | |
| Write-Warning 'Windows code-signing secrets are unavailable; portable executables will be unsigned.' | |
| } | |
| Compress-Archive -Path "$fullRoot/*" -DestinationPath "$releaseRoot/dist/SecRandom-$env:VERSION-$env:RID-portable-full.zip" -Force | |
| Compress-Archive -Path "$lightRoot/*" -DestinationPath "$releaseRoot/dist/SecRandom-$env:VERSION-$env:RID-portable-light.zip" -Force | |
| - name: Pack Portable Zips (Unix) | |
| if: ${{ matrix.os != 'windows' }} | |
| shell: bash | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| run: | | |
| set -euo pipefail | |
| release_root="artifacts/release" | |
| zip_root="${release_root}/portable/${RID}" | |
| full_root="${zip_root}/full" | |
| light_root="${zip_root}/light" | |
| rm -rf "$zip_root" | |
| mkdir -p "$full_root" "$light_root" "${release_root}/dist" | |
| full_app="$full_root/app-${VERSION}-0" | |
| light_app="$light_root/app-${VERSION}-0" | |
| assembly_version="${VERSION#v}" | |
| cp -a "${release_root}/publish/full/${RID}" "$full_app" | |
| cp -a "${release_root}/publish/light/${RID}" "$light_app" | |
| printf '{"schemaVersion":1,"product":"SecRandom","rid":"%s","packageKind":"portable-zip","runtimeKind":"self-contained"}\n' "$RID" > "$full_app/SecRandom.package.json" | |
| printf '{"schemaVersion":1,"product":"SecRandom","rid":"%s","packageKind":"portable-zip","runtimeKind":"framework-dependent"}\n' "$RID" > "$light_app/SecRandom.package.json" | |
| dotnet publish SecRandom.Launcher/SecRandom.Launcher.csproj -c Release -r "$RID" --self-contained -p:UseAppHost=true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:Version="$assembly_version" --no-restore -o "$full_root" | |
| dotnet publish SecRandom.Launcher/SecRandom.Launcher.csproj -c Release -r "$RID" --self-contained false -p:UseAppHost=true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -p:Version="$assembly_version" --no-restore -o "$light_root" | |
| (cd "$full_root" && zip -qr "../../../dist/SecRandom-${VERSION}-${RID}-portable-full.zip" .) | |
| (cd "$light_root" && zip -qr "../../../dist/SecRandom-${VERSION}-${RID}-portable-light.zip" .) | |
| - name: Install Inno Setup Languages | |
| if: ${{ matrix.os == 'windows' }} | |
| shell: pwsh | |
| run: | | |
| $langDir = "C:\Program Files (x86)\Inno Setup 6\Languages" | |
| if (!(Test-Path $langDir)) { | |
| New-Item -ItemType Directory -Path $langDir -Force | |
| } | |
| $languages = @{ | |
| 'ChineseSimplified.isl' = 'https://raw.githubusercontent.com/kira-96/Inno-Setup-Chinese-Simplified-Translation/main/ChineseSimplified.isl' | |
| 'Japanese.isl' = 'https://raw.githubusercontent.com/jrsoftware/issrc/main/Files/Languages/Japanese.isl' | |
| } | |
| foreach ($language in $languages.GetEnumerator()) { | |
| $path = Join-Path $langDir $language.Key | |
| Invoke-WebRequest -Uri $language.Value -OutFile $path | |
| Write-Host "Downloaded Inno Setup language: $($language.Key)" | |
| } | |
| - name: Build Windows Setup | |
| if: ${{ matrix.os == 'windows' }} | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| ARCH: ${{ matrix.arch }} | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| PLATFORM: Windows | |
| WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }} | |
| WINDOWS_CODESIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PFX_PASSWORD }} | |
| WINDOWS_CODESIGN_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODESIGN_TIMESTAMP_URL }} | |
| run: | | |
| $uiAccessBuild = -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_BASE64) -and -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_PASSWORD) | |
| if ($uiAccessBuild) { | |
| $outDir = "artifacts/release/installer/$env:RID" | |
| Remove-Item $outDir -Recurse -Force -ErrorAction SilentlyContinue | |
| New-Item -ItemType Directory -Path $outDir -Force | Out-Null | |
| & dotnet publish SecRandom.Desktop/SecRandom.Desktop.csproj ` | |
| -c Release ` | |
| -r $env:RID ` | |
| -p:SecRandomPlatform=$env:PLATFORM ` | |
| --self-contained ` | |
| -p:PublishTrimmed=false ` | |
| -p:UseAppHost=true ` | |
| -p:EnableUiAccess=true ` | |
| -p:BuildInParallel=false ` | |
| -p:UseSharedCompilation=false ` | |
| --no-restore ` | |
| -o $outDir | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "UIAccess desktop publish failed with exit code $LASTEXITCODE." | |
| } | |
| $certificatePath = Join-Path $env:RUNNER_TEMP 'secrandom-uiaccess.pfx' | |
| try { | |
| [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String(($env:WINDOWS_CODESIGN_PFX_BASE64 -replace '[^A-Za-z0-9+/=]', ''))) | |
| $signTool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe' | | |
| Sort-Object FullName -Descending | | |
| Select-Object -First 1 -ExpandProperty FullName | |
| if ([string]::IsNullOrWhiteSpace($signTool)) { | |
| throw 'signtool.exe was not found on the Windows runner.' | |
| } | |
| $arguments = @('sign', '/fd', 'SHA256', '/f', $certificatePath, '/p', $env:WINDOWS_CODESIGN_PFX_PASSWORD) | |
| if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_TIMESTAMP_URL)) { | |
| $arguments += @('/tr', $env:WINDOWS_CODESIGN_TIMESTAMP_URL, '/td', 'SHA256') | |
| } | |
| $arguments += "$outDir/SecRandom.Desktop.exe" | |
| & $signTool @arguments | |
| if ($LASTEXITCODE -ne 0) { throw "signtool failed with exit code $LASTEXITCODE." } | |
| & $signTool verify /pa /v "$outDir/SecRandom.Desktop.exe" | |
| if ($LASTEXITCODE -ne 0) { throw "signtool verification failed with exit code $LASTEXITCODE." } | |
| } | |
| finally { | |
| Remove-Item $certificatePath -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| else { | |
| $outDir = "artifacts/release/installer/$env:RID" | |
| Remove-Item $outDir -Recurse -Force -ErrorAction SilentlyContinue | |
| Copy-Item "artifacts/release/publish/full/$env:RID" $outDir -Recurse | |
| Write-Warning "Windows code-signing secrets are unavailable; building the installer without the UIAccess manifest." | |
| } | |
| @{ schemaVersion = 1; product = 'SecRandom'; rid = $env:RID; packageKind = 'windows-exe'; runtimeKind = 'self-contained' } | | |
| ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $outDir 'SecRandom.package.json') -Encoding utf8NoBOM | |
| $setupOutput = 'artifacts/release/setup' | |
| New-Item -ItemType Directory -Path $setupOutput -Force | Out-Null | |
| $isccArguments = @( | |
| "/DMyAppVersion=$env:VERSION" | |
| "/DMyAppOutDir=$outDir" | |
| "/FSecRandom-$env:VERSION-$env:RID-setup" | |
| "/O$setupOutput" | |
| ) | |
| if ($uiAccessBuild) { $isccArguments += '/DUiAccessBuild' } | |
| switch ($env:ARCH) { | |
| 'x86' { $isccArguments += '/DBuildArchX86' } | |
| 'x64' { $isccArguments += '/DBuildArchX64' } | |
| 'arm64' { $isccArguments += '/DBuildArchArm64' } | |
| default { throw "Unsupported Windows architecture: $env:ARCH" } | |
| } | |
| & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" Setup.iss @isccArguments | |
| if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE." } | |
| - name: Build Linux deb | |
| if: ${{ matrix.os == 'linux' }} | |
| env: | |
| ARCH: ${{ matrix.arch }} | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| version_no_v="${VERSION#v}" | |
| release_root="artifacts/release" | |
| mkdir -p "${release_root}/dist" | |
| deb_arch="amd64" | |
| case "${{ matrix.arch }}" in | |
| x64) deb_arch="amd64" ;; | |
| arm64) deb_arch="arm64" ;; | |
| esac | |
| work="$(pwd)/${release_root}/linux/${RID}" | |
| pkgroot="${work}/package-root" | |
| rm -rf "$work" | |
| mkdir -p "$pkgroot/DEBIAN" | |
| mkdir -p "$pkgroot/usr/lib/secrandom" | |
| mkdir -p "$pkgroot/usr/bin" | |
| mkdir -p "$pkgroot/usr/share/applications" | |
| mkdir -p "$pkgroot/usr/share/icons/hicolor/256x256/apps" | |
| cp -a "${release_root}/publish/full/${RID}/." "$pkgroot/usr/lib/secrandom/" | |
| printf '{"schemaVersion":1,"product":"SecRandom","rid":"%s","packageKind":"linux-deb","runtimeKind":"self-contained"}\n' "$RID" > "$pkgroot/usr/lib/secrandom/SecRandom.package.json" | |
| chmod +x "$pkgroot/usr/lib/secrandom/SecRandom.Desktop" || true | |
| cat > "$pkgroot/usr/bin/secrandom" << 'EOF' | |
| #!/bin/sh | |
| exec /usr/lib/secrandom/SecRandom.Desktop "$@" | |
| EOF | |
| chmod +x "$pkgroot/usr/bin/secrandom" | |
| cat > "$pkgroot/usr/share/applications/secrandom.desktop" << 'EOF' | |
| [Desktop Entry] | |
| Type=Application | |
| Name=SecRandom | |
| Exec=secrandom | |
| Icon=secrandom | |
| Categories=Utility; | |
| Terminal=false | |
| EOF | |
| if [ -f "resources/secrandom-icon-paper.png" ]; then | |
| cp "resources/secrandom-icon-paper.png" "$pkgroot/usr/share/icons/hicolor/256x256/apps/secrandom.png" | |
| fi | |
| cat > "$pkgroot/DEBIAN/control" << EOF | |
| Package: secrandom | |
| Version: ${version_no_v} | |
| Section: utils | |
| Priority: optional | |
| Architecture: ${deb_arch} | |
| Maintainer: SECTL | |
| Description: SecRandom | |
| EOF | |
| dpkg-deb --build "$pkgroot" "${release_root}/dist/SecRandom-${VERSION}-${RID}.deb" | |
| - name: Build macOS pkg | |
| if: ${{ matrix.os == 'macos' }} | |
| env: | |
| ARCH: ${{ matrix.arch }} | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| RID: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }} | |
| MACOS_SIGNING_P12_BASE64: ${{ secrets.MACOS_SIGNING_P12_BASE64 }} | |
| MACOS_SIGNING_P12_PASSWORD: ${{ secrets.MACOS_SIGNING_P12_PASSWORD }} | |
| MACOS_APPLICATION_SIGNING_IDENTITY: ${{ secrets.MACOS_APPLICATION_SIGNING_IDENTITY }} | |
| MACOS_INSTALLER_SIGNING_IDENTITY: ${{ secrets.MACOS_INSTALLER_SIGNING_IDENTITY }} | |
| MACOS_NOTARY_APPLE_ID: ${{ secrets.MACOS_NOTARY_APPLE_ID }} | |
| MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }} | |
| MACOS_NOTARY_PASSWORD: ${{ secrets.MACOS_NOTARY_PASSWORD }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| version_no_v="${VERSION#v}" | |
| release_root="artifacts/release" | |
| mkdir -p "${release_root}/dist" | |
| work="$(pwd)/${release_root}/macos/${RID}" | |
| rm -rf "$work" | |
| mkdir -p "$work/bundle/SecRandom.app/Contents/MacOS" | |
| mkdir -p "$work/bundle/SecRandom.app/Contents/Resources" | |
| cp -a "${release_root}/publish/full/${RID}/." "$work/bundle/SecRandom.app/Contents/MacOS/" | |
| printf '{"schemaVersion":1,"product":"SecRandom","rid":"%s","packageKind":"macos-app","runtimeKind":"self-contained"}\n' "$RID" > "$work/bundle/SecRandom.app/Contents/MacOS/SecRandom.package.json" | |
| chmod +x "$work/bundle/SecRandom.app/Contents/MacOS/SecRandom.Desktop" || true | |
| cat > "$work/bundle/SecRandom.app/Contents/Info.plist" << EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>CFBundleName</key><string>SecRandom</string> | |
| <key>CFBundleDisplayName</key><string>SecRandom</string> | |
| <key>CFBundleIdentifier</key><string>top.sectl.secrandom</string> | |
| <key>CFBundleVersion</key><string>${version_no_v}</string> | |
| <key>CFBundleShortVersionString</key><string>${version_no_v}</string> | |
| <key>CFBundleExecutable</key><string>SecRandom.Desktop</string> | |
| <key>CFBundlePackageType</key><string>APPL</string> | |
| <key>LSMinimumSystemVersion</key><string>11.0</string> | |
| <key>CFBundleIconFile</key><string>AppIcon</string> | |
| </dict> | |
| </plist> | |
| EOF | |
| icon_png="resources/secrandom-icon-paper.png" | |
| if [ -f "$icon_png" ]; then | |
| iconset="$work/AppIcon.iconset" | |
| mkdir -p "$iconset" | |
| for size in 16 32 64 128 256 512; do | |
| sips -z "$size" "$size" "$icon_png" --out "$iconset/icon_${size}x${size}.png" >/dev/null | |
| done | |
| for size in 16 32 128 256 512; do | |
| double=$((size*2)) | |
| sips -z "$double" "$double" "$icon_png" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null | |
| done | |
| iconutil -c icns "$iconset" -o "$work/bundle/SecRandom.app/Contents/Resources/AppIcon.icns" | |
| fi | |
| if [ -z "$MACOS_SIGNING_P12_BASE64" ] || [ -z "$MACOS_SIGNING_P12_PASSWORD" ] || [ -z "$MACOS_APPLICATION_SIGNING_IDENTITY" ] || [ -z "$MACOS_INSTALLER_SIGNING_IDENTITY" ] || [ -z "$MACOS_NOTARY_APPLE_ID" ] || [ -z "$MACOS_NOTARY_TEAM_ID" ] || [ -z "$MACOS_NOTARY_PASSWORD" ]; then | |
| echo "macOS signing or notarization credentials are unavailable; keeping unsigned APP and skipping signed PKG." | |
| ditto -c -k --sequesterRsrc --keepParent \ | |
| "$work/bundle/SecRandom.app" \ | |
| "${release_root}/dist/SecRandom-${VERSION}-${RID}.app.zip" | |
| exit 0 | |
| fi | |
| keychain="$RUNNER_TEMP/secrandom-signing.keychain-db" | |
| certificate="$RUNNER_TEMP/secrandom-signing.p12" | |
| trap 'security delete-keychain "$keychain" >/dev/null 2>&1 || true; rm -f "$certificate"' EXIT | |
| echo "$MACOS_SIGNING_P12_BASE64" | base64 --decode > "$certificate" | |
| security create-keychain -p "$MACOS_SIGNING_P12_PASSWORD" "$keychain" | |
| security set-keychain-settings -lut 21600 "$keychain" | |
| security unlock-keychain -p "$MACOS_SIGNING_P12_PASSWORD" "$keychain" | |
| security import "$certificate" -k "$keychain" -P "$MACOS_SIGNING_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild | |
| security list-keychain -d user -s "$keychain" | |
| security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_SIGNING_P12_PASSWORD" "$keychain" | |
| codesign --force --deep --options runtime --sign "$MACOS_APPLICATION_SIGNING_IDENTITY" "$work/bundle/SecRandom.app" | |
| codesign --verify --deep --strict "$work/bundle/SecRandom.app" | |
| ditto -c -k --sequesterRsrc --keepParent \ | |
| "$work/bundle/SecRandom.app" \ | |
| "${release_root}/dist/SecRandom-${VERSION}-${RID}.app.zip" | |
| printf '{"schemaVersion":1,"product":"SecRandom","rid":"%s","packageKind":"macos-pkg","runtimeKind":"self-contained"}\n' "$RID" > "$work/bundle/SecRandom.app/Contents/MacOS/SecRandom.package.json" | |
| pkgroot="$work/pkgroot" | |
| mkdir -p "$pkgroot/Applications" | |
| cp -a "$work/bundle/SecRandom.app" "$pkgroot/Applications/" | |
| pkgbuild \ | |
| --root "$pkgroot" \ | |
| --identifier "top.sectl.secrandom" \ | |
| --version "$version_no_v" \ | |
| --install-location "/" \ | |
| --sign "$MACOS_INSTALLER_SIGNING_IDENTITY" \ | |
| "${release_root}/dist/SecRandom-${VERSION}-${RID}.pkg" | |
| xcrun notarytool submit "${release_root}/dist/SecRandom-${VERSION}-${RID}.pkg" --apple-id "$MACOS_NOTARY_APPLE_ID" --team-id "$MACOS_NOTARY_TEAM_ID" --password "$MACOS_NOTARY_PASSWORD" --wait | |
| xcrun stapler staple "${release_root}/dist/SecRandom-${VERSION}-${RID}.pkg" | |
| pkgutil --check-signature "${release_root}/dist/SecRandom-${VERSION}-${RID}.pkg" | |
| - name: Upload Portable Artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }}_portable | |
| path: artifacts/release/dist/*.zip | |
| - name: Upload Setup Artifact | |
| if: ${{ matrix.os == 'windows' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }}_setup | |
| path: artifacts/release/setup/*.exe | |
| - name: Upload deb Artifact | |
| if: ${{ matrix.os == 'linux' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }}_deb | |
| path: artifacts/release/dist/*.deb | |
| - name: Upload pkg Artifact | |
| if: ${{ matrix.os == 'macos' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.os == 'windows' && format('win-{0}', matrix.arch) || (matrix.os == 'linux' && format('linux-{0}', matrix.arch) || format('osx-{0}', matrix.arch)) }}_pkg | |
| path: artifacts/release/dist/*.pkg | |
| if-no-files-found: ignore | |
| build_pluginsdk: | |
| name: Build_PluginSDK | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }} | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Get Version | |
| id: get_version | |
| shell: pwsh | |
| env: | |
| RELEASE_TAG: ${{ github.event.inputs.release_tag }} | |
| run: | | |
| $semverPattern = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$' | |
| if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { | |
| $version = $env:RELEASE_TAG.TrimStart('v', 'V') | |
| if ($version -notmatch $semverPattern) { | |
| throw "Release tag must be a v-prefixed semantic version (vMAJOR.MINOR.PATCH), got '$env:RELEASE_TAG'." | |
| } | |
| } else { | |
| $tag = git describe --tags --abbrev=0 2>$null | |
| if ($LASTEXITCODE -eq 0 -and $tag) { | |
| $version = $tag.TrimStart('v', 'V') | |
| if ($version -notmatch $semverPattern) { | |
| Write-Host "WARNING: latest tag '$tag' is not semantic versioning; using it as-is for the routine build artifact." | |
| } | |
| } else { | |
| $version = '0.0.0-dev' | |
| } | |
| } | |
| echo "PKG_VERSION=$version" >> $env:GITHUB_OUTPUT | |
| Write-Host "Plugin SDK package version: $version" | |
| - name: Verify API version matches main version | |
| shell: pwsh | |
| env: | |
| PKG_VERSION: ${{ steps.get_version.outputs.PKG_VERSION }} | |
| run: | | |
| $api = Get-Content 'SecRandom.PluginSdk/PluginApiVersions.cs' -Raw | |
| $match = [regex]::Match($api, 'new\((?<major>\d+),\s*\d+') | |
| if (-not $match.Success) { throw 'Unable to parse PluginApiVersions.Current.' } | |
| $apiMajor = $match.Groups['major'].Value | |
| $pkgMajor = ($env:PKG_VERSION -split '\.')[0] | |
| if ($apiMajor -ne $pkgMajor) { | |
| throw "PluginApiVersions.Current major ($apiMajor) must match the package version major ($pkgMajor)." | |
| } | |
| - name: Pack Plugin SDK | |
| shell: pwsh | |
| env: | |
| PKG_VERSION: ${{ steps.get_version.outputs.PKG_VERSION }} | |
| run: | | |
| $outDir = 'artifacts/pluginsdk' | |
| New-Item -ItemType Directory -Path $outDir -Force | Out-Null | |
| foreach ($project in @( | |
| 'SecRandom.Shared/SecRandom.Shared.csproj', | |
| 'SecRandom.Core/SecRandom.Core.csproj', | |
| 'SecRandom.PluginSdk/SecRandom.PluginSdk.csproj' | |
| )) { | |
| & dotnet pack $project -c Release -o $outDir "-p:PackageVersion=$env:PKG_VERSION" '-p:BuildInParallel=false' '-p:UseSharedCompilation=false' | |
| if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed for $project with exit code $LASTEXITCODE." } | |
| } | |
| - name: Upload Plugin SDK Packages | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: pluginsdk | |
| path: artifacts/pluginsdk/*.nupkg | |
| if-no-files-found: error | |
| build_android: | |
| name: Build_Android_${{ matrix.arch }} | |
| runs-on: ubuntu-24.04 | |
| env: | |
| arch: ${{ matrix.arch }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: ['arm64', 'x64'] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }} | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Get Latest Tag | |
| id: get_tag | |
| if: ${{ github.event_name != 'workflow_dispatch' }} | |
| shell: pwsh | |
| run: | | |
| $latestTag = git describe --tags --abbrev=0 | |
| "LATEST_TAG=$latestTag" >> $env:GITHUB_OUTPUT | |
| - name: Setup JDK 21 | |
| uses: actions/setup-java@v5 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| - name: Install Android SDK packages | |
| shell: pwsh | |
| run: | | |
| & "$env:ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platform-tools" "platforms;android-36" "build-tools;36.0.0" | |
| - name: Install Android workload | |
| shell: pwsh | |
| run: dotnet workload install android | |
| - name: Build Android APK | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} | |
| ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} | |
| ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} | |
| ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} | |
| run: | | |
| $version = $env:VERSION.TrimStart('v', 'V') | |
| $arguments = @('build', 'SecRandom.Android/SecRandom.Android.csproj', '-c', 'Release', '-r', 'android-${{ matrix.arch }}', "-p:Version=$version", '-p:ApplicationVersion=${{ github.run_number }}', '-p:BuildInParallel=false', '-p:UseSharedCompilation=false') | |
| $signingValues = @{ | |
| ANDROID_SIGNING_KEYSTORE_BASE64 = $env:ANDROID_SIGNING_KEYSTORE_BASE64 | |
| ANDROID_SIGNING_KEYSTORE_PASSWORD = $env:ANDROID_SIGNING_KEYSTORE_PASSWORD | |
| ANDROID_SIGNING_KEY_ALIAS = $env:ANDROID_SIGNING_KEY_ALIAS | |
| ANDROID_SIGNING_KEY_PASSWORD = $env:ANDROID_SIGNING_KEY_PASSWORD | |
| } | |
| $missingSigningValues = @($signingValues.GetEnumerator() | Where-Object { [string]::IsNullOrWhiteSpace($_.Value) } | Select-Object -ExpandProperty Key) | |
| if ($missingSigningValues.Count -gt 0) { | |
| throw "Android release signing is required for every build; missing secrets: $($missingSigningValues -join ', ')." | |
| } | |
| $keystorePath = Join-Path $env:RUNNER_TEMP 'secrandom-android.keystore' | |
| $keystoreBase64 = $env:ANDROID_SIGNING_KEYSTORE_BASE64 | |
| if ($keystoreBase64.Length -gt 0 -and [int][char]$keystoreBase64[0] -eq 0xFEFF) { | |
| $keystoreBase64 = $keystoreBase64.Substring(1) | |
| } | |
| $keystoreBase64 = $keystoreBase64.Trim() | |
| $keystoreBase64 = $keystoreBase64 -replace '-', '+' | |
| $keystoreBase64 = $keystoreBase64 -replace '_', '/' | |
| $keystoreBytes = $null | |
| try { | |
| $keystoreBytes = [Convert]::FromBase64String($keystoreBase64) | |
| } catch { | |
| $firstInvalid = $keystoreBase64.ToCharArray() | Where-Object { [char]::IsWhiteSpace($_) -eq $false -and $_ -notmatch '[A-Za-z0-9+/=]' } | Select-Object -First 1 | |
| $invalidCodePoint = if ($null -ne $firstInvalid) { [int][char]$firstInvalid } else { -1 } | |
| throw "ANDROID_SIGNING_KEYSTORE_BASE64 is not valid base64 even after trimming whitespace and translating URL-safe characters; first invalid character has code point $invalidCodePoint. Re-provision the secret with a clean base64 encoding of the keystore." | |
| } | |
| if ($keystoreBytes.Length -lt 16) { | |
| throw 'The Android release keystore secret decoded to an implausibly small file; re-check ANDROID_SIGNING_KEYSTORE_BASE64.' | |
| } | |
| [IO.File]::WriteAllBytes($keystorePath, $keystoreBytes) | |
| $keytoolArguments = @('-J-Duser.language=en', '-J-Duser.country=US', '-list', '-v', '-keystore', $keystorePath, '-storepass', $env:ANDROID_SIGNING_KEYSTORE_PASSWORD, '-alias', $env:ANDROID_SIGNING_KEY_ALIAS) | |
| $keystoreVerification = & keytool @keytoolArguments 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Android signing keystore validation failed. $($keystoreVerification -join ' ')" | |
| } | |
| $arguments += @('-p:AndroidKeyStore=true', "-p:AndroidSigningKeyStore=$keystorePath", "-p:AndroidSigningStorePass=$env:ANDROID_SIGNING_KEYSTORE_PASSWORD", "-p:AndroidSigningKeyAlias=$env:ANDROID_SIGNING_KEY_ALIAS", "-p:AndroidSigningKeyPass=$env:ANDROID_SIGNING_KEY_PASSWORD") | |
| & dotnet @arguments | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Android build failed with exit code $LASTEXITCODE." | |
| } | |
| - name: Prepare Android APK | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} | |
| ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} | |
| run: | | |
| $apk = Get-ChildItem 'SecRandom.Android/bin/Release/net10.0-android36.0/android-${{ matrix.arch }}' -Filter '*-Signed.apk' | | |
| Select-Object -First 1 -ExpandProperty FullName | |
| if ([string]::IsNullOrWhiteSpace($apk)) { | |
| throw 'The Android build did not produce a signed APK.' | |
| } | |
| $keystorePath = Join-Path $env:RUNNER_TEMP 'secrandom-android.keystore' | |
| if (!(Test-Path -LiteralPath $keystorePath)) { | |
| throw 'The Android signing keystore is missing after the build.' | |
| } | |
| $keytoolArguments = @('-J-Duser.language=en', '-J-Duser.country=US', '-list', '-v', '-keystore', $keystorePath, '-storepass', $env:ANDROID_SIGNING_KEYSTORE_PASSWORD, '-alias', $env:ANDROID_SIGNING_KEY_ALIAS) | |
| $keystoreCertificate = & keytool @keytoolArguments 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| throw 'Unable to read the Android release certificate from the signing keystore.' | |
| } | |
| $expectedFingerprint = [regex]::Match(($keystoreCertificate -join "`n"), '(?im)^\s*SHA256:\s*([0-9A-F:]+)\s*$').Groups[1].Value.Replace(':', '').ToUpperInvariant() | |
| if ([string]::IsNullOrWhiteSpace($expectedFingerprint)) { | |
| throw 'The Android signing keystore did not expose a SHA-256 certificate fingerprint.' | |
| } | |
| $apksigner = Get-ChildItem "$env:ANDROID_HOME/build-tools/*/apksigner" -File | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName | |
| if ([string]::IsNullOrWhiteSpace($apksigner)) { | |
| throw 'apksigner was not found on the Android runner.' | |
| } | |
| $verification = & $apksigner verify --verbose --print-certs $apk 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| throw 'The Android APK signature verification failed.' | |
| } | |
| $actualFingerprint = [regex]::Match(($verification -join "`n"), '(?im)certificate SHA-256 digest:\s*([0-9A-F:]+)').Groups[1].Value.Replace(':', '').ToUpperInvariant() | |
| if ([string]::IsNullOrWhiteSpace($actualFingerprint) -or $actualFingerprint -cne $expectedFingerprint) { | |
| throw "The Android APK certificate fingerprint does not match the release keystore. Expected $expectedFingerprint, got $actualFingerprint." | |
| } | |
| Write-Host "Verified Android release certificate SHA-256: $actualFingerprint" | |
| New-Item -ItemType Directory -Path 'artifacts/mobile' -Force | Out-Null | |
| Copy-Item $apk "artifacts/mobile/SecRandom-v$($env:VERSION.TrimStart('v', 'V'))-android-${{ matrix.arch }}.apk" | |
| - name: Upload Android APK | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: android-${{ matrix.arch }}_apk | |
| path: artifacts/mobile/*.apk | |
| retention-days: 14 | |
| if-no-files-found: error | |
| build_ios: | |
| name: Build_iOS | |
| runs-on: macos-26 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| persist-credentials: false | |
| ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref }} | |
| - name: Set up Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: '26.6' | |
| - name: Setup .NET | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Get Latest Tag | |
| id: get_tag | |
| if: ${{ github.event_name != 'workflow_dispatch' }} | |
| shell: bash | |
| run: echo "LATEST_TAG=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" | |
| - name: Verify iOS toolchain | |
| shell: bash | |
| run: | | |
| test "$(uname -m)" = arm64 | |
| xcode-select -p | |
| xcodebuild -version | |
| xcrun --find strip | |
| dotnet --version | |
| - name: Install iOS workload | |
| shell: bash | |
| run: dotnet workload install ios | |
| - name: Publish unsigned iOS arm64 IPA | |
| shell: bash | |
| env: | |
| VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || steps.get_tag.outputs.LATEST_TAG }} | |
| run: | | |
| set -euo pipefail | |
| version="${VERSION#v}" | |
| version="${version#V}" | |
| output="artifacts/mobile/SecRandom-v${version}-ios-arm64-unsigned.ipa" | |
| mkdir -p artifacts/mobile | |
| dotnet publish SecRandom.iOS/SecRandom.iOS.csproj \ | |
| -c Release \ | |
| -f net10.0-ios \ | |
| -r ios-arm64 \ | |
| -p:Version="$version" \ | |
| -p:ApplicationVersion=${{ github.run_number }} \ | |
| -p:EnableCodeSigning=false \ | |
| -p:CodesignRequireProvisioningProfile=false \ | |
| -p:BuildIpa=true \ | |
| -p:ArchiveOnBuild=false \ | |
| -p:BuildInParallel=false \ | |
| -p:UseSharedCompilation=false \ | |
| -p:IpaPackagePath="$GITHUB_WORKSPACE/$output" | |
| test -s "$output" | |
| unzip -l "$output" | grep -q 'Payload/.*\.app/Info.plist' | |
| unzip -p "$output" 'Payload/*.app/Info.plist' > "$RUNNER_TEMP/SecRandom-iOS-Info.plist" | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$RUNNER_TEMP/SecRandom-iOS-Info.plist")" = 'SecRandom' | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "$RUNNER_TEMP/SecRandom-iOS-Info.plist")" = 'AppIcon' | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$RUNNER_TEMP/SecRandom-iOS-Info.plist")" = "$version" | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$RUNNER_TEMP/SecRandom-iOS-Info.plist")" = "${{ github.run_number }}" | |
| - name: Upload unsigned iOS IPA | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ios-arm64_unsigned_ipa | |
| path: artifacts/mobile/*.ipa | |
| retention-days: 14 | |
| if-no-files-found: error | |
| publish: | |
| name: Publish_Release | |
| runs-on: windows-2022 | |
| needs: [build_desktop, build_android, build_ios, build_pluginsdk] | |
| if: ${{ always() && success('build_desktop') && success('build_android') && success('build_ios') && success('build_pluginsdk') && github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag && github.event_name != 'pull_request' }} | |
| permissions: | |
| contents: write | |
| id-token: write | |
| concurrency: | |
| group: publish-public | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| submodules: recursive | |
| ref: ${{ github.event.inputs.release_tag }} | |
| - name: Download Artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: ./artifacts/release/downloaded | |
| - name: Combine Artifacts | |
| shell: pwsh | |
| run: | | |
| $releaseOutput = './artifacts/release/output' | |
| New-Item -ItemType Directory -Path $releaseOutput -Force | Out-Null | |
| Get-ChildItem ./artifacts/release/downloaded -Recurse -File -Include *.zip,*.exe,*.deb,*.pkg,*.apk,*.ipa | ForEach-Object { | |
| Copy-Item $_.FullName $releaseOutput -Force | |
| } | |
| Write-Host "Combined files:" | |
| Get-ChildItem $releaseOutput -File | Select-Object Name, Length | |
| - name: NuGet login (OIDC → temp API key) | |
| uses: NuGet/login@v1 | |
| id: login | |
| with: | |
| user: ${{ secrets.NUGET_USER }} | |
| - name: NuGet push | |
| shell: pwsh | |
| env: | |
| NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} | |
| run: | | |
| $packages = Get-ChildItem './artifacts/release/downloaded/pluginsdk' -Filter *.nupkg -Recurse | Select-Object -ExpandProperty FullName | |
| if (-not $packages) { throw 'No Plugin SDK nupkg artifacts found to publish.' } | |
| foreach ($nupkg in $packages) { | |
| & dotnet nuget push $nupkg --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate | |
| if ($LASTEXITCODE -ne 0) { throw "NuGet push failed for $nupkg with exit code $LASTEXITCODE." } | |
| } | |
| - name: Generate signed update manifest | |
| shell: pwsh | |
| env: | |
| RELEASE_TAG: ${{ github.event.inputs.release_tag }} | |
| UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64: ${{ secrets.UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64 }} | |
| run: | | |
| if ([string]::IsNullOrWhiteSpace($env:UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64)) { | |
| throw 'Release publication requires the UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64 secret.' | |
| } | |
| $privateKeyPath = Join-Path $env:RUNNER_TEMP 'secrandom-update-ed25519.pem' | |
| $publicDerPath = Join-Path $env:RUNNER_TEMP 'secrandom-update-ed25519-public.der' | |
| try { | |
| [IO.File]::WriteAllBytes($privateKeyPath, [Convert]::FromBase64String(($env:UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64 -replace '[^A-Za-z0-9+/=]', ''))) | |
| & openssl pkey -in $privateKeyPath -pubout -outform DER -out $publicDerPath | |
| if ($LASTEXITCODE -ne 0) { throw "OpenSSL Ed25519 public-key export failed with exit code $LASTEXITCODE." } | |
| $publicDer = [IO.File]::ReadAllBytes($publicDerPath) | |
| if ($publicDer.Length -lt 32) { throw 'The update-signing public key is invalid.' } | |
| $derivedPublicKey = [Convert]::ToBase64String($publicDer[($publicDer.Length - 32)..($publicDer.Length - 1)]) | |
| $embeddedPublicKey = (Get-Content -LiteralPath 'SecRandom/Assets/Updates/release-public-key.txt' -Raw).Trim() | |
| if ($derivedPublicKey -cne $embeddedPublicKey) { | |
| throw 'UPDATE_MANIFEST_PRIVATE_KEY_PEM_BASE64 does not match SecRandom/Assets/Updates/release-public-key.txt.' | |
| } | |
| $releaseTag = $env:RELEASE_TAG | |
| $channel = if ($releaseTag -match '-alpha(?:\.|$)') { 'alpha' } elseif ($releaseTag -match '-beta(?:\.|$)') { 'beta' } elseif ($releaseTag -match '-') { throw "Unsupported prerelease tag: $releaseTag" } else { 'release' } | |
| $metadata = Get-Content -LiteralPath metadata.yaml -Raw | |
| $channelMatch = [regex]::Match($metadata, "(?ms)^ ${channel}:\s*\r?\n\s+tag:\s*(?<tag>\S+)\s*$") | |
| if (-not $channelMatch.Success -or $channelMatch.Groups['tag'].Value -ne $releaseTag) { | |
| throw "metadata.yaml channel '$channel' must point to release tag $releaseTag." | |
| } | |
| $releaseOutput = './artifacts/release/output' | |
| $artifacts = Get-ChildItem $releaseOutput -File | ForEach-Object { | |
| $name = $_.Name | |
| $kind, $runtimeKind, $os, $arch = if ($name -match '^SecRandom-v.+-(?<rid>(win|linux|osx)-(?<arch>x64|x86|arm64))-portable-(?<runtime>full|light)\.zip$') { | |
| $platform = switch ($Matches[2]) { 'win' { 'windows' } 'linux' { 'linux' } 'osx' { 'macos' } } | |
| @('portable-zip', $(if ($Matches.runtime -eq 'full') { 'self-contained' } else { 'framework-dependent' }), $platform, $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-(?<rid>win-(?<arch>x64|x86|arm64))-setup\.exe$') { | |
| @('windows-exe', 'self-contained', 'windows', $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-(?<rid>linux-(?<arch>x64|arm64))\.deb$') { | |
| @('linux-deb', 'self-contained', 'linux', $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-(?<rid>osx-(?<arch>x64|arm64))\.pkg$') { | |
| @('macos-pkg', 'self-contained', 'macos', $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-(?<rid>osx-(?<arch>x64|arm64))\.app\.zip$') { | |
| @('macos-app', 'self-contained', 'macos', $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-Android-(?<arch>arm64|x64)\.apk$') { | |
| @('android-apk', 'self-contained', 'android', $Matches.arch) | |
| } elseif ($name -match '^SecRandom-v.+-iOS-(?<arch>arm64)-unsigned\.ipa$') { | |
| @('ios-ipa', 'self-contained', 'ios', $Matches.arch) | |
| } else { | |
| throw "Unrecognized release artifact name: $name" | |
| } | |
| [ordered]@{ | |
| id = "$os-$arch-$kind-$runtimeKind" | |
| os = $os | |
| arch = $arch | |
| kind = $kind | |
| runtimeKind = $runtimeKind | |
| assetName = $name | |
| byteLength = $_.Length | |
| sha512 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA512).Hash | |
| } | |
| } | |
| $manifest = [ordered]@{ | |
| schemaVersion = 1 | |
| product = 'SecRandom' | |
| channel = $channel | |
| tag = $releaseTag | |
| version = $releaseTag.TrimStart('v') | |
| publishedAt = [DateTimeOffset]::UtcNow.ToString('O') | |
| artifacts = @($artifacts) | |
| } | |
| $manifestPath = "$releaseOutput/SecRandom-update-manifest.json" | |
| $signaturePath = "$releaseOutput/SecRandom-update-manifest.sig" | |
| $manifest | ConvertTo-Json -Depth 5 -Compress | Set-Content -LiteralPath $manifestPath -Encoding utf8NoBOM -NoNewline | |
| & openssl pkeyutl -sign -rawin -inkey $privateKeyPath -in $manifestPath -out $signaturePath | |
| if ($LASTEXITCODE -ne 0) { throw "OpenSSL Ed25519 signing failed with exit code $LASTEXITCODE." } | |
| } | |
| finally { | |
| Remove-Item $privateKeyPath -Force -ErrorAction SilentlyContinue | |
| Remove-Item $publicDerPath -Force -ErrorAction SilentlyContinue | |
| } | |
| - name: Generate Release Note | |
| env: | |
| tagName: ${{ github.event.inputs.release_tag }} | |
| repoName: ${{ github.repository }} | |
| run: pwsh -ep bypass ./scripts/gen-release-note.ps1 | |
| - name: Upload APP to release | |
| uses: ncipollo/release-action@v1 | |
| with: | |
| name: SecRandom ${{ github.event.inputs.release_tag }} | |
| artifacts: "./artifacts/release/output/*.zip,./artifacts/release/output/*.exe,./artifacts/release/output/*.deb,./artifacts/release/output/*.pkg,./artifacts/release/output/*.apk,./artifacts/release/output/*.ipa,./artifacts/release/output/SecRandom-update-manifest.json,./artifacts/release/output/SecRandom-update-manifest.sig" | |
| draft: true | |
| bodyFile: ./release-note.md | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| tag: ${{ github.event.inputs.release_tag }} |