name: OneClient Release Build on: workflow_dispatch: # NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at # workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment # variable into its cache key, so a workflow-level declaration gave this workflow # a cache namespace that the nightly workflow could never populate - every # release built the whole dependency graph from scratch. They are scoped to the # individual steps that actually need them instead. concurrency: group: oneclient-release cancel-in-progress: false jobs: prepare: runs-on: ubuntu-latest permissions: contents: write outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} sha: ${{ steps.version.outputs.sha }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: # Knope finds the commit (and so the PR author) that added each change file fetch-depth: 0 - name: Setup Rust uses: ./.github/actions/setup-rust with: restore-cache: false - name: Install Knope uses: ./.github/actions/install-cargo-tool with: tool: knope version: 0.23.0 - name: Consume changesets id: version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail read_version() { sed -n -E '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n1 } # No change files means a re-run of a release whose version commit # already landed, so carry on with the current version. RELEASED=0 if compgen -G ".changeset/*.md" > /dev/null; then knope prepare-release cargo update --workspace --offline || cargo update --workspace RELEASED=1 fi VERSION="$(read_version)" if [ -z "$VERSION" ]; then echo "::error::Could not read version from [workspace.package] in Cargo.toml" exit 1 fi TAG="oneclient-$VERSION" echo "Releasing OneClient $VERSION" # Abort rather than clobber: a published release (or an existing tag, # which only appears once a release is published) means this version # already shipped. A *draft* is a previous failed/partial run of this # same workflow, so reuse it. if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Add a change file with \`knope document-change\` first." exit 1 fi RELEASE_EXISTS=0 IS_DRAFT="" if IS_DRAFT="$(gh release view "$TAG" --json isDraft -q .isDraft 2>/dev/null)"; then RELEASE_EXISTS=1 fi if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Add a change file with \`knope document-change\` first." exit 1 fi # Commit only after the guards pass, so a rejected release does not # leave a stray version bump on the branch. if [ "$RELEASED" = "1" ]; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A Cargo.toml Cargo.lock .changeset CHANGELOG.md git commit -m "chore: release $VERSION" git push origin "HEAD:${GITHUB_REF_NAME}" fi SHA="$(git rev-parse HEAD)" if [ "$RELEASE_EXISTS" = "1" ]; then echo "Reusing existing draft release $TAG" gh release edit "$TAG" --target "$SHA" else # The body is what the launcher's changelog page shows awk -v heading="## $VERSION " '/^## /{p = index($0, heading) == 1; next} p' \ CHANGELOG.md > notes.md 2>/dev/null || true if ! grep -q '[^[:space:]]' notes.md; then echo "::error::No changelog entry for $VERSION. Add a change file with \`knope document-change\` first." exit 1 fi gh release create "$TAG" \ --draft \ --target "$SHA" \ --title "OneClient $VERSION" \ --notes-file notes.md fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" # --------------------------------------------------------------------------- package: needs: prepare permissions: contents: write # Required so SignPath can download the uploaded build artifact. actions: read strategy: fail-fast: false matrix: include: - platform: macos-latest target: aarch64-apple-darwin os_key: darwin arch: aarch64 formats: app,dmg - platform: macos-latest target: x86_64-apple-darwin os_key: darwin arch: x86_64 formats: app,dmg macos_deployment_target: '10.14' - platform: ubuntu-22.04 target: '' os_key: linux arch: x86_64 formats: deb,appimage - platform: windows-latest target: '' os_key: windows arch: x86_64 formats: nsis runs-on: ${{ matrix.platform }} steps: # Pin to the commit `prepare` resolved - after a release that is the version # commit it just pushed, which github.sha would not point at. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: ${{ needs.prepare.outputs.sha }} - name: (macOS) Pin deployment target if: matrix.macos_deployment_target != '' shell: bash run: echo "MACOSX_DEPLOYMENT_TARGET=${{ matrix.macos_deployment_target }}" >> "$GITHUB_ENV" - name: Setup Rust uses: ./.github/actions/setup-rust with: target: ${{ matrix.target }} save-cache: true restore-cache: true - name: (Linux) Install system dependencies if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential cmake pkg-config clang python3 \ libfontconfig-dev libfreetype-dev \ libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \ libgtk-3-dev libayatana-appindicator3-dev \ libfuse2 - name: Install cargo-packager uses: ./.github/actions/install-cargo-tool with: tool: cargo-packager version: 0.11.8 - name: Import Apple Developer certificate if: startsWith(matrix.platform, 'macos') env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -t 3600 -u build.keychain security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain CERT_ID=$(security find-identity -v -p codesigning build.keychain | awk -F'"' '/Developer ID Application/ {print $2; exit}') if [ -z "$CERT_ID" ]; then echo "::error::No Developer ID Application certificate found in keychain." exit 1 fi echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> "$GITHUB_ENV" # cargo-packager only codesigns when `signing-identity` is present in the # packager config; it does NOT read APPLE_SIGNING_IDENTITY. Inject it into # the macOS metadata so signing + notarization actually run. CERT_ID="$CERT_ID" python3 - <<'PY' import os, re p = "packages/oneclient_app/Cargo.toml" s = open(p).read() ident = os.environ["CERT_ID"] hdr = "[package.metadata.packager.macos]" assert hdr in s, "macos packager section not found" if "signing-identity" not in s: s = s.replace(hdr, f'{hdr}\nsigning-identity = "{ident}"', 1) open(p, "w").write(s) print(f"signing-identity set to: {ident}") PY - name: Build release binary shell: bash run: | if [ -n "${{ matrix.target }}" ]; then cargo build --release --bin oneclient_app --target ${{ matrix.target }} else cargo build --release --bin oneclient_app fi - name: (macOS) Verify deployment target if: matrix.macos_deployment_target != '' shell: bash run: | set -euo pipefail bin="target/${{ matrix.target }}/release/oneclient_app" minos="$(vtool -show-build "$bin" | awk '/minos/ {print $2; exit}')" echo "linked minos=$minos, declared minimum=${{ matrix.macos_deployment_target }}" if [ "$(printf '%s\n%s\n' "$minos" "${{ matrix.macos_deployment_target }}" | sort -V | tail -n1)" != "${{ matrix.macos_deployment_target }}" ]; then echo "::error::Binary requires macOS $minos but the bundle advertises ${{ matrix.macos_deployment_target }}." exit 1 fi - name: (Windows) Upload unsigned app binary artifact if: matrix.platform == 'windows-latest' id: win_upload_unsigned_bin uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: oneclient-windows-binary-unsigned path: target/release/oneclient_app.exe - name: (Windows) Submit SignPath signing request for app binary if: matrix.platform == 'windows-latest' uses: signpath/github-action-submit-signing-request@ced31329c0317e779dad2eec2a7c3bb46ea1343e # v1.3 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} project-slug: onelauncher signing-policy-slug: release-signing artifact-configuration-slug: onelauncher-binary github-artifact-id: ${{ steps.win_upload_unsigned_bin.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: signed-bin - name: (Windows) Swap in signed app binary if: matrix.platform == 'windows-latest' shell: bash run: | set -euo pipefail signed="signed-bin/oneclient_app.exe" if [ ! -e "$signed" ]; then echo "::error::SignPath did not return signed-bin/oneclient_app.exe" ls -R signed-bin || true exit 1 fi cp "$signed" target/release/oneclient_app.exe - name: (Windows) Verify app binary is signed if: matrix.platform == 'windows-latest' shell: pwsh run: | $sig = Get-AuthenticodeSignature 'target/release/oneclient_app.exe' Write-Host "Authenticode status: $($sig.Status)" Write-Host "Signer: $($sig.SignerCertificate.Subject)" if ($sig.Status -eq 'NotSigned') { Write-Error 'oneclient_app.exe carries no Authenticode signature' exit 1 } - name: (macOS) Compile app icon catalog if: startsWith(matrix.platform, 'macos') shell: bash run: | set -euo pipefail out="packages/oneclient_app/icons/generated" mkdir -p "$out" xcrun actool packages/oneclient_app/icons/OneClient.icon \ --compile "$out" \ --app-icon OneClient \ --output-partial-info-plist "$out/partial.plist" \ --platform macosx \ --minimum-deployment-target 26.0 \ --errors --warnings --notices rm -f "$out/OneClient.icns" test -f "$out/Assets.car" || { echo "::error::actool produced no Assets.car"; exit 1; } - name: (Windows) Stage app-local VC++ runtime if: matrix.platform == 'windows-latest' shell: bash run: python3 distribution/windows/bundle-crt.py - name: (Windows) Verify staged runtime is Microsoft-signed if: matrix.platform == 'windows-latest' shell: pwsh run: | $dlls = @(Get-ChildItem 'target/crt/*.dll') if ($dlls.Count -eq 0) { Write-Error 'no CRT DLLs staged'; exit 1 } foreach ($dll in $dlls) { $sig = Get-AuthenticodeSignature $dll.FullName Write-Host "$($dll.Name): $($sig.Status) $($sig.SignerCertificate.Subject)" if ($sig.Status -ne 'Valid') { Write-Error "$($dll.Name) signature is $($sig.Status), refusing to ship it" exit 1 } if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { Write-Error "$($dll.Name) is not signed by Microsoft" exit 1 } } - name: Package shell: bash env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} CI: ${{ startsWith(matrix.platform, 'macos') && 'false' || 'true' }} CARGO_PACKAGER_SIGN_PRIVATE_KEY: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY }} CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD }} run: | ARGS="--release --formats ${{ matrix.formats }}" if [ -n "${{ matrix.target }}" ]; then ARGS="$ARGS --target ${{ matrix.target }}" fi cargo packager $ARGS --verbose - name: Resolve packager output dir id: outdir shell: bash run: | if [ -n "${{ matrix.target }}" ]; then echo "dir=target/${{ matrix.target }}/release" >> "$GITHUB_OUTPUT" else echo "dir=target/release" >> "$GITHUB_OUTPUT" fi - name: (Linux) Install cargo-generate-rpm if: matrix.platform == 'ubuntu-22.04' uses: ./.github/actions/install-cargo-tool with: tool: cargo-generate-rpm version: 0.21.0 - name: (Linux) Build RPM if: matrix.platform == 'ubuntu-22.04' shell: bash run: | set -euo pipefail cp target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop \ target/release/oneclient_app.desktop cargo generate-rpm -p packages/oneclient_app cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/" # Upload debug info (dSYM / .dwp / .pdb produced by the release profile's # split-debuginfo) to Sentry so crash reports get real file/function/line # stack traces instead of raw addresses. Skips silently unless the repo has # a SENTRY_AUTH_TOKEN secret plus SENTRY_ORG / SENTRY_PROJECT variables. - name: Upload debug symbols to Sentry shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} OUT_DIR: ${{ steps.outdir.outputs.dir }} run: | set -euo pipefail if [ -z "${SENTRY_AUTH_TOKEN:-}" ] || [ -z "${SENTRY_ORG:-}" ] || [ -z "${SENTRY_PROJECT:-}" ]; then echo "Sentry credentials not configured (need SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars); skipping debug-symbol upload." exit 0 fi curl -sL https://sentry.io/get-cli/ | bash export PATH="/usr/local/bin:$PATH" SYMS="$(mktemp -d)" shopt -s nullglob for f in "$OUT_DIR"/oneclient_app "$OUT_DIR"/oneclient_app.exe \ "$OUT_DIR"/oneclient_app.pdb "$OUT_DIR"/*.dwp "$OUT_DIR"/*.dSYM; do [ -e "$f" ] || continue cp -R "$f" "$SYMS/" done sentry-cli debug-files upload --include-sources "$SYMS" - name: (Windows) Locate installer if: matrix.platform == 'windows-latest' id: win_installer shell: bash run: echo "path=$(ls ${{ steps.outdir.outputs.dir }}/*-setup.exe | head -n1)" >> "$GITHUB_OUTPUT" - name: (Windows) Smoke-test installer layout if: matrix.platform == 'windows-latest' shell: pwsh run: | $ErrorActionPreference = 'Stop' $dest = 'C:\oneclient-smoke' Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue $exe = Resolve-Path '${{ steps.win_installer.outputs.path }}' # NSIS: /S is silent, /D= sets the install dir and must be the last arg. $p = Start-Process -FilePath $exe -ArgumentList '/S', "/D=$dest" -Wait -PassThru if ($p.ExitCode -ne 0) { Write-Error "installer exited $($p.ExitCode)"; exit 1 } Write-Host '--- installed layout ---' Get-ChildItem -Recurse $dest | ForEach-Object { Write-Host $_.FullName } if (-not (Test-Path "$dest\oneclient_app.exe")) { Write-Error "oneclient_app.exe is not at the root of $dest" exit 1 } foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { if (-not (Test-Path (Join-Path $dest $dll.Name))) { Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" exit 1 } } Write-Host "OK: $($(Get-ChildItem 'target/crt/*.dll').Count) CRT DLLs sit beside oneclient_app.exe" - name: (Windows) Upload unsigned installer artifact if: matrix.platform == 'windows-latest' id: win_upload_unsigned uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: oneclient-windows-installer-unsigned path: ${{ steps.win_installer.outputs.path }} - name: (Windows) Submit SignPath signing request if: matrix.platform == 'windows-latest' uses: signpath/github-action-submit-signing-request@ced31329c0317e779dad2eec2a7c3bb46ea1343e # v1.3 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} project-slug: onelauncher signing-policy-slug: release-signing artifact-configuration-slug: onelauncher-installer github-artifact-id: ${{ steps.win_upload_unsigned.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: signed - name: (Windows) Replace installer with signed one + recompute updater sig if: matrix.platform == 'windows-latest' shell: bash env: CARGO_PACKAGER_SIGN_PRIVATE_KEY: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY }} CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD }} run: | signed="$(ls signed/*-setup.exe | head -n1)" # Overwrite the unsigned installer in the packager out dir with the signed exe. cp "$signed" "${{ steps.win_installer.outputs.path }}" # Recompute the updater signature over the SIGNED exe. cargo packager signer sign \ --private-key "$CARGO_PACKAGER_SIGN_PRIVATE_KEY" \ --password "$CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD" \ "${{ steps.win_installer.outputs.path }}" - name: Upload installers and build manifest fragment shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare.outputs.tag }} OUT_DIR: ${{ steps.outdir.outputs.dir }} OS_KEY: ${{ matrix.os_key }} ARCH: ${{ matrix.arch }} VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail shopt -s nullglob # Identify the single updater artifact for this platform (the file with a # sibling .sig): -setup.exe (nsis), *.app.tar.gz (app), *.AppImage (appimage). # NOTE: nullglob is on, so an unmatched glob expands to nothing. Use arrays # rather than `ls | head` - the latter falls back to listing the cwd # when nothing matches, silently picking an unrelated file. fmt=""; ext=""; matches=() case "$OS_KEY" in windows) matches=("$OUT_DIR"/*-setup.exe); fmt="nsis"; ext="exe" ;; darwin) matches=("$OUT_DIR"/*.app.tar.gz); fmt="app"; ext="app.tar.gz" ;; linux) matches=("$OUT_DIR"/*.AppImage); fmt="appimage"; ext="AppImage" ;; esac updater_file="${matches[0]:-}" if [ -z "$updater_file" ]; then echo "::error::No updater artifact found in $OUT_DIR for $OS_KEY" exit 1 fi # The updater bundle isn't always arch-tagged by cargo-packager, so the two # macOS legs would collide on the release. Rename the updater artifact + its .sig # to an arch-unique name before upload, and point the manifest at that name. unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.${ext}" cp "$updater_file" "$OUT_DIR/$unique" cp "$updater_file.sig" "$OUT_DIR/$unique.sig" # cargo-packager names the .deb after the binary (oneclient_app__amd64.deb). # Rename it to the same OneClient___ scheme as the other assets so # the AUR oneclient-bin package can point at a stable, predictable URL. deb_unique="" deb_matches=("$OUT_DIR"/*.deb) if [ -e "${deb_matches[0]:-}" ]; then deb_unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.deb" cp "${deb_matches[0]}" "$OUT_DIR/$deb_unique" fi # Same treatment for the .rpm (cargo-generate-rpm names it # oneclient_app--1.x86_64.rpm). rpm_unique="" rpm_matches=("$OUT_DIR"/*.rpm) if [ -e "${rpm_matches[0]:-}" ]; then rpm_unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.rpm" cp "${rpm_matches[0]}" "$OUT_DIR/$rpm_unique" fi # Upload human-facing installers (dmg/renamed deb/renamed rpm/AppImage/signed exe) + # the renamed updater bundle and its .sig. Skip the un-renamed updater bundle to avoid clashes. for f in "$OUT_DIR"/*.dmg ${deb_unique:+"$OUT_DIR/$deb_unique"} ${rpm_unique:+"$OUT_DIR/$rpm_unique"} "$OUT_DIR"/*.AppImage "$OUT_DIR"/*-setup.exe "$OUT_DIR/$unique" "$OUT_DIR/$unique.sig"; do [ -e "$f" ] || continue gh release upload "$TAG" "$f" --clobber done base="$unique" mkdir -p fragments case "$OS_KEY" in darwin) KEYS="darwin-${ARCH} macos-${ARCH}" ;; *) KEYS="${OS_KEY}-${ARCH}" ;; esac SIG="$(cat "$updater_file.sig")" \ URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${base}" \ FMT="$fmt" \ KEYS="$KEYS" \ FRAG_NAME="${OS_KEY}-${ARCH}" \ python3 - <<'PY' import json, os entry = { "signature": os.environ["SIG"], "url": os.environ["URL"], "format": os.environ["FMT"], } frag = {key: entry for key in os.environ["KEYS"].split()} with open(f"fragments/{os.environ['FRAG_NAME']}.json", "w") as f: json.dump(frag, f) PY - name: Upload manifest fragment uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: manifest-${{ matrix.os_key }}-${{ matrix.arch }} path: fragments/*.json manifest: needs: [prepare, package] runs-on: ubuntu-latest permissions: contents: write steps: - name: Download all manifest fragments uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: manifest-* path: fragments merge-multiple: true - name: Assemble latest.json env: VERSION: ${{ needs.prepare.outputs.version }} run: | python3 - <<'PY' import json, glob, os, datetime platforms = {} for path in glob.glob("fragments/*.json"): with open(path) as f: platforms.update(json.load(f)) manifest = { "version": os.environ["VERSION"], "pub_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "platforms": platforms, } with open("latest.json", "w") as f: json.dump(manifest, f, indent=2) print(json.dumps(manifest, indent=2)) PY - name: Upload latest.json to release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare.outputs.tag }} run: gh release upload "$TAG" latest.json --clobber --repo "$GITHUB_REPOSITORY"