diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 870464a..2c094a6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,148 +1,193 @@
-name: Release
-
-on:
- pull_request:
- branches: [main]
- types: [closed]
-
-permissions:
- contents: write
-
-jobs:
- release:
- if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
- runs-on: windows-latest
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Get version
- id: version
- shell: pwsh
- run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- echo "VERSION=$version" >> $env:GITHUB_OUTPUT
-
- - name: Check if release already exists
- id: check
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
- echo "EXISTS=true" >> $GITHUB_OUTPUT
- else
- echo "EXISTS=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Create release
- if: steps.check.outputs.EXISTS == 'false'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
-
- - name: Setup .NET 8.0
- if: steps.check.outputs.EXISTS == 'false'
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
-
- - name: Build and test
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet restore
- dotnet build -c Release
- dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
-
- - name: Publish App (all platforms)
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
-
- - name: Create Velopack release (Windows)
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- dotnet tool install -g vpk
- New-Item -ItemType Directory -Force -Path releases/velopack
-
- # Download previous release for delta generation
- vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
-
- # Pack Windows release
- vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
-
- - name: Package and upload
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- New-Item -ItemType Directory -Force -Path releases
-
- # Package Windows and Linux as flat zips
- foreach ($rid in @('win-x64', 'linux-x64')) {
- if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
- Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Package macOS as proper .app bundles
- foreach ($rid in @('osx-x64', 'osx-arm64')) {
- $appName = "PerformanceStudio.app"
- $bundleDir = "publish/$rid-bundle/$appName"
-
- # Create .app bundle structure
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
-
- # Copy all published files into Contents/MacOS
- Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
-
- # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
- if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
- Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
- }
-
- # Update version in Info.plist to match csproj
- $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
- $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
-
- # Move icon to Contents/Resources
- if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
- Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
- }
-
- # Add README and LICENSE alongside the .app bundle
- $wrapperDir = "publish/$rid-bundle"
- if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
-
- Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Checksums (zips only, Velopack has its own checksums)
- $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
- $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
- "$hash $($_.Name)"
- }
- $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
- Write-Host "Checksums:"
- $checksums | ForEach-Object { Write-Host $_ }
-
- # Upload zips + checksums
- gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
-
- # Upload Velopack artifacts
- vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
+name: Release
+
+on:
+ pull_request:
+ branches: [main]
+ types: [closed]
+
+permissions:
+ contents: write
+ id-token: write
+ actions: read
+
+jobs:
+ release:
+ if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get version
+ id: version
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+
+ - name: Check if release already exists
+ id: check
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
+ echo "EXISTS=true" >> $GITHUB_OUTPUT
+ else
+ echo "EXISTS=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create release
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
+
+ - name: Setup .NET 8.0
+ if: steps.check.outputs.EXISTS == 'false'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Build and test
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet restore
+ dotnet build -c Release
+ dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
+
+ - name: Publish App (all platforms)
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
+
+ # ── SignPath code signing (Windows only, skipped if secret not configured) ──
+ - name: Check if signing is configured
+ if: steps.check.outputs.EXISTS == 'false'
+ id: signing
+ shell: bash
+ run: |
+ if [ -n "${{ secrets.SIGNPATH_API_TOKEN }}" ]; then
+ echo "ENABLED=true" >> $GITHUB_OUTPUT
+ else
+ echo "ENABLED=false" >> $GITHUB_OUTPUT
+ echo "::warning::SIGNPATH_API_TOKEN not configured — releasing unsigned binaries"
+ fi
+
+ - name: Upload Windows build for signing
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ id: upload-unsigned
+ uses: actions/upload-artifact@v4
+ with:
+ name: App-unsigned
+ path: publish/win-x64/
+
+ - name: Sign Windows build
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceStudio'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'App'
+ github-artifact-id: '${{ steps.upload-unsigned.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/win-x64'
+
+ - name: Replace unsigned Windows build with signed
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ shell: pwsh
+ run: |
+ Remove-Item -Recurse -Force publish/win-x64
+ Copy-Item -Recurse signed/win-x64 publish/win-x64
+
+ # ── Velopack (uses signed Windows binaries) ───────────────────────
+ - name: Create Velopack release (Windows)
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ dotnet tool install -g vpk
+ New-Item -ItemType Directory -Force -Path releases/velopack
+
+ # Download previous release for delta generation
+ vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
+
+ # Pack Windows release (now signed)
+ vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
+
+ # ── Package and upload ────────────────────────────────────────────
+ - name: Package and upload
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ New-Item -ItemType Directory -Force -Path releases
+
+ # Package Windows (signed) and Linux as flat zips
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
+ Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Package macOS as proper .app bundles
+ foreach ($rid in @('osx-x64', 'osx-arm64')) {
+ $appName = "PerformanceStudio.app"
+ $bundleDir = "publish/$rid-bundle/$appName"
+
+ # Create .app bundle structure
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
+
+ # Copy all published files into Contents/MacOS
+ Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
+
+ # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
+ if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
+ }
+
+ # Update version in Info.plist to match csproj
+ $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
+ $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
+
+ # Move icon to Contents/Resources
+ if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
+ }
+
+ # Add README and LICENSE alongside the .app bundle
+ $wrapperDir = "publish/$rid-bundle"
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
+
+ Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Checksums (zips only, Velopack has its own checksums)
+ $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
+ $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$hash $($_.Name)"
+ }
+ $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
+ Write-Host "Checksums:"
+ $checksums | ForEach-Object { Write-Host $_ }
+
+ # Upload zips + checksums
+ gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
+
+ # Upload Velopack artifacts
+ vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN