# 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.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` | | 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. - **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).