name: OneClient Release Build on: workflow_dispatch: 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 }} jobs: prepare: runs-on: ubuntu-latest permissions: contents: write outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v4 - name: Resolve version from Cargo.toml id: version shell: bash run: | VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=oneclient-$VERSION" >> "$GITHUB_OUTPUT" - name: Create/ensure draft release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.version.outputs.tag }} VERSION: ${{ steps.version.outputs.version }} run: | if ! gh release view "$TAG" >/dev/null 2>&1; then gh release create "$TAG" \ --draft \ --title "OneClient $VERSION" \ --notes "Automated release for OneClient $VERSION." fi # --------------------------------------------------------------------------- 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 - 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: - uses: actions/checkout@v4 - 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 \ libfuse2 - name: Install cargo-packager shell: bash run: cargo install cargo-packager --version 0.11.8 --locked - 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: 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' }} 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) Build RPM if: matrix.platform == 'ubuntu-22.04' shell: bash run: | set -euo pipefail cargo install cargo-generate-rpm --version 0.21.0 --locked 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: | 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 sentry-cli debug-files upload --include-sources "$OUT_DIR" - 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) Upload unsigned installer artifact if: matrix.platform == 'windows-latest' id: win_upload_unsigned uses: actions/upload-artifact@v4 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@v1 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 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@v4 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@v4 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"