Skip to content

Release

Release #94

Workflow file for this run

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.