name: wai-quantum desktop release # On a tag wai-quantum-desktop-v (the version wai/wai-quantum-desktop/Cargo.toml # and its tauri.conf.json state), builds Quantum Studio for the desktop on each runner and # attaches its installers to the forge release of that tag, each with its SHA-256: # macOS aarch64-apple-darwin and x86_64-apple-darwin: a disk image (.dmg), with the # GPU state vector; # Linux x86_64- and aarch64-unknown-linux-gnu: an AppImage, a Debian package and an # RPM package; # Windows x86_64-pc-windows-msvc: an installer (-setup.exe) and a portable archive # (.zip) for package managers. # Each build runs what it made before it is attached: the app's `check` (a Bell pair run in # its worker, sealed and verified) from the bundle where the runner can run it, and its # `update-status` (the app, the AppImage and the Debian package's app know their installer; # the portable archive's copy knows none, so it never updates itself), the macOS app's code # signature, the disk image's checksums, and the Debian package unpacked. # # Signing. Each kind is skipped, and the log says so, when its secrets are absent: # - Updates: with the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY (and, if the key has one, # WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD), every installer an update installs is # signed (the macOS app as a .app.tar.gz, the AppImage, the packages, the Windows # installer), and the built app's `verify-update` checks each signature with the public # key in tauri.conf.json, for this version, before anything is attached. Without the # secret the release has installers and no update: copies already installed are not # offered it, and update themselves no further until a release is signed. # - macOS: with APPLE_CERTIFICATE (a Developer ID Application certificate, .p12 in # base64), APPLE_CERTIFICATE_PASSWORD and APPLE_SIGNING_IDENTITY, the app is signed with # that identity; with APPLE_API_ISSUER, APPLE_API_KEY and APPLE_API_KEY_P8 (or APPLE_ID, # APPLE_PASSWORD and APPLE_TEAM_ID) it is also notarised and stapled. Without them the # app is signed ad hoc and not notarised, and the cask says how to open it. # # A last job writes the Homebrew cask and the Scoop manifest from the assets just # attached, and, when the builds were signed for updates, the update manifest # (latest.json), and attaches them to this version's release. The app asks the website # for the update manifest (plugins.updater.endpoints), and the website takes it from # this release when it is next deployed. Nothing is pushed to the tap or the bucket: # their repositories take the cask and the manifest by hand. # # Uploads go to the forge directly over the private network (FORGE_API), never through # the public tunnel. A runner that is down leaves its platform's assets missing: the # release lists what each build attached, and the manifests leave that platform out. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: tags: ['wai-quantum-desktop-v*'] permissions: contents: write env: CARGO_TERM_COLOR: always FORGE_API: http://100.64.224.0:3400/api/v1 FORGE_REPO: Transaction-Science/open-standards PUBLIC: https://git.transaction.science/Transaction-Science/open-standards # The command that bundles, pinned (cargo install --locked). TAURI_CLI: 2.12.3 jobs: version: name: the tag is the app's version runs-on: linux outputs: version: ${{ steps.v.outputs.version }} steps: - uses: actions/checkout@v5 - id: v shell: bash run: | set -euo pipefail tag="${GITHUB_REF_NAME}" v="${tag#wai-quantum-desktop-v}" crate="$(sed -n 's/^version = "\(.*\)"$/\1/p' wai/wai-quantum-desktop/Cargo.toml | head -1)" conf="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' wai/wai-quantum-desktop/tauri.conf.json)" [ "$v" = "$crate" ] || { echo "::error::the tag says $v, wai-quantum-desktop/Cargo.toml says $crate"; exit 1; } [ "$v" = "$conf" ] || { echo "::error::the tag says $v, wai-quantum-desktop/tauri.conf.json says $conf"; exit 1; } echo "version=$v" >> "$GITHUB_OUTPUT" macos: name: macOS · ${{ matrix.triple }} needs: version runs-on: macos timeout-minutes: 120 strategy: fail-fast: false max-parallel: 1 matrix: triple: [aarch64-apple-darwin, x86_64-apple-darwin] env: SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }} SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }} SECRET_APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} SECRET_APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} SECRET_APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} SECRET_APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} SECRET_APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} SECRET_APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} SECRET_APPLE_ID: ${{ secrets.APPLE_ID }} SECRET_APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} SECRET_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - uses: actions/checkout@v5 # launchd's PATH omits cargo and node on the macOS host runner. - name: Put cargo and node on PATH shell: bash run: | echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" echo "/opt/homebrew/bin" >> "$GITHUB_PATH" # The app embeds the Studio's build, so it is built first. - name: Build the Studio shell: bash working-directory: wai/quantum-studio run: | set -euo pipefail npm ci --no-audit --no-fund node build.mjs # In a folder of its own, by version: the host's own cargo bin may hold another # version for other work, which this leaves alone. - name: The bundler, and the target shell: bash run: | set -euo pipefail rustup target add "${{ matrix.triple }}" root="$HOME/.cache/wai-ci/tauri-cli-$TAURI_CLI" [ -x "$root/bin/cargo-tauri" ] || nice -n 10 cargo install --locked tauri-cli --version "$TAURI_CLI" --root "$root" --target-dir "$RUNNER_TEMP/tauri-cli-build" rm -rf "$RUNNER_TEMP/tauri-cli-build" echo "$root/bin" >> "$GITHUB_PATH" "$root/bin/cargo-tauri" tauri --version - name: Build, sign where the secrets allow, and check shell: bash working-directory: wai/wai-quantum-desktop env: CI: 'true' run: | set -euo pipefail t="${{ matrix.triple }}"; v="${{ needs.version.outputs.version }}" cli="$(cargo tauri --version)"; [[ "$cli" == *" $TAURI_CLI" ]] || { echo "::error::cargo tauri is $cli, not $TAURI_CLI"; exit 1; } source release/signing.sh if [ -n "${SECRET_APPLE_SIGNING_IDENTITY:-}" ]; then export APPLE_SIGNING_IDENTITY="$SECRET_APPLE_SIGNING_IDENTITY" if [ -n "${SECRET_APPLE_CERTIFICATE:-}" ]; then export APPLE_CERTIFICATE="$SECRET_APPLE_CERTIFICATE" APPLE_CERTIFICATE_PASSWORD="${SECRET_APPLE_CERTIFICATE_PASSWORD:-}" fi echo "macOS signing: with the identity APPLE_SIGNING_IDENTITY names" if [ -n "${SECRET_APPLE_API_KEY_P8:-}" ]; then (umask 077; printf '%s\n' "$SECRET_APPLE_API_KEY_P8" > "$RUNNER_TEMP/notary-key.p8") export APPLE_API_ISSUER="$SECRET_APPLE_API_ISSUER" APPLE_API_KEY="$SECRET_APPLE_API_KEY" APPLE_API_KEY_PATH="$RUNNER_TEMP/notary-key.p8" echo "notarisation: with the API key" elif [ -n "${SECRET_APPLE_ID:-}" ]; then export APPLE_ID="$SECRET_APPLE_ID" APPLE_PASSWORD="$SECRET_APPLE_PASSWORD" APPLE_TEAM_ID="$SECRET_APPLE_TEAM_ID" echo "notarisation: with the Apple ID" else echo "::warning::notarisation skipped: neither APPLE_API_KEY_P8 nor APPLE_ID is set; the app is signed and not notarised" fi else echo "macOS signing skipped: the secret APPLE_SIGNING_IDENTITY is not set; the app is signed ad hoc and not notarised" fi nice -n 10 cargo tauri build --ci --target "$t" --bundles app,dmg --features metal --config "$UPDATER_CONFIG" -- --locked rm -f "$RUNNER_TEMP/notary-key.p8" b="target/$t/release/bundle" app="$b/macos/Quantum Studio.app" exe="$app/Contents/MacOS/$(plutil -extract CFBundleExecutable raw -o - "$app/Contents/Info.plist")" codesign --verify --deep --strict --verbose=2 "$app" for d in "$b"/dmg/*.dmg; do hdiutil verify "$d"; done if [ "$t" = "$(rustc -vV | sed -n 's/^host: //p')" ]; then "$exe" check # The app knows it was installed as an app (so, with a key, it updates itself). s="$("$exe" update-status)"; echo "update-status: $s" [[ "$s" == *'"installed_by":"app"'* ]] || { echo "::error::the bundled app does not know it is the installed app"; exit 1; } if [ "$UPDATES_SIGNED" = 1 ]; then for f in "$b"/macos/*.app.tar.gz; do "$exe" verify-update "$f" "$f.sig" "$v"; done fi else echo "note: the $t app is not run here (another architecture)" fi bash release/collect.sh "$b" "$v" "$t" "$RUNNER_TEMP/dist" - name: Attach to the release shell: bash working-directory: wai/wai-quantum-desktop env: FORGE_TOKEN: ${{ github.token }} run: bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/* linux: name: Linux · ${{ matrix.triple }} needs: version runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: fail-fast: false matrix: include: - runner: linux triple: x86_64-unknown-linux-gnu - runner: linux-arm64 triple: aarch64-unknown-linux-gnu env: SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }} SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }} steps: - uses: actions/checkout@v5 - name: Install Rust 1.98.0 and the web view's libraries shell: bash run: | export PATH="$HOME/.cargo/bin:$PATH" command -v rustup >/dev/null 2>&1 || \ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 rustup toolchain install 1.98.0 --profile minimal rustup default 1.98.0 SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" $SUDO apt-get update -qq $SUDO apt-get install -y -qq libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev file xdg-utils echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions/setup-node@v4 with: node-version: '22' - name: Build the Studio shell: bash working-directory: wai/quantum-studio run: | set -euo pipefail npm ci --no-audit --no-fund node build.mjs - name: The bundler shell: bash run: | set -euo pipefail cargo install --locked tauri-cli --version "$TAURI_CLI" cargo tauri --version - name: Build, sign for updates if the key is set, and check shell: bash working-directory: wai/wai-quantum-desktop run: | set -euo pipefail t="${{ matrix.triple }}"; v="${{ needs.version.outputs.version }}" cli="$(cargo tauri --version)"; [[ "$cli" == *" $TAURI_CLI" ]] || { echo "::error::cargo tauri is $cli, not $TAURI_CLI"; exit 1; } source release/signing.sh cargo tauri build --ci --target "$t" --bundles appimage,deb,rpm --config "$UPDATER_CONFIG" -- --locked b="target/$t/release/bundle" "target/$t/release/wai-quantum-desktop" check # The AppImage runs (unpacked first: a container has no FUSE), and knows it is one. for a in "$b"/appimage/*.AppImage; do APPIMAGE_EXTRACT_AND_RUN=1 "$a" check s="$(APPIMAGE_EXTRACT_AND_RUN=1 "$a" update-status)"; echo "update-status: $s" [[ "$s" == *'"installed_by":"appimage"'* ]] || { echo "::error::the AppImage does not know it is one"; exit 1; } done # The Debian package holds the app, which runs and knows the package installed it. for d in "$b"/deb/*.deb; do dpkg-deb --info "$d" | grep -E '^ (Package|Version|Depends):' rm -rf "$RUNNER_TEMP/deb" && dpkg-deb -x "$d" "$RUNNER_TEMP/deb" "$RUNNER_TEMP/deb/usr/bin/wai-quantum-desktop" check s="$("$RUNNER_TEMP/deb/usr/bin/wai-quantum-desktop" update-status)"; echo "update-status: $s" [[ "$s" == *'"installed_by":"deb"'* ]] || { echo "::error::the Debian package's app does not know the package installed it"; exit 1; } done if [ "$UPDATES_SIGNED" = 1 ]; then for f in "$b"/appimage/*.AppImage "$b"/deb/*.deb "$b"/rpm/*.rpm; do "target/$t/release/wai-quantum-desktop" verify-update "$f" "$f.sig" "$v" done fi bash release/collect.sh "$b" "$v" "$t" "$RUNNER_TEMP/dist" - name: Attach to the release shell: bash working-directory: wai/wai-quantum-desktop env: FORGE_TOKEN: ${{ github.token }} run: bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio ${{ needs.version.outputs.version }}" "$RUNNER_TEMP"/dist/* windows: name: Windows · x86_64-pc-windows-msvc needs: version runs-on: windows timeout-minutes: 120 env: SECRET_UPDATER_KEY: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY }} SECRET_UPDATER_KEY_PASSWORD: ${{ secrets.WAI_QUANTUM_DESKTOP_UPDATER_KEY_PASSWORD }} steps: - uses: actions/checkout@v5 - name: Build the Studio shell: pwsh working-directory: wai/quantum-studio run: | $ErrorActionPreference = 'Stop' Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force node --version npm ci --no-audit --no-fund if ($LASTEXITCODE -ne 0) { throw "npm ci failed" } node build.mjs if ($LASTEXITCODE -ne 0) { throw "the Studio's build failed" } - name: Build, sign for updates if the key is set, check and package shell: pwsh working-directory: wai/wai-quantum-desktop run: | $ErrorActionPreference = 'Stop' Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force $t = 'x86_64-pc-windows-msvc' $v = "${{ needs.version.outputs.version }}" $name = "wai-quantum-desktop-$v-$t" $dist = Join-Path $env:RUNNER_TEMP 'dist' $stage = Join-Path $env:RUNNER_TEMP $name Remove-Item $stage, $dist -Recurse -Force -ErrorAction Ignore New-Item -ItemType Directory -Force -Path $stage, $dist | Out-Null # A run of the built app, waited for (it is a windowed program), its output kept. function Run-App($exe, [string[]] $appArgs) { $out = Join-Path $env:RUNNER_TEMP 'app.out'; $err = Join-Path $env:RUNNER_TEMP 'app.err' $quoted = ($appArgs | ForEach-Object { '"' + $_ + '"' }) -join ' ' $p = Start-Process -FilePath $exe -ArgumentList $quoted -Wait -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError $err $o = (Get-Content $out -Raw) + '' Write-Host $o (Get-Content $err -Raw) if ($p.ExitCode -ne 0) { throw "$exe $($appArgs -join ' ') exited $($p.ExitCode)" } return $o } # The portable copy first, as cargo builds it: the installer's copy is marked as # installed by it (and so updates itself), the archive's is not (a package manager # updates it). cargo build --locked --release --target $t if ($LASTEXITCODE -ne 0) { throw "the build failed" } $exe = "target\$t\release\wai-quantum-desktop.exe" Copy-Item $exe, README.md, ..\LICENSE $stage Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('check') | Out-Null # The portable copy knows no installer made it, so it never updates itself. $s = Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('update-status') if ($s -notmatch '"installed_by":null' -or $s -notmatch '"updates_itself":false') { throw "the portable copy would update itself: $s" } $updaterConfig = '{}' if ($env:SECRET_UPDATER_KEY) { $pub = (Get-Content tauri.conf.json -Raw | ConvertFrom-Json).plugins.updater.pubkey if (-not $pub) { throw "the update key is set, but tauri.conf.json has no plugins.updater.pubkey: put the public key there first" } $env:TAURI_SIGNING_PRIVATE_KEY = $env:SECRET_UPDATER_KEY $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "$env:SECRET_UPDATER_KEY_PASSWORD" $updaterConfig = '{"bundle":{"createUpdaterArtifacts":true}}' Write-Output "update signing: on, with the key the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY holds" } else { Write-Output "update signing skipped: the secret WAI_QUANTUM_DESKTOP_UPDATER_KEY is not set; this release has installers and no update, and installed copies are not offered it" } # The bundler in a folder of its own, by version, ahead of any other on PATH. $root = Join-Path $env:USERPROFILE ".cache\wai-ci\tauri-cli-$env:TAURI_CLI" if (-not (Test-Path (Join-Path $root 'bin\cargo-tauri.exe'))) { cargo install --locked tauri-cli --version $env:TAURI_CLI --root $root --target-dir (Join-Path $env:RUNNER_TEMP 'tauri-cli-build') if ($LASTEXITCODE -ne 0) { throw "the bundler did not install" } Remove-Item (Join-Path $env:RUNNER_TEMP 'tauri-cli-build') -Recurse -Force -ErrorAction Ignore } $env:PATH = (Join-Path $root 'bin') + [IO.Path]::PathSeparator + $env:PATH $cli = (cargo tauri --version) -join '' if ($cli -notmatch " $([regex]::Escape($env:TAURI_CLI))$") { throw "cargo tauri is $cli, not $env:TAURI_CLI" } # '--' quoted, so PowerShell hands it to cargo as it is. cargo tauri build --ci --target $t --bundles nsis --config $updaterConfig '--' --locked if ($LASTEXITCODE -ne 0) { throw "the bundle failed" } $setup = @(Get-ChildItem "target\$t\release\bundle\nsis\*-setup.exe") if ($setup.Count -ne 1) { throw "the bundle holds $($setup.Count) installers, not one" } $installer = Join-Path $dist "$name-setup.exe" Copy-Item $setup[0].FullName $installer if ($updaterConfig -ne '{}') { Copy-Item "$($setup[0].FullName).sig" "$installer.sig" Run-App (Join-Path $stage 'wai-quantum-desktop.exe') @('verify-update', $installer, "$installer.sig", $v) | Out-Null } Compress-Archive -Path $stage -DestinationPath (Join-Path $dist "$name.zip") -Force foreach ($f in "$name-setup.exe", "$name.zip") { $h = (Get-FileHash (Join-Path $dist $f) -Algorithm SHA256).Hash.ToLower() Set-Content -NoNewline -Path (Join-Path $dist "$f.sha256") -Value "$h $f`n" Write-Output "packaged $f ($h)" } - name: Attach to the release shell: pwsh env: FORGE_TOKEN: ${{ github.token }} run: | $ErrorActionPreference = 'Stop' $H = @{ Authorization = "token $env:FORGE_TOKEN" } $base = "$env:FORGE_API/repos/$env:FORGE_REPO/releases" $tag = $env:GITHUB_REF_NAME $rel = $null for ($i = 0; $i -lt 6 -and -not $rel; $i++) { try { $rel = Invoke-RestMethod -Headers $H -Uri "$base/tags/$tag" } catch { try { Invoke-RestMethod -Headers $H -Method Post -Uri $base -ContentType 'application/json' -Body (@{ tag_name = $tag; name = "Quantum Studio ${{ needs.version.outputs.version }}" } | ConvertTo-Json) | Out-Null } catch {} Start-Sleep -Seconds 3 } } if (-not $rel) { throw "no release for $tag" } foreach ($f in Get-ChildItem (Join-Path $env:RUNNER_TEMP 'dist')) { foreach ($a in (Invoke-RestMethod -Headers $H -Uri "$base/$($rel.id)/assets" | Where-Object { $_.name -eq $f.Name })) { Invoke-RestMethod -Headers $H -Method Delete -Uri "$base/$($rel.id)/assets/$($a.id)" | Out-Null } curl.exe -fsS -H "Authorization: token $env:FORGE_TOKEN" -X POST "$base/$($rel.id)/assets?name=$($f.Name)" -F "attachment=@$($f.FullName);type=application/octet-stream" -o NUL -w "uploaded $($f.Name): http %{http_code}`n" if ($LASTEXITCODE -ne 0) { throw "the upload of $($f.Name) failed" } } # The package managers' entries and the update manifest, for the assets just attached: # a Homebrew cask (macOS), a Scoop manifest (Windows) and latest.json. A platform whose # build did not attach its assets is left out, and the log says so. manifests: name: Homebrew cask, Scoop manifest and update manifest needs: [version, macos, linux, windows] if: always() && needs.version.result == 'success' runs-on: linux steps: - uses: actions/checkout@v5 - name: Write and attach them shell: bash working-directory: wai/wai-quantum-desktop env: FORGE_TOKEN: ${{ github.token }} MACOS_NOTARISED: ${{ secrets.APPLE_SIGNING_IDENTITY != '' && (secrets.APPLE_API_KEY_P8 != '' || secrets.APPLE_ID != '') }} run: | set -euo pipefail v="${{ needs.version.outputs.version }}" out="$RUNNER_TEMP/manifests"; mkdir -p "$out" python3 release/manifests.py "$v" "$GITHUB_REF_NAME" "$FORGE_API" "$FORGE_REPO" "$PUBLIC" "$out" files=() for f in wai-quantum-desktop.rb wai-quantum-desktop.json latest.json; do if [ -f "$out/$f" ]; then files+=("$out/$f"); fi done bash release/publish.sh "$GITHUB_REF_NAME" "Quantum Studio $v" "${files[@]}" if [ -f "$out/latest.json" ]; then echo "latest.json is on this release; installed copies find $v once the website serves it (scripts/sync-desktop-update.sh $v in its repository)" fi