# 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`. Nobody sees a draft until a maintainer publishes it. This is GridCraft's implementation of the shared [release playbook](release-playbook.md) (craftrules `release/playbook.md`), copied from DesignCraft and renamed. User-facing names say **GridCraft**. Files, binaries and ids stay lowercase (`gridcraft---.`, `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` `.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 `` | ## 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`: ```sh 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 v -- 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 `). ## What gets built | Platform | Artifacts | Built on | |---|---|---| | macOS 11+ (universal: Apple silicon + Intel) | `gridcraft--macos-universal.dmg`, `gridcraft-cli--macos-universal.zip` | `macos-15` | | Windows 10+ x64 | `gridcraft--windows-x64.msi`, `gridcraft--windows-x64-portable.zip` | `windows-latest` | | Windows 10+ x86 (32-bit) | `gridcraft--windows-x86.msi`, `gridcraft--windows-x86-portable.zip` | `windows-latest` | | Windows 11 ARM64 | `gridcraft--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--linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `gridcraft--linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | | Linux riscv64 | `gridcraft--linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` | | Flatpak x86_64, aarch64 | `gridcraft--linux-.flatpak` (repackages the Linux tarball) | `ubuntu-24.04`, `ubuntu-24.04-arm` | | FreeBSD 14 x86_64 | `gridcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM (`freebsd.yml`, called by `release.yml`) | | Web | `gridcraft-web-.zip` (static site; see [`packaging/web/README.md`](../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`, `.csv` and `.tsv` with rank **Alternate**, so GridCraft is offered in *Open With* without taking over Numbers' (or another app's) defaults. `.xlsm` gets an imported UTI in case macOS doesn't know it. - **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/`](../packaging/macos/dmg/README.md), 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: ```sh 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](https://docs.firegiant.com/wix/schema/wxs/registryvalue/), [ICE43](https://learn.microsoft.com/windows/win32/msi/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-.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](https://nfpm.goreleaser.com/install/), 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--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`](../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. ```sh 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 </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).