Build and upload to latest release #18
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 and upload to latest release | |
| on: | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: upload-to-latest-release-${{ github.repository }} | |
| cancel-in-progress: false | |
| jobs: | |
| test: | |
| uses: ./.github/workflows/tests.yml | |
| prepare: | |
| needs: test | |
| runs-on: ubuntu-latest | |
| outputs: | |
| name: ${{ steps.metadata.outputs.name }} | |
| version: ${{ steps.metadata.outputs.version }} | |
| release_tag: ${{ steps.metadata.outputs.release_tag }} | |
| revision: ${{ steps.metadata.outputs.revision }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Prepare hotfix metadata | |
| id: metadata | |
| shell: pwsh | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $info = Get-Content version.json -Raw | ConvertFrom-Json | |
| if (-not $info.name -or -not $info.version) { | |
| Write-Error 'version.json must contain name and version.' | |
| exit 1 | |
| } | |
| $tag = gh release view --repo $env:GITHUB_REPOSITORY --json tagName --jq '.tagName' | |
| if (-not $tag) { | |
| Write-Error 'Latest release was not found.' | |
| exit 1 | |
| } | |
| if ($tag.TrimStart('v') -ne [string]$info.version) { | |
| Write-Error "version.json version '$($info.version)' does not match latest release '$tag'." | |
| exit 1 | |
| } | |
| $revision = (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss') | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "name=$($info.name)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "version=$($info.version)" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "release_tag=$tag" | |
| Add-Content -Path $env:GITHUB_OUTPUT -Value "revision=$revision" | |
| build: | |
| needs: prepare | |
| env: | |
| name: ${{ needs.prepare.outputs.name }} | |
| version: ${{ needs.prepare.outputs.version }} | |
| revision: ${{ needs.prepare.outputs.revision }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: windows-latest | |
| platform: Windows | |
| - os: macos-latest | |
| platform: macOS | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v6 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.14' | |
| update-environment: true | |
| cache: 'pipenv' | |
| - name: Install dependencies with pipenv | |
| shell: bash | |
| run: | | |
| python -m pip install --upgrade pip pipenv | |
| pipenv --python 3.14 | |
| pipenv install --dev | |
| - name: Embed hotfix metadata | |
| shell: bash | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| from datetime import datetime, timezone | |
| with open("version.json", encoding="utf-8") as file: | |
| info = json.load(file) | |
| info["build_revision"] = int(os.environ["revision"]) | |
| info["build_time"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| info["source_sha"] = os.environ["GITHUB_SHA"] | |
| with open("version.json", "w", encoding="utf-8") as file: | |
| json.dump(info, file, ensure_ascii=False, indent=2) | |
| file.write("\n") | |
| PY | |
| - name: Build the primary GUI application | |
| run: pipenv run ui_build | |
| - name: Package Windows application | |
| if: runner.os == 'Windows' | |
| shell: pwsh | |
| run: | | |
| $bundle = Join-Path $PWD "dist\$env:name-GUI-v$env:version" | |
| if (-not (Test-Path $bundle)) { | |
| Write-Error "Windows application directory not found: $bundle" | |
| exit 1 | |
| } | |
| New-Item -ItemType Directory -Path artifacts -Force | Out-Null | |
| $asset = "$env:name-GUI-Windows-$env:RUNNER_ARCH-v$env:version-r$env:revision.zip" | |
| Compress-Archive -Path $bundle -DestinationPath (Join-Path $PWD "artifacts\$asset") | |
| - name: Package macOS application | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| app_path="dist/${name}-GUI-v${version}.app" | |
| if [[ ! -d "$app_path" ]]; then | |
| echo "macOS application bundle not found: $app_path" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p artifacts | |
| asset="${name}-GUI-macOS-${RUNNER_ARCH}-v${version}-r${revision}.zip" | |
| ditto -c -k --sequesterRsrc --keepParent "$app_path" "artifacts/$asset" | |
| - name: Upload GUI artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: gui-${{ matrix.platform }}-${{ runner.arch }} | |
| path: artifacts/*.zip | |
| if-no-files-found: error | |
| upload: | |
| needs: [prepare, build] | |
| runs-on: ubuntu-latest | |
| env: | |
| name: ${{ needs.prepare.outputs.name }} | |
| version: ${{ needs.prepare.outputs.version }} | |
| release_tag: ${{ needs.prepare.outputs.release_tag }} | |
| revision: ${{ needs.prepare.outputs.revision }} | |
| steps: | |
| - name: Download GUI artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: gui-* | |
| path: release-assets | |
| merge-multiple: true | |
| - name: Upload assets to latest release | |
| shell: pwsh | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $tag = $env:release_tag | |
| $assets = @(Get-ChildItem release-assets -Filter '*.zip' | Select-Object -ExpandProperty FullName) | |
| if ($assets.Count -ne 2) { | |
| Write-Error "Expected two desktop assets, found $($assets.Count)." | |
| exit 1 | |
| } | |
| $assetNames = @($assets | ForEach-Object { Split-Path $_ -Leaf }) | |
| $windowsAsset = @($assetNames | Where-Object { $_ -match '-Windows-X64-' }) | |
| $macosAsset = @($assetNames | Where-Object { $_ -match '-macOS-ARM64-' }) | |
| if ($windowsAsset.Count -ne 1 -or $macosAsset.Count -ne 1) { | |
| Write-Error 'Expected one Windows X64 asset and one macOS ARM64 asset.' | |
| exit 1 | |
| } | |
| $stateDirectory = Join-Path $PWD 'release-state' | |
| New-Item -ItemType Directory -Path $stateDirectory -Force | Out-Null | |
| $manifestName = 'UPDATE-MANIFEST.json' | |
| $manifestPath = Join-Path $stateDirectory $manifestName | |
| $releaseBefore = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$tag" | ConvertFrom-Json | |
| $existingManifest = @($releaseBefore.assets | Where-Object { $_.name -eq $manifestName }) | |
| if ($existingManifest.Count -gt 0) { | |
| gh release download $tag --pattern $manifestName --dir $stateDirectory --repo $env:GITHUB_REPOSITORY | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error 'Unable to download the existing update manifest.' | |
| exit $LASTEXITCODE | |
| } | |
| } | |
| foreach ($asset in $assets) { | |
| gh release upload $tag $asset --repo $env:GITHUB_REPOSITORY | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| } | |
| $releaseAfter = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$tag" | ConvertFrom-Json | |
| $remoteNames = @($releaseAfter.assets | Select-Object -ExpandProperty name) | |
| foreach ($assetName in $assetNames) { | |
| if ($assetName -notin $remoteNames) { | |
| Write-Error "Uploaded asset was not found in the release: $assetName" | |
| exit 1 | |
| } | |
| } | |
| $builds = @() | |
| if (Test-Path $manifestPath) { | |
| $existing = Get-Content $manifestPath -Raw | ConvertFrom-Json | |
| if ($existing.schema_version -ne 1) { | |
| Write-Error 'The existing update manifest has an unsupported schema.' | |
| exit 1 | |
| } | |
| if ([string]$existing.version -ne $env:version) { | |
| Write-Error 'The existing update manifest version does not match version.json.' | |
| exit 1 | |
| } | |
| $builds = @($existing.builds | Where-Object { [string]$_.revision -ne $env:revision }) | |
| } | |
| $builds += [ordered]@{ | |
| revision = [long]$env:revision | |
| status = 'active' | |
| source_sha = $env:GITHUB_SHA | |
| workflow_run_id = $env:GITHUB_RUN_ID | |
| created_at = (Get-Date).ToUniversalTime().ToString('o') | |
| assets = [ordered]@{ | |
| 'windows-x64' = $windowsAsset[0] | |
| 'macos-arm64' = $macosAsset[0] | |
| } | |
| } | |
| [ordered]@{ | |
| schema_version = 1 | |
| version = $env:version | |
| builds = @($builds) | |
| } | ConvertTo-Json -Depth 8 | Set-Content -Path $manifestPath -Encoding utf8NoBOM | |
| $checksums = Join-Path $stateDirectory 'SHA256SUMS.txt' | |
| $checksumLines = @($releaseAfter.assets | Where-Object { | |
| $_.name -like '*.zip' | |
| } | Sort-Object name | ForEach-Object { | |
| $digest = [string]$_.digest | |
| if (-not $digest.StartsWith('sha256:')) { | |
| Write-Error "Release asset is missing a SHA-256 digest: $($_.name)" | |
| exit 1 | |
| } | |
| "$($digest.Substring(7).ToLower()) $($_.name)" | |
| }) | |
| if ($checksumLines.Count -eq 0) { | |
| Write-Error 'No ZIP assets were found when generating checksums.' | |
| exit 1 | |
| } | |
| $checksumLines | Set-Content -Path $checksums -Encoding utf8NoBOM | |
| gh release upload $tag $checksums --clobber --repo $env:GITHUB_REPOSITORY | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| gh release upload $tag $manifestPath --clobber --repo $env:GITHUB_REPOSITORY | |
| if ($LASTEXITCODE -ne 0) { | |
| exit $LASTEXITCODE | |
| } | |
| - name: Confirm upload | |
| shell: pwsh | |
| run: Write-Host "Uploaded revision $env:revision to release $env:release_tag." |