name: Release # Pushing a `v*` tag builds the installers for every operating system and attaches them to a # draft GitHub Release for that tag. Nothing is published automatically: review the draft, edit # the notes, then publish it by hand. # # Run it manually (Actions → Release → Run workflow) for a dry run: it builds the same installers # and keeps them as workflow artifacts, without creating a release. on: push: tags: ['v*'] workflow_dispatch: # Any `permissions:` block replaces the default token scopes, so grant per job. permissions: {} # PowerShell on Windows let `pnpm` return before it finished; see build.yml. defaults: run: shell: bash jobs: prepare: name: Prepare release runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write outputs: release-id: ${{ steps.release.outputs.id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Tags are vMAJOR.MINOR.PATCH, nothing else: Windows installers only take numeric # versions, so a prerelease suffix would fail the Windows build partway through. And the # version lives in several files; a tag that disagrees with any of them would ship # installers labeled with a different version than the release. - name: Check the tag is a version and matches the app if: github.ref_type == 'tag' env: TAG: ${{ github.ref_name }} run: | if ! [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::$TAG isn't a release tag. Use vMAJOR.MINOR.PATCH, e.g. v0.2.0 (see CONTRIBUTING.md, Versioning)." exit 1 fi expected="${TAG#v}" tauri=$(jq -r .version src-tauri/tauri.conf.json) node=$(jq -r .version package.json) cargo=$(sed -n 's/^version = "\(.*\)"/\1/p' src-tauri/Cargo.toml | head -n 1) status=0 for pair in "src-tauri/tauri.conf.json=$tauri" "package.json=$node" "src-tauri/Cargo.toml=$cargo"; do if [ "${pair#*=}" != "$expected" ]; then echo "::error file=${pair%%=*}::version is ${pair#*=}, but the tag is $TAG" status=1 fi done exit $status # Created once here rather than by each build job, which would race to create it and could # leave duplicate drafts. The ID comes from the creation response itself: a draft that was # just created doesn't reliably show up in the release list yet, and a missing ID makes # the build jobs skip uploading while still passing. - name: Create the draft release id: release if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} run: | id=$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ -f tag_name="$TAG" -f name="Honk $TAG" -F draft=true -F generate_release_notes=true \ --jq .id) if ! [[ "$id" =~ ^[0-9]+$ ]]; then echo "::error::Creating the draft release returned no ID." exit 1 fi echo "id=$id" >> "$GITHUB_OUTPUT" build: name: Build (${{ matrix.name }}) needs: prepare runs-on: ${{ matrix.runner }} timeout-minutes: 60 permissions: contents: write strategy: # One operating system failing shouldn't cancel the others' uploads. fail-fast: false matrix: include: - name: macOS runner: macos-latest # One universal build that runs on both Apple Silicon and Intel Macs. rust-targets: aarch64-apple-darwin,x86_64-apple-darwin arguments: --target universal-apple-darwin - name: Linux # The oldest supported Ubuntu, so the bundles link against a glibc old enough to run # on most distributions. Kept in step with build.yml. runner: ubuntu-22.04 # Built natively rather than cross-compiled: cross-compiling GTK and WebKit is fragile. - name: Linux, ARM runner: ubuntu-22.04-arm - name: Windows runner: windows-latest portable: x64 # Cross-compiled on the x64 runner, which has the 32-bit MSVC toolchain. - name: Windows, 32-bit runner: windows-latest rust-targets: i686-pc-windows-msvc arguments: --target i686-pc-windows-msvc portable: x86 # NSIS only: WiX, which builds the .msi, can't make ARM64 installers. - name: Windows, ARM runner: windows-11-arm arguments: --bundles nsis portable: arm64 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Linux system libraries if: runner.os == 'Linux' # WebKitGTK and friends for Tauri, ALSA headers for cpal, and xdg-open, which the # AppImage bundles; the x64 runner image has it already, the ARM one doesn't. 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 libasound2-dev xdg-utils - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 12 cache: true - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable targets: ${{ matrix.rust-targets }} - name: Install dependencies run: pnpm install --frozen-lockfile # No Rust cache here on purpose: a release is built from a clean slate, so nothing a # pull request's cache picked up can end up inside a shipped installer. - name: Build and upload the installers uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # action-v1.0.0 env: GITHUB_TOKEN: ${{ github.token }} with: # Empty on a manual run, which makes the action build without uploading to a release. releaseId: ${{ needs.prepare.outputs.release-id }} args: ${{ matrix.arguments }} # Honk has no auto-updater, so there's no update manifest to publish. uploadUpdaterJson: false uploadWorkflowArtifacts: ${{ github.event_name == 'workflow_dispatch' }} # The bare executable, for running Honk without installing it. tauri-action can upload it # (uploadPlainBinary), but names it without the version and uploads a bare binary for macOS # and Linux too, where it isn't runnable on its own; so it's renamed and uploaded here. - name: Upload the portable executable if: matrix.portable env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} RELEASE_ID: ${{ needs.prepare.outputs.release-id }} # With --target, Cargo puts the build under target// instead of target/. BINARY: src-tauri/target/${{ matrix.rust-targets && format('{0}/', matrix.rust-targets) || '' }}release/honk.exe ARCH: ${{ matrix.portable }} run: | if [ ! -f "$BINARY" ]; then echo "::error::$BINARY wasn't built." exit 1 fi if [ -z "$RELEASE_ID" ]; then echo "Dry run: $BINARY was built, and isn't uploaded." exit 0 fi # By release ID, like tauri-action: looking the draft up by its tag isn't reliable. name="Honk_${TAG#v}_${ARCH}-portable.exe" curl --fail-with-body --silent --show-error --retry 3 \ -H "Authorization: Bearer $GH_TOKEN" -H "Content-Type: application/octet-stream" \ --data-binary "@$BINARY" \ "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$name" \ > /dev/null # Every build job passing doesn't prove anything was attached: a job that uploads nothing # still succeeds. So the release itself is checked for every installer. verify: name: Check the release has every installer needs: [prepare, build] if: github.ref_type == 'tag' runs-on: ubuntu-latest timeout-minutes: 5 permissions: # Only read here, but GitHub shows draft releases only to tokens with push access. contents: write steps: - name: Check the attached files env: GH_TOKEN: ${{ github.token }} RELEASE_ID: ${{ needs.prepare.outputs.release-id }} TAG: ${{ github.ref_name }} run: | # Only the files named with this version count. The copies without a version, which the # next job adds, would otherwise satisfy these patterns on a re-run. names=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate --jq '.[].name' \ | grep -F "${TAG#v}" || true) echo "$names" status=0 for pattern in \ '_universal\.dmg$' \ '_x64_en-US\.msi$' '_x64-setup\.exe$' '_x86_en-US\.msi$' '_x86-setup\.exe$' '_arm64-setup\.exe$' \ '_x64-portable\.exe$' '_x86-portable\.exe$' '_arm64-portable\.exe$' \ '_amd64\.deb$' '\.x86_64\.rpm$' '_amd64\.AppImage$' \ '_arm64\.deb$' '\.aarch64\.rpm$' '_aarch64\.AppImage$'; do if ! grep -Eq "$pattern" <<< "$names"; then echo "::error::The release has no file matching $pattern" status=1 fi done exit $status # A copy of each main installer without the version in its name, such as Honk_universal.dmg, so # https://github.com/itriumid/honk/releases/latest/download/ always downloads the newest # release. itrium.id/honk links to these; the versioned files stay what Homebrew and winget use. stable-names: name: Add copies without the version needs: [prepare, verify] if: github.ref_type == 'tag' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - name: Copy the installers env: GH_TOKEN: ${{ github.token }} RELEASE_ID: ${{ needs.prepare.outputs.release-id }} VERSION: ${{ github.ref_name }} run: | VERSION="${VERSION#v}" assets=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \ --jq '.[] | "\(.id) \(.name)"') status=0 for name in \ "Honk_${VERSION}_universal.dmg" \ "Honk_${VERSION}_x64-setup.exe" "Honk_${VERSION}_x86-setup.exe" "Honk_${VERSION}_arm64-setup.exe" \ "Honk_${VERSION}_amd64.deb" "Honk-${VERSION}-1.x86_64.rpm" "Honk_${VERSION}_amd64.AppImage" \ "Honk_${VERSION}_arm64.deb" "Honk-${VERSION}-1.aarch64.rpm" "Honk_${VERSION}_aarch64.AppImage"; do # Honk_0.2.0_universal.dmg becomes Honk_universal.dmg; Honk-0.2.0-1.x86_64.rpm becomes # Honk.x86_64.rpm. stable="${name/_${VERSION}_/_}" stable="${stable/-${VERSION}-1./.}" id=$(awk -v name="$name" '$2 == name { print $1 }' <<< "$assets") if [ -z "$id" ]; then echo "::error::The release has no $name to copy." status=1 continue fi if grep -q " $stable\$" <<< "$assets"; then echo "$stable is already attached." continue fi # Downloaded by asset ID: a draft release can't be downloaded by its tag. gh api -H 'Accept: application/octet-stream' \ "repos/$GITHUB_REPOSITORY/releases/assets/$id" > "$stable" curl --fail-with-body --silent --show-error --retry 3 \ -H "Authorization: Bearer $GH_TOKEN" -H "Content-Type: application/octet-stream" \ --data-binary "@$stable" \ "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$stable" \ > /dev/null echo "Attached $stable" rm "$stable" done exit $status