GridCraft imports XLSB (#101) and ODS (#100) files, and #321 added both to the Linux and Windows "Open with" lists, but the macOS Info.plist still declared only .xlsx, .xlsm, .csv and .tsv, so Finder's Open With didn't offer GridCraft for them. Add two CFBundleDocumentTypes entries (Editor, rank Alternate) with the UTIs macOS reports for these files, plus an imported UTI for .xlsb like the .xlsm one. A test now checks that the macOS and Windows packaging offer the same file types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 KiB
Releasing GridCraft
Every push to the release branch runs .github/workflows/release.yml. The workflow builds
signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates or updates a
draft GitHub Release named GridCraft v<version>. Nobody sees a draft until a maintainer
publishes it.
This is GridCraft's implementation of the shared release playbook
(craftrules release/playbook.md), copied from DesignCraft and renamed. User-facing names say
GridCraft. Files, binaries and ids stay lowercase
(gridcraft-<version>-<platform>-<arch>.<ext>, ai.storyteller.gridcraft).
| App-specific value | Where |
|---|---|
Display name GridCraft, binaries gridcraft + gridcraft-cli |
every script and workflow |
Bundle / app id ai.storyteller.gridcraft |
packaging/macos/Info.plist.in, packaging/linux/*, flatpak manifest |
WiX UpgradeCode 5511327B-F305-41BE-92D0-EB84944E7C4D (fixed forever) |
packaging/windows/gridcraft.wxs |
File types .xlsx .xlsm .xlsb .ods .csv .tsv |
Info.plist (CFBundleDocumentTypes), WiX (ProgIds/OpenWithProgids), .desktop MimeType=, ai.storyteller.gridcraft.mime.xml, metainfo |
Web app dir apps/gridcraft-web |
packaging/web/package.sh |
App colour #1f9d55 (ink #147a40), proposed |
assets/app-icon/README.md, metainfo <branding> |
Prerequisites still owned by the app crates
The pipeline references crates that don't exist yet; the first release run fails until they do:
apps/gridcraft(the GUI binarygridcraft) andapps/gridcraft-web(trunk site) must be workspace members.apps/gridcraft-cliexists and supports--version.- Windows resources: move
packaging/windows/app-build.rs.intoapps/gridcraft/build.rsand addwinresource = "0.1"under[build-dependencies](instructions at the top of the file).package.ps1setsGRIDCRAFT_REQUIRE_WINRES=1, so a release build without the icon fails, and it checks thatgridcraft.exeis a GUI-subsystem binary, somain.rsneeds#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]. - Build info: CI exports
GRIDCRAFT_BUILD_SHAandGRIDCRAFT_BUILD_DATE; read them withoption_env!(fallback: "dev build") for--versionand Help › About GridCraft. - Runtime icon: embed
assets/app-icon/gridcraft-macos-512.png(macOS Dock) andhicolor/256x256/apps/ai.storyteller.gridcraft.png(Windows/Linux) as the window icon, and useai.storyteller.gridcraftas the Wayland app id /StartupWMClass. cargo xtaskalias: the docs usecargo xtask …, which needs.cargo/config.tomlwith[alias] xtask = "run --package xtask --". Until it exists, usecargo run -p xtask -- …. The workflows already callcargo run -p xtaskdirectly.
Cutting a release
-
Bump the version on
main. The only place it lives is[workspace.package] versionin the rootCargo.toml:cargo xtask version # prints the current version, e.g. 0.1.0 cargo xtask version set 0.2.0 # or 0.2.0-rc.1; updates Cargo.toml and Cargo.lockCommit the change (
Cargo.toml+Cargo.lock) through the normal review flow. -
Merge
mainintorelease(or fast-forward it) and push. The workflow starts by itself. -
Wait for the draft. Notarization and the FreeBSD VM are the slow parts. The Releases page then has a draft
GridCraft v0.2.0, taggedv0.2.0on the pushed commit, with every artifact andSHA256SUMS.txt. The notes are generated from the merged PRs. -
Check it. Download an installer or two and read the job summaries. Any
::warning::there means a signing secret was missing and that artifact is unsigned. -
Add the parity delta to the notes:
git diff v<previous> v<this> -- docs/parity-checklist.md(regenerated bycargo xtask parity). -
Publish the draft in the GitHub UI. Publishing creates the
v0.2.0tag. Versions with a pre-release suffix (-rc.1) are marked as pre-releases.
Pushing to release again before you publish rebuilds the same draft and replaces its assets.
After the draft is published, the workflow refuses to touch that version again, so bump first.
Test runs: Actions → Release → Run workflow runs the whole pipeline by hand. The optional
version input (such as 0.2.0-rc.1) overrides Cargo.toml for that run only; each job
applies it with cargo xtask version set before building, so the binaries report it too. The
signing jobs and the draft release need the release environment, which only the release
branch can use. Run from any other branch, it's a dry run: Linux, Flatpak, FreeBSD and web build
and upload their artifacts, the environment refuses macOS and Windows, and no release is drafted
(gh workflow run release.yml --ref <branch>).
What gets built
| Platform | Artifacts | Built on |
|---|---|---|
| macOS 11+ (universal: Apple silicon + Intel) | gridcraft-<v>-macos-universal.dmg, gridcraft-cli-<v>-macos-universal.zip |
macos-15 |
| Windows 10+ x64 | gridcraft-<v>-windows-x64.msi, gridcraft-<v>-windows-x64-portable.zip |
windows-latest |
| Windows 10+ x86 (32-bit) | gridcraft-<v>-windows-x86.msi, gridcraft-<v>-windows-x86-portable.zip |
windows-latest |
| Windows 11 ARM64 | gridcraft-<v>-windows-arm64.msi, …-portable.zip (cross-compiled, signed like x64) |
windows-latest; installed and run on windows-11-arm by windows-arm64.yml |
| Linux x86_64 | gridcraft-<v>-linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz} |
ubuntu-22.04 |
| Linux aarch64 | gridcraft-<v>-linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz} |
ubuntu-22.04-arm |
| Linux riscv64 | gridcraft-<v>-linux-riscv64.tar.gz (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) |
ubuntu-24.04 |
| Flatpak x86_64, aarch64 | gridcraft-<v>-linux-<arch>.flatpak (repackages the Linux tarball) |
ubuntu-24.04, ubuntu-24.04-arm |
| FreeBSD 14 x86_64 | gridcraft-<v>-freebsd-x86_64.tar.gz |
FreeBSD 14.3 VM (freebsd.yml, called by release.yml) |
| Web | gridcraft-web-<v>.zip (static site; see packaging/web/README.md) |
ubuntu-latest |
macOS
packaging/macos/package.sh builds aarch64-apple-darwin and x86_64-apple-darwin with
MACOSX_DEPLOYMENT_TARGET=11.0, joins them with lipo, and assembles GridCraft.app:
Info.plistcomes fromInfo.plist.in: bundle idai.storyteller.gridcraft,LSMinimumSystemVersion11.0,NSHighResolutionCapable, category productivity, andCFBundleDocumentTypesfor.xlsx,.xlsm,.xlsb,.ods,.csvand.tsvwith rank Alternate, so GridCraft is offered in Open With without taking over Numbers' (or another app's) defaults..xlsmand.xlsbget imported UTIs in case macOS doesn't know them.- Signing goes inside-out with the hardened runtime and a secure timestamp; no
--deepon the final signature;entitlements.plistis deliberately empty. - Notarization: the app is zipped and sent with
xcrun notarytool submit --wait, then stapled. The DMG (with anApplicationslink) is signed, notarized and stapled too, and checked withcodesign --verify --strict,stapler validateandspctl -a -vvv. Its Finder window (background, icon size and positions) comes frompackaging/macos/dmg/, and its volume is namedGridCraftwithout the version, which the window's background needs; the DMG file name keeps the version. - CLI: the universal
gridcraft-cliis signed, zipped and notarized.
Locally, without certificates, the script signs ad-hoc (codesign -s -) and skips notarization:
packaging/macos/package.sh # universal; needs both rustup targets
packaging/macos/package.sh --arch aarch64 # quicker, host-only
open dist/release/gridcraft-*-macos-*.dmg
Windows
packaging/windows/package.ps1 -Arch x64|x86|arm64 builds with -C target-feature=+crt-static
(no VC++ redistributable needed), checks both PE headers (machine matches -Arch; gridcraft.exe
is GUI, gridcraft-cli.exe console), signs both executables, builds the MSI with WiX v5, signs
the MSI, and zips a portable build.
gridcraft.wxs: per-machine install into Program Files, advertised Start Menu shortcut, App Paths (Win+Rgridcraft). File types: ProgIdsGridCraft.Workbook(.xlsx,.xlsm) andGridCraft.TextData(.csv,.tsv), listed under each extension'sOpenWithProgidsand inCapabilities+RegisteredApplications, so GridCraft appears in Open with and Settings › Default apps but never silently becomes the default. The MSI version is the numericX.Y.Z; same-version upgrades let release candidates replace each other.- Desktop shortcut: full installer UI offers an unchecked checkbox on a new installation.
Unattended installs can use
msiexec /i gridcraft.msi /qn DESKTOPSHORTCUT=1to opt in (orDESKTOPSHORTCUT=0to opt out). The choice is stored per machine and reused on upgrades; repair retains the installed component choice, and uninstall removes the managed shortcut. The Start Menu shortcut is always installed. Run the Packaging lint workflow’s Windows job to build small fixture installers and check installation, repair, upgrade, opt-out and cleanup, including removal by SYSTEM after installation by the runner account. The shortcut marker usesHKMU, which resolves toHKLMfor this per-machine package; it leaves no installing-user registry marker. Unlike a literalHKLMkey path, MSI registry root -1 also meets ICE43's shortcut rule without suppressing validation (WiX registry roots, ICE43). Itspackaging/windows/test-desktop-shortcut.ps1script requires a GitHub-hosted Windows runner. - Installer review images: the same Windows job renders the compiled MSI's dialogs through
Windows Installer's native preview API and uploads PNGs plus install logs in
desktop-shortcut-msi-evidence, on success or failure. These are native dialog previews; the separate silent lifecycle test verifies installation behavior. Normal full UI presents the shortcut choice and then completion; cancel/failure and files-in-use dialogs are support paths. To review on a maintainer branch, run Packaging lint with that branch selected (orgh workflow run packaging-lint.yml --repo storytold/gridcraft --ref BRANCH). No release environment, signing credentials or Rust build is required. Interactive checkbox behavior still needs a Windows desktop check; previews alone do not establish that behavior. - Signing:
packaging/windows/sign.ps1usessigntoolwith SHA-256 and an RFC 3161 timestamp, from a.pfx(WINDOWS_CERTIFICATE*) if present, otherwise Azure Trusted Signing (AZURE_*, the storytold setup). With neither it warns and leaves files unsigned.
Locally on Windows: dotnet tool install -g wix --version 5.0.2, then
pwsh packaging/windows/package.ps1 -Arch x64.
Linux
packaging/linux/package.sh stages one FHS tree (both binaries,
ai.storyteller.gridcraft.desktop with MimeType= for the four types, hicolor icons 16–512 px
- scalable SVG, AppStream metainfo, shared-mime-info) and builds the AppImage (appimagetool),
.deband.rpm(nfpm,nfpm.yaml) and.tar.gzfrom it. Built on Ubuntu 22.04, so the binaries need glibc ≥ 2.35. X11/Wayland/xkbcommon/Vulkan/EGL are loaded at runtime; the packages declare them (seenfpm.yaml).
Each AppImage embeds update information
(gh-releases-zsync|storytold|gridcraft|latest|gridcraft-*-linux-<arch>.AppImage.zsync), and the
matching .zsync is published beside it, so AppImageUpdate and AppImageLauncher fetch only the
changed blocks of the newest published release. appimagetool writes the .zsync when
zsyncmake (the zsync package) is installed; without it the script warns and skips it.
Flatpak: packaging/linux/flatpak/ai.storyteller.gridcraft.yml builds from source and is ready
for a Flathub submission (freedesktop 25.08; Wayland + X11 fallback, dri, IPC; Documents and
Downloads; everything else through portals); build it by hand with the commands in its header.
The single-file .flatpak on each release comes from packaging/linux/flatpak-bundle.sh and
ai.storyteller.gridcraft.bundle.yml, which repackage the release's Linux tarball (no Rust
build) and smoke-test the result in the sandbox. packaging-lint checks that both manifests
agree on runtime and finish-args.
Locally (on Linux): install nfpm, then
packaging/linux/package.sh (or --formats "deb tar").
FreeBSD
GitHub has no FreeBSD runners. .github/workflows/freebsd.yml boots a FreeBSD 14.3 VM
(vmactions/freebsd-vm). Run by hand from release, it builds and tests the workspace; called
from release.yml with package: true it runs packaging/freebsd/package.sh, which builds the
release binaries and writes gridcraft-<v>-freebsd-x86_64.tar.gz (bin/ + share/, extract
under /usr/local). The target dir lives outside the synced checkout so only dist/release
comes back from the VM. No signing (checksums are in SHA256SUMS.txt).
Web
packaging/web/package.sh runs trunk build --release --dist dist/web --public-url ./ in
apps/gridcraft-web and zips the site with sample _headers / .htaccess and the hosting guide
(packaging/web/README.md). Relative URLs only, so it works under
any path and in an iframe; the script fails on root-absolute URLs.
Secrets and GitHub setup (org admin, once)
All signing secrets live in the repository's release environment, deployable only from the
release branch; a ruleset restricts who can push that branch. Every job in release.yml
declares environment: release; the FreeBSD job is a called workflow and needs no secrets.
Each secret is optional: if one is missing that platform's artifacts are unsigned and the run
shows a ::warning::. Never store these as org- or repo-level Actions secrets (playbook §3).
| Secret | Used for |
|---|---|
APPLE_CERTIFICATE |
single-line base64 .p12: "Developer ID Application: Learning Machines LLC (DJ6XS33FX8)" |
APPLE_CERTIFICATE_PASSWORD |
password for that .p12 |
KEYCHAIN_PASSWORD |
password for the temporary CI keychain (random if unset) |
APPLE_ID |
Apple ID used by notarytool |
APPLE_PASSWORD |
app-specific password for that Apple ID |
APPLE_TEAM_ID |
DJ6XS33FX8 |
WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD |
.pfx signing (unused by storytold; Azure is used) |
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET |
service principal for Azure Trusted Signing |
AZURE_SIGNING_ENDPOINT |
https://eus.codesigning.azure.net |
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE |
LearningMachinesInc, learningmachines |
GITHUB_TOKEN creates the release; only the final job gets contents: write.
The exact commands (from craftrules release/signing-setup.md, steps 1–3), run by an org
admin. Done for storytold/gridcraft on 2026-10-07; the release-managers team also has write
access to the repo, which its ruleset bypass needs.
REPO=storytold/gridcraft
RMTEAM_ID=19837241 # release-managers team; `gh api orgs/storytold/teams/release-managers --jq .id`
# 1a. release branch from the default branch head
gh api repos/$REPO/git/ref/heads/release >/dev/null 2>&1 || \
gh api --method POST repos/$REPO/git/refs -f ref=refs/heads/release \
-f sha="$(gh api repos/$REPO/git/ref/heads/$(gh api repos/$REPO --jq .default_branch) --jq .object.sha)"
# 1b. release environment, deployable only from the release branch
printf '{"deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}' \
| gh api --method PUT repos/$REPO/environments/release --input -
gh api --method POST repos/$REPO/environments/release/deployment-branch-policies -f name=release -f type=branch >/dev/null 2>&1 || true
# 1c. ruleset: only org admins + release-managers may create/update/delete/force-push release
cat > /tmp/ruleset.json <<JSON
{ "name":"release","target":"branch","enforcement":"active",
"conditions":{"ref_name":{"include":["refs/heads/release"],"exclude":[]}},
"rules":[{"type":"creation"},{"type":"update"},{"type":"deletion"},{"type":"non_fast_forward"}],
"bypass_actors":[{"actor_id":0,"actor_type":"OrganizationAdmin","bypass_mode":"always"},
{"actor_id":$RMTEAM_ID,"actor_type":"Team","bypass_mode":"always"}] }
JSON
gh api --method POST repos/$REPO/rulesets --input /tmp/ruleset.json >/dev/null 2>&1 || echo "ruleset exists"
# 2. sync the twelve secrets into the release environment (run in ~/secrets/storytold/)
for s in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID KEYCHAIN_PASSWORD \
AZURE_TENANT_ID AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_SIGNING_ENDPOINT AZURE_SIGNING_ACCOUNT AZURE_CERT_PROFILE; do
[ -f "$s.txt" ] && gh secret set "$s" --env release --repo "$REPO" < "$s.txt" && echo " set $s" || echo " MISSING $s.txt"
done
gh secret list --env release --repo "$REPO" --json name --jq '[.[].name]|sort|join(", ")'
# 3. test build (rc version so it can't collide with a real release)
gh workflow run release.yml --repo $REPO --ref release -f version=0.1.0-rc.1
RID=$(gh run list --repo $REPO -w release.yml -L1 --json databaseId --jq '.[0].databaseId')
until [ "$(gh run view $RID --repo $REPO --json status --jq .status)" = completed ]; do sleep 60; done
gh run view $RID --repo $REPO --json conclusion,jobs --jq '.conclusion, (.jobs[]|"[\(.conclusion)] \(.name)")'
In the macOS job log expect status: Accepted and The staple and validate action worked!; in
the Windows jobs, Successfully signed and a passing signtool verify /pa. Then check every
artifact installs and launches: Gatekeeper accepts the DMG with no warning, SmartScreen shows
the publisher.
Icons
assets/app-icon/gridcraft.svg is a placeholder (an original ledger-page drawing on
GridCraft green) until the owner makes GridCraft's engraved creature portrait (craftrules
standards/icon-design.md). packaging/icons.sh regenerates the 1024 px PNG, the macOS 512 px
PNG, the .icns (iconutil), the .ico (cargo xtask ico) and the hicolor PNGs from it; it
needs resvg. The outputs are committed, so packaging never needs those tools. Every file has a
row in ATTRIBUTION.md (cargo xtask assets).
Checks
.github/workflows/packaging-lint.yml(seconds, on changes topackaging/, the workflows or the icons): actionlint, shellcheck, a PowerShell parse, xmllint, a WiX icon-id check,desktop-file-validate,appstreamcli validate, and a Flatpak manifest check..github/workflows/windows-arm64.yml(onreleasepushes): packages ARM64 on x64, then installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner..github/workflows/freebsd.yml: FreeBSD build + tests (manual run fromrelease).
Only the release branch builds. No build workflow runs on main or pull requests; every
build job checks github.ref == 'refs/heads/release', so a manual run from another branch is
skipped. Only packaging-lint.yml (no build, no secrets) runs on main and PRs.
- Locally:
cargo xtask ci(fmt, clippy, tests, assets, layers, wasm, parity check).