name: Release # A pushed plain vX.Y.Z tag publishes the release that every client updates # from: legacy Win32 clients (v0.5.0 and earlier) through the GitHub API's # `/releases/latest`, Tauri clients through its `latest.json` asset. A # dispatch run builds the same assets into a draft, which neither reaches. # Suffixed tags (vX.Y.Z-suffix) are dispatch-only: both client comparators # ignore the suffix, so publishing one would ship an rc as a stable update # and hide the final vX.Y.Z behind it. on: push: tags: ['v[0-9]+.[0-9]+.[0-9]+'] workflow_dispatch: inputs: tag: description: 'Existing tag to build into a draft release (e.g. v0.0.0-test)' required: true permissions: contents: read jobs: prepare: name: Check the tag and the updater key runs-on: ubuntu-latest outputs: tag: ${{ steps.tag.outputs.tag }} version: ${{ steps.tag.outputs.version }} steps: - uses: actions/checkout@v7 with: ref: ${{ inputs.tag || github.ref }} persist-credentials: false - name: Verify Cargo.toml version matches tag id: tag env: INPUT_TAG: ${{ inputs.tag }} run: | if [[ "$GITHUB_REF" == refs/tags/* ]]; then tag="${GITHUB_REF#refs/tags/}" else tag="$INPUT_TAG" fi if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::Tag '$tag' is not of the form vX.Y.Z or vX.Y.Z-suffix." exit 1 fi if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] && [[ "$tag" == *-* ]]; then echo "::error::Tag '$tag' has a suffix; suffixed tags are for draft dispatch runs only and are never published." exit 1 fi expected="${tag#v}" cargo_version=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n 1) if [ "$cargo_version" != "$expected" ]; then echo "::error::Tag ($tag -> $expected) does not match Cargo.toml version ($cargo_version). Bump Cargo.toml before tagging." exit 1 fi echo "Cargo version $cargo_version matches tag $tag" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "version=$expected" >> "$GITHUB_OUTPUT" # An exe without the updater public key can never verify, and so never # install, a later update; every install of it would be stranded. # build.rs checks this only for a build that has the signing key in its # environment, so check it here, before any build runs. - name: Refuse an empty updater public key run: | pubkey=$(jq -r '.plugins.updater.pubkey // ""' tauri.conf.json) if [ -z "${pubkey//[[:space:]]/}" ]; then echo "::error::plugins.updater.pubkey in tauri.conf.json is empty: put the updater public key there before releasing." exit 1 fi win-x64: name: Windows x64 needs: prepare runs-on: windows-latest # The updater signing key decides what every installed client will # install, so only the steps that check or use it see it: never # `npm ci` install scripts, the setup actions or crate build scripts. steps: - name: Refuse a missing signing key shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released." exit 1 fi - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v7 with: node-version: lts/* cache: npm - run: npm ci # The Windows release is the portable exe; no installer is built. - run: npm run tauri build -- --no-bundle -- --locked # `signer sign` writes a separate .sig and leaves the exe bytes as # built, so the digest GitHub reports for the uploaded exe, which # legacy clients verify, is the digest of the file they run. - name: Sign the exe shell: bash env: VERSION: ${{ needs.prepare.outputs.version }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | exe=target/release/usage-monitor.exe npx tauri signer sign --app-version "$VERSION" "$exe" test -s "$exe.sig" - uses: actions/upload-artifact@v7 with: name: release-win-x64 path: | target/release/usage-monitor.exe target/release/usage-monitor.exe.sig retention-days: 7 if-no-files-found: error linux-x64: name: Linux x64 needs: prepare runs-on: ubuntu-22.04 # As on Windows, only the steps that check or use the signing key see it. steps: - name: Refuse a missing signing key env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} run: | if [ -z "${TAURI_SIGNING_PRIVATE_KEY//[[:space:]]/}" ]; then echo "::error::The TAURI_SIGNING_PRIVATE_KEY secret is empty; an unsigned build cannot be released." exit 1 fi - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: actions/setup-node@v7 with: node-version: lts/* cache: npm - name: Install Linux build prerequisites run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \ libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci # Compile without the key, then bundle and sign with it: `tauri bundle` # packages the binary already built and runs no cargo build scripts. - run: npm run tauri build -- --no-bundle -- --locked - name: Bundle the AppImage and deb with updater signatures env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: >- npm run tauri bundle -- --bundles appimage,deb --config '{"bundle":{"createUpdaterArtifacts":true}}' # The bundler names outputs after productName, which has spaces, and # GitHub rewrites spaces in asset names, so the URLs in latest.json # would no longer match. A signature covers the content, not the name. - name: Rename the bundles to space-free names env: VERSION: ${{ needs.prepare.outputs.version }} run: | set -euo pipefail shopt -s nullglob one() { if [ "$#" -ne 1 ]; then echo "::error::Expected exactly one bundle, found $#: $*" >&2 return 1 fi printf '%s' "$1" } appimage=$(one target/release/bundle/appimage/*_amd64.AppImage) deb=$(one target/release/bundle/deb/*_amd64.deb) name="usage-monitor_${VERSION}_amd64" mkdir -p dist cp "$appimage" "dist/$name.AppImage" cp "$appimage.sig" "dist/$name.AppImage.sig" cp "$deb" "dist/$name.deb" test -s "dist/$name.AppImage.sig" ls -l dist - uses: actions/upload-artifact@v7 with: name: release-linux-x64 path: dist/ retention-days: 7 if-no-files-found: error publish: name: Publish needs: [prepare, win-x64, linux-x64] runs-on: ubuntu-latest permissions: contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare.outputs.tag }} steps: - uses: actions/checkout@v7 with: ref: ${{ needs.prepare.outputs.tag }} persist-credentials: false - uses: actions/setup-node@v7 with: node-version: lts/* - uses: actions/download-artifact@v8 with: pattern: release-* path: dist merge-multiple: true - name: Write latest.json and check the asset set run: node scripts/make-latest-json.js "$TAG" dist # Legacy clients read only `/releases/latest`, so a tag release must be # a non-draft, non-prerelease release marked latest. - name: Create the release run: | if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then mode=(--draft) else mode=(--latest) fi gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes "${mode[@]}" - name: Check that /releases/latest is this release if: github.event_name != 'workflow_dispatch' run: | latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" -q .tag_name) if [ "$latest" != "$TAG" ]; then echo "::error::/releases/latest returns $latest, not $TAG; legacy clients would not see this release." exit 1 fi