Files
neilcawseandClaude Opus 5.5 0485e7f4ba Packaging: offer GridCraft for .xlsb and .ods files on macOS
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>
2026-10-12 20:52:03 -04:00

19 KiB
Raw Permalink Blame History

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 binary gridcraft) and apps/gridcraft-web (trunk site) must be workspace members. apps/gridcraft-cli exists and supports --version.
  • Windows resources: move packaging/windows/app-build.rs.in to apps/gridcraft/build.rs and add winresource = "0.1" under [build-dependencies] (instructions at the top of the file). package.ps1 sets GRIDCRAFT_REQUIRE_WINRES=1, so a release build without the icon fails, and it checks that gridcraft.exe is a GUI-subsystem binary, so main.rs needs #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")].
  • Build info: CI exports GRIDCRAFT_BUILD_SHA and GRIDCRAFT_BUILD_DATE; read them with option_env! (fallback: "dev build") for --version and Help › About GridCraft.
  • Runtime icon: embed assets/app-icon/gridcraft-macos-512.png (macOS Dock) and hicolor/256x256/apps/ai.storyteller.gridcraft.png (Windows/Linux) as the window icon, and use ai.storyteller.gridcraft as the Wayland app id / StartupWMClass.
  • cargo xtask alias: the docs use cargo xtask …, which needs .cargo/config.toml with [alias] xtask = "run --package xtask --". Until it exists, use cargo run -p xtask -- …. The workflows already call cargo run -p xtask directly.

Cutting a release

  1. Bump the version on main. The only place it lives is [workspace.package] version in the root Cargo.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.lock
    

    Commit the change (Cargo.toml + Cargo.lock) through the normal review flow.

  2. Merge main into release (or fast-forward it) and push. The workflow starts by itself.

  3. Wait for the draft. Notarization and the FreeBSD VM are the slow parts. The Releases page then has a draft GridCraft v0.2.0, tagged v0.2.0 on the pushed commit, with every artifact and SHA256SUMS.txt. The notes are generated from the merged PRs.

  4. 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.

  5. Add the parity delta to the notes: git diff v<previous> v<this> -- docs/parity-checklist.md (regenerated by cargo xtask parity).

  6. Publish the draft in the GitHub UI. Publishing creates the v0.2.0 tag. 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.plist comes from Info.plist.in: bundle id ai.storyteller.gridcraft, LSMinimumSystemVersion 11.0, NSHighResolutionCapable, category productivity, and CFBundleDocumentTypes for .xlsx, .xlsm, .xlsb, .ods, .csv and .tsv with rank Alternate, so GridCraft is offered in Open With without taking over Numbers' (or another app's) defaults. .xlsm and .xlsb get imported UTIs in case macOS doesn't know them.
  • Signing goes inside-out with the hardened runtime and a secure timestamp; no --deep on the final signature; entitlements.plist is deliberately empty.
  • Notarization: the app is zipped and sent with xcrun notarytool submit --wait, then stapled. The DMG (with an Applications link) is signed, notarized and stapled too, and checked with codesign --verify --strict, stapler validate and spctl -a -vvv. Its Finder window (background, icon size and positions) comes from packaging/macos/dmg/, and its volume is named GridCraft without the version, which the window's background needs; the DMG file name keeps the version.
  • CLI: the universal gridcraft-cli is 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+R gridcraft). File types: ProgIds GridCraft.Workbook (.xlsx, .xlsm) and GridCraft.TextData (.csv, .tsv), listed under each extension's OpenWithProgids and in Capabilities + RegisteredApplications, so GridCraft appears in Open with and Settings › Default apps but never silently becomes the default. The MSI version is the numeric X.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=1 to opt in (or DESKTOPSHORTCUT=0 to 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 uses HKMU, which resolves to HKLM for this per-machine package; it leaves no installing-user registry marker. Unlike a literal HKLM key path, MSI registry root -1 also meets ICE43's shortcut rule without suppressing validation (WiX registry roots, ICE43). Its packaging/windows/test-desktop-shortcut.ps1 script 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 (or gh 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.ps1 uses signtool with 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), .deb and .rpm (nfpm, nfpm.yaml) and .tar.gz from 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 (see nfpm.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 to packaging/, 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 (on release pushes): 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 from release).

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).