Release #94
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Triggered on a `v*` tag (cuts a real release) or manually | |
| # (test the workflow without publishing — outputs go to artifacts only). | |
| # | |
| # The manual mode did not work until 2026-08-04: every installer job | |
| # took its version from `github.ref_name`, which on a dispatch is the | |
| # *branch* name. All four failed on it, and each in its own dialect — | |
| # WiX refused `feat/plugin` as a ProductVersion, and the AppImage's | |
| # output path picked up the `/` and came out as a mksquashfs error that | |
| # named nothing. So the one mode meant for rehearsing a release could | |
| # never rehearse it. Off a tag the version is the tag; off anything | |
| # else it is `0.0.0-dev`, which every builder accepts and nobody could | |
| # mistake for a release. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| env: | |
| CARGO_TERM_COLOR: always | |
| CARGO_INCREMENTAL: 0 | |
| CARGO_NET_RETRY: 10 | |
| RUSTUP_MAX_RETRIES: 10 | |
| # A note on caching here. These jobs run on `v*` tags, and a cache saved | |
| # under a tag's scope can never be restored again — the next tag is a | |
| # different ref, and a run may only read its own ref's caches plus the | |
| # default branch's. So target-dir caching in this workflow was writing | |
| # multi-gigabyte entries that nothing would ever hit, while counting | |
| # against the repo-wide 10 GB budget and evicting the CI caches that | |
| # every PR *does* restore from. `cache-targets: false` keeps the cheap | |
| # half (the registry, so we stop re-downloading crates.io) and drops the | |
| # expensive, useless half. A release rebuilds from source regardless — | |
| # `lto = "thin"` + `codegen-units = 1` recompile the world anyway. | |
| # Per-platform packaging is intentionally split across three jobs | |
| # instead of being squeezed into a matrix-with-`if`s — the steps are | |
| # different enough (WiX vs lipo vs linuxdeploy) that one shared job | |
| # would just be a forest of `if: matrix.os == ...`. Three small jobs | |
| # make each pipeline readable, parallelise cleanly, and let a single | |
| # platform fail without holding up the others. | |
| # | |
| # The installers are *unsigned* (no Apple Developer ID, no Windows | |
| # code-signing cert in this phase). The release notes call out the | |
| # Gatekeeper / SmartScreen workaround so users know what to do. | |
| # | |
| # About the Node.js 20 deprecation warnings GitHub emits at the top | |
| # of each run: those come from third-party actions | |
| # (`Swatinem/rust-cache`, `dtolnay/rust-toolchain`, | |
| # `softprops/action-gh-release`) whose maintainers haven't yet | |
| # shipped Node 24-based releases. They're warnings, not failures — | |
| # GitHub gave a long migration window and the upstreams will catch | |
| # up. Pinning to development branches just to silence the warning | |
| # would trade a *known-good* surface for a moving target. Revisit | |
| # once stable v3+ tags exist. | |
| jobs: | |
| # ─── Linux: AppImage (x86_64 + aarch64) ────────────────────────────── | |
| # | |
| # A matrix here and not three jobs, unlike the OS split below: the | |
| # steps are byte-for-byte the same, only the runner and the triple | |
| # change. Both build natively — GitHub's `ubuntu-24.04-arm` runners | |
| # are free for public repositories, and native beats cross-compiling | |
| # for anything that links system libraries (GTK, X11, Wayland) and | |
| # then has to be packaged by a tool that is itself an AppImage. | |
| # | |
| # aarch64 is the *only* extra artifact we take on. Every installer is | |
| # a support surface — a build to keep green, a download to explain, | |
| # a self-update path to get right — so armv7, ARM Windows and the | |
| # rest stay out until someone turns up with the hardware and the | |
| # demand. `poltertype-update::platform_key()` and the `latest.json` | |
| # step below must always name exactly the set published here. | |
| appimage: | |
| name: AppImage (${{ matrix.arch }}) | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: x86_64 | |
| runner: ubuntu-latest | |
| - arch: aarch64 | |
| runner: ubuntu-24.04-arm | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.arch }}-unknown-linux-gnu | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| cache-targets: false | |
| - name: Install Linux native deps | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| pkg-config libdbus-1-dev libudev-dev \ | |
| libxkbcommon-dev libxkbcommon-x11-dev \ | |
| libwayland-dev libx11-dev libxi-dev libxtst-dev libxdo-dev \ | |
| libgtk-3-dev libayatana-appindicator3-dev libasound2-dev \ | |
| file desktop-file-utils | |
| - name: Build release binary | |
| # `ai,poltertype-ai/remote` ships the AI subsystem compiled in — | |
| # dormant until the user writes an `[[ai.plugins]]` entry; see | |
| # docs/AI.md for the full opt-in ladder. All four installers must | |
| # pass the same feature set or platforms silently diverge. | |
| run: cargo build --release --locked --features ai,poltertype-ai/remote --target ${{ matrix.arch }}-unknown-linux-gnu -p poltertype-app | |
| - name: Render app icon | |
| # linuxdeploy wants a square PNG ≥ 256×256. We render at the | |
| # exact size we need — `cargo xtask assets icon-png` is the | |
| # only image tool we depend on, so no `magick` / `convert` | |
| # needed (ubuntu-latest doesn't ship ImageMagick v7's `magick` | |
| # binary). If the mark ever ships as a checked-in PNG instead, | |
| # swap this step for a copy of that file. | |
| run: | | |
| mkdir -p target/dist | |
| cargo xtask assets icon-png target/dist/icon-256.png --size 256 | |
| - name: Build AppImage | |
| env: | |
| VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '0.0.0-dev' }} | |
| ARCH: ${{ matrix.arch }} | |
| ICON_PNG: target/dist/icon-256.png | |
| OUT_DIR: target/dist | |
| run: bash installers/linux/build-appimage.sh | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: appimage-${{ matrix.arch }} | |
| path: target/dist/poltertype-*-${{ matrix.arch }}.AppImage | |
| if-no-files-found: error | |
| # ─── macOS: universal DMG ──────────────────────────────────────────── | |
| dmg: | |
| name: DMG (universal) | |
| # macos-latest is arm64; we cross-compile x86_64 from there. Avoids | |
| # a second runner just for Intel. | |
| runs-on: macos-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: x86_64-apple-darwin,aarch64-apple-darwin | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| cache-targets: false | |
| - name: Build release binaries (both arches) | |
| run: | | |
| cargo build --release --locked --features ai,poltertype-ai/remote --target x86_64-apple-darwin -p poltertype-app | |
| cargo build --release --locked --features ai,poltertype-ai/remote --target aarch64-apple-darwin -p poltertype-app | |
| - name: Render and convert icon | |
| run: | | |
| mkdir -p target/dist | |
| cargo xtask assets icon-png target/dist/icon-1024.png --size 1024 | |
| # Build a .icns from the source PNG using macOS's bundled | |
| # `sips` + `iconutil`. The required 10 sizes per Apple's HIG. | |
| mkdir -p target/dist/AppIcon.iconset | |
| for size in 16 32 128 256 512; do | |
| sips -z $size $size target/dist/icon-1024.png \ | |
| --out target/dist/AppIcon.iconset/icon_${size}x${size}.png | |
| sips -z $((size*2)) $((size*2)) target/dist/icon-1024.png \ | |
| --out target/dist/AppIcon.iconset/icon_${size}x${size}@2x.png | |
| done | |
| iconutil --convert icns target/dist/AppIcon.iconset \ | |
| --output target/dist/AppIcon.icns | |
| - name: Build DMG | |
| env: | |
| VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '0.0.0-dev' }} | |
| BIN_X86_64: target/x86_64-apple-darwin/release/poltertype | |
| BIN_ARM64: target/aarch64-apple-darwin/release/poltertype | |
| ICON_ICNS: target/dist/AppIcon.icns | |
| OUT_DIR: target/dist | |
| run: bash installers/macos/build-dmg.sh | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: dmg-universal | |
| path: target/dist/poltertype-*-universal-apple-darwin.dmg | |
| if-no-files-found: error | |
| # ─── Windows: MSI (x86_64) ─────────────────────────────────────────── | |
| msi: | |
| name: MSI (x86_64) | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: x86_64-pc-windows-msvc | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| cache-targets: false | |
| - name: Install WiX Toolset | |
| # windows-latest doesn't preinstall WiX, but Chocolatey is | |
| # there. The package adds candle.exe / light.exe to PATH after | |
| # this step (next steps see the new PATH). | |
| run: choco install wixtoolset --no-progress -y | |
| shell: pwsh | |
| - name: Build release binary | |
| run: cargo build --release --locked --features ai,poltertype-ai/remote --target x86_64-pc-windows-msvc -p poltertype-app | |
| - name: Render the installer icon | |
| # `poltertype.exe` already carries this icon — its build script | |
| # embeds one drawn from the same geometry, and that is what the | |
| # Start menu, Explorer, the taskbar and Alt-Tab read. This copy | |
| # is only for the MSI's Add/Remove Programs entry, which is a | |
| # property of the *installation* rather than of the file. | |
| # | |
| # Rendered by xtask rather than converted by ImageMagick: every | |
| # size comes straight off the vector mark instead of being | |
| # box-filtered down from one 1024 px master, and CI stops | |
| # depending on `magick` being preinstalled. | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force target/dist | Out-Null | |
| cargo xtask assets icon-ico target/dist/AppIcon.ico | |
| - name: Build MSI | |
| shell: pwsh | |
| env: | |
| VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '0.0.0-dev' }} | |
| BIN_PATH: target/x86_64-pc-windows-msvc/release/poltertype.exe | |
| ICON_PATH: target/dist/AppIcon.ico | |
| OUT_DIR: target/dist | |
| run: | | |
| # Reload PATH so the WiX install from the previous step is | |
| # visible (Chocolatey updates the registry, not the current | |
| # session). RefreshEnv is provided by the Chocolatey shim. | |
| $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') | |
| pwsh installers/windows/build-msi.ps1 | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: msi-x86_64 | |
| path: target/dist/poltertype-*-x86_64-pc-windows-msvc.msi | |
| if-no-files-found: error | |
| # ─── Publish: only when triggered by a tag ─────────────────────────── | |
| release: | |
| name: GitHub Release | |
| needs: [appimage, dmg, msi] | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| path: dist | |
| merge-multiple: true | |
| - name: List artifacts | |
| run: ls -la dist/ | |
| # ─── Update manifest ───────────────────────────────────────── | |
| # `latest.json` is what the in-app updater polls. It is written | |
| # here, from the artifacts we are *about* to publish, because | |
| # that is the only place where the checksums and the download | |
| # URLs are both known and guaranteed to agree: the URLs are | |
| # deterministic from the tag and the file name, and the hashes | |
| # come from the exact bytes being uploaded in the next step. | |
| # | |
| # The updater fetches this through GitHub's | |
| # `releases/latest/download/` redirector, which resolves only to | |
| # published, non-prerelease releases. Since we create the release | |
| # as a *draft*, nothing here reaches a single user until a human | |
| # presses Publish — the draft gate is the release process, not an | |
| # afterthought. | |
| # | |
| # Keep the shape in step with `poltertype-update`'s `Manifest` | |
| # struct and its `platform_key()`: the three keys below are | |
| # exactly what that function can produce. | |
| # | |
| # What this step does NOT do is sign the manifest. The updater | |
| # verifies an ed25519 signature over it, and the whole point of | |
| # that signature is to mean something a compromised GitHub | |
| # account cannot forge — so the key is not an Actions secret and | |
| # never enters this runner. Signing is a manual step between this | |
| # draft and the moment a human publishes it; the job summary | |
| # below spells out the two commands, and `docs/RELEASING.md` has | |
| # the full ritual. | |
| - name: Write latest.json | |
| env: | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="${TAG#v}" | |
| BASE="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" | |
| # Fail loudly on a missing artifact rather than shipping a | |
| # manifest with a hole in it: a platform silently absent from | |
| # `artifacts` means every user on that OS quietly stops | |
| # getting updates, which is the kind of bug that is only | |
| # noticed months later. | |
| entry() { | |
| local key="$1" pattern="$2" | |
| local path | |
| path="$(ls dist/${pattern} 2>/dev/null | head -n1)" | |
| if [[ -z "${path}" ]]; then | |
| echo "::error::no artifact matched '${pattern}' — refusing to write a partial manifest" >&2 | |
| exit 1 | |
| fi | |
| local name sha size | |
| name="$(basename "${path}")" | |
| sha="$(sha256sum "${path}" | cut -d' ' -f1)" | |
| size="$(stat -c%s "${path}")" | |
| jq -n --arg k "$key" --arg u "${BASE}/${name}" --arg s "$sha" --argjson z "$size" \ | |
| '{($k): {url: $u, sha256: $s, size: $z}}' | |
| } | |
| { | |
| entry "linux-x86_64" "poltertype-*-x86_64.AppImage" | |
| entry "linux-aarch64" "poltertype-*-aarch64.AppImage" | |
| entry "macos-universal" "poltertype-*-universal-apple-darwin.dmg" | |
| entry "windows-x86_64" "poltertype-*-x86_64-pc-windows-msvc.msi" | |
| } | jq -s \ | |
| --argjson schema 1 \ | |
| --arg version "${VERSION}" \ | |
| --arg notes "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" \ | |
| 'add | {schema: $schema, version: $version, notes_url: $notes, artifacts: .}' \ | |
| > dist/latest.json | |
| echo "── dist/latest.json ──" | |
| cat dist/latest.json | |
| # The draft is not shippable yet and the reason is easy to forget | |
| # a month later, so it goes where the person who just cut the tag | |
| # is already looking. | |
| - name: Remind the maintainer to sign | |
| env: | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| { | |
| echo "## Before publishing \`${TAG}\`" | |
| echo | |
| echo 'The manifest attached to this draft is **unsigned**. Sign it with the' | |
| echo 'release key (which lives on your machine, not here):' | |
| echo | |
| echo 'Run these **from a checkout of this repository** — `gh` infers the repo' | |
| echo 'from the git remote, and `cargo xtask` is an alias from its' | |
| echo '`.cargo/config.toml`. The manifest goes to /tmp so it cannot be committed.' | |
| echo | |
| echo '```bash' | |
| echo "gh release download ${TAG} --pattern latest.json --dir /tmp --clobber" | |
| echo 'cargo xtask manifest sign /tmp/latest.json --key ~/.config/poltertype-signing/release.key' | |
| echo "gh release upload ${TAG} /tmp/latest.json --clobber" | |
| echo '```' | |
| echo | |
| echo '`sign` verifies its own output against the public key compiled into the' | |
| echo 'app, so a mismatch fails there rather than on a user machine.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Create release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| files: | | |
| dist/poltertype-*-x86_64.AppImage | |
| dist/poltertype-*-aarch64.AppImage | |
| dist/poltertype-*-universal-apple-darwin.dmg | |
| dist/poltertype-*-x86_64-pc-windows-msvc.msi | |
| dist/latest.json | |
| draft: true | |
| fail_on_unmatched_files: true | |
| generate_release_notes: true | |
| # Body shown above the autogen'd commit list. Tells users what | |
| # they need to know about *unsigned* builds — the workarounds | |
| # here are the difference between "it works" and "Gatekeeper | |
| # said no, I gave up". | |
| # | |
| # Do NOT reintroduce "beta" here. PolterType has been out of | |
| # beta since v0.1.0 (README, docs/PLAN.md); the installers being | |
| # unsigned is a separate, still-true fact, and conflating the | |
| # two told every visitor the product was less finished than it | |
| # is — on the very page the website's download buttons open. | |
| body: | | |
| ## PolterType ${{ github.ref_name }} | |
| **No signing identity.** The MSI / DMG / AppImage below are | |
| produced by CI on `${{ github.sha }}`. The macOS bundle is | |
| ad-hoc signed — which is what lets it hold Accessibility | |
| permission, not a statement about who built it — and | |
| nothing is notarised; the Windows installer is unsigned. So | |
| both still warn on first launch — see the workarounds below. | |
| ### Install | |
| * **Windows** — download the `.msi` and double-click. Per-user | |
| install, no admin rights needed. SmartScreen may say | |
| "Windows protected your PC"; click **More info** → | |
| **Run anyway**. | |
| * **macOS** — download the `.dmg`, drag `poltertype.app` into | |
| `/Applications`. First launch shows "PolterType cannot be | |
| opened because the developer cannot be verified" — right- | |
| click the app and pick **Open**, or run | |
| `xattr -dr com.apple.quarantine /Applications/poltertype.app` | |
| once. Then grant Accessibility permission in System | |
| Settings — again after every update, since without a | |
| Developer ID macOS identifies the app by the hash of its | |
| own bytes and reads a new version as different software. | |
| * **Linux** — download the `.AppImage` for your | |
| architecture (`x86_64` for a normal PC, `aarch64` for a | |
| Raspberry Pi 5, an Asahi Mac or an ARM server), | |
| `chmod +x` it, run. On Wayland sessions, see | |
| `docs/PERMISSIONS.md` for the evdev permission setup. | |
| See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. |