NC Picto · Docs

Releasing Picto

Every push to the release branch runs .github/workflows/release.yml. The workflow builds signed installers for macOS, Windows, Linux and the web, plus a FreeBSD tarball, then creates or updates a draft GitHub Release named Picto v<version>. Nobody sees a draft until a maintainer publishes it.

This is Picto's implementation of the shared release playbook. User-facing names say Picto. Files, binaries and ids stay lowercase (picto-<version>-<platform>-<arch>.<ext>, com.ncdev.picto).

In-app update checks

The native desktop app checks the public GitHub latest-release endpoint in a background thread once at launch, with no periodic checks or automatic retries. Preferences › General › Automatically Check for Updates disables the startup check; Help › Check for Updates runs one manually. Checks use a 15-second timeout and a 1 MiB response limit, need no GitHub token, and send no document or personal data. Network failures are quiet during automatic checks and visible in the manual check window. Concurrent requests share one check. HTTPS validates certificates and host names against the operating system's trust store.

Tags must be semantic versions (v0.8.0 or 0.8.0); only published stable versions newer than the installed workspace version trigger a notice. The notice opens the official release page to choose an installer; installation remains a user action. Drafts and prereleases do not trigger it. Web/headless frontends and the Flatpak (no network in its sandbox; flatpak update owns updates there) have no update transport. No release-workflow changes are required: publishing a newer stable GitHub Release makes it discoverable.

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. After about 30 to 45 minutes (notarization is the slow part), the Releases page has a draft Picto 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 look at the job summaries. Any ::warning:: there means a signing secret was missing and that artifact is unsigned.

  5. Add the scorecard deltas to the draft's notes: what moved in docs/scorecard.md since the previous release. Compare the summary table and the performance table of both tags (git diff v<previous> v<this> -- docs/scorecard.md) and list each area's done / partial / missing change, the scenarios that newly meet their budget or got slower, corpus floors raised, and the change in settings that do nothing. Quote numbers with the baseline's machine and load average.

  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 it 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. The jobs apply it with cargo xtask version set before building, so the binaries report it too. The run still needs the release environment, which only the release branch can use, so pick that branch in the dialog.

What gets built

Platform Artifacts Built on
macOS 11+ (universal: Apple silicon + Intel) picto-<v>-macos-universal.dmg, picto-cli-<v>-macos-universal.zip macos-15
Windows 10+ x64 picto-<v>-windows-x64.msi, picto-<v>-windows-x64-portable.zip windows-latest
Windows 10+ x86 (32-bit) picto-<v>-windows-x86.msi, picto-<v>-windows-x86-portable.zip windows-latest
Linux x86_64 picto-<v>-linux-x86_64.{AppImage,AppImage.zsync,deb,rpm,tar.gz,flatpak} ubuntu-22.04 (Flatpak: ubuntu-24.04)
Linux aarch64 picto-<v>-linux-aarch64.{AppImage,AppImage.zsync,deb,rpm,tar.gz,flatpak} ubuntu-22.04-arm (Flatpak: ubuntu-24.04-arm)
Linux riscv64 (glibc 2.39+) picto-<v>-linux-riscv64.tar.gz cross-compiled on ubuntu-24.04 (ports.ubuntu.com riscv64 libs); CLI smoke-tested under QEMU
FreeBSD 14 x86_64 picto-<v>-freebsd-x86_64.tar.gz FreeBSD 14.3 VM on ubuntu-latest
Web picto-web-<v>.zip (static site; see packaging/web/README.md) ubuntu-latest

Every binary reports its version, the commit and the build date: picto --version, picto-cli --version, and Help › About Picto. CI sets PICTO_BUILD_SHA and PICTO_BUILD_DATE, and crates/engine/src/build_info.rs reads them at compile time. A plain cargo build doesn't set them and reports 0.2.0 (dev build).

Every desktop build job (macOS, Windows, Linux, FreeBSD) also checks out craft-fonts at the craft-fonts-ref commit in .github/workflows/release.yml and builds with CRAFT_FONTS_DIR and CRAFT_FONTS_REQUIRED=1, so desktop releases embed its Japanese fonts (the web build embeds none: see docs/development.md › Fonts) and fail rather than ship without them. The packages carry each font's licence as OFL-<family>.txt (copy_font_licences in packaging/env.sh; the portable zip on Windows; Contents/Resources/Licenses in the macOS app). Bump the pin deliberately, together with the one in ci.yml. Rules: ../craftrules/standards/fonts.md; build option: docs/development.md › Fonts.

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 Picto.app:

Locally, without certificates, the script signs ad-hoc (codesign -s -) and skips notarization. That's enough to check the bundle and the DMG on your own Mac:

packaging/macos/package.sh                    # universal; needs both rustup targets
packaging/macos/package.sh --arch aarch64     # quicker, host-only
open dist/release/picto-*-macos-*.dmg

Windows

packaging/windows/package.ps1 -Arch x64|x86 builds with -C target-feature=+crt-static. The static C runtime means neither the MSI nor the portable zip needs the Visual C++ redistributable, which matters for a standalone installer and costs only about 100 KB. The flag goes in CARGO_TARGET_<TRIPLE>_RUSTFLAGS, so host build scripts aren't affected.

Locally on Windows: dotnet tool install -g wix --version 5.0.2, wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2, then pwsh packaging/windows/package.ps1 -Arch x64.

Linux

packaging/linux/package.sh stages one FHS tree and makes every format from it. The tree holds both binaries, com.ncdev.picto.desktop, hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a shared-mime-info file for .pcraft, .psb and .qoi.

Why these formats:

All Linux binaries are built on Ubuntu 22.04 and need glibc ≥ 2.35: Ubuntu 22.04+, Debian 12+, Fedora 36+, RHEL 10, openSUSE Tumbleweed. They link only glibc and libgcc_s. X11, Wayland, xkbcommon, Vulkan and EGL are loaded at runtime from the system, which is also where the GPU driver has to come from. That's why the .deb and .rpm declare them as dependencies (the full list, and why each is there, is in packaging/linux/nfpm.yaml) and the AppImage doesn't bundle them. The Flatpak gets them from the freedesktop runtime. Because the AppImage and the tarball can't declare dependencies, picto checks for the libraries its session (X11 or Wayland) needs before it opens a window (apps/picto/src/linux_libs.rs) and, if one is missing, prints the package to install and exits with status 1 instead of crashing. PICTO_SKIP_LIB_CHECK=1 skips the check.

Locally (on Linux): install nfpm, then packaging/linux/package.sh (or --formats "deb tar").

FreeBSD

GitHub has no FreeBSD runners, so the freebsd job runs packaging/freebsd/package.sh in a FreeBSD 14.3 VM (vmactions/freebsd-vm, pinned by commit), with the same packages as the FreeBSD CI workflow (.github/workflows/freebsd.yml) plus bash. The script builds both binaries with CARGO_BUILD_JOBS=4 (more runs the 12 GB VM out of memory) and writes picto-<v>-freebsd-x86_64.tar.gz. FreeBSD's uname -m says amd64; the file name uses x86_64 like the other artifacts. The tarball is a /usr/local-style tree: bin/picto, bin/picto-cli, and under share/ the same desktop entry, MIME type, AppStream metainfo and hicolor icons as Linux, plus the licences, NOTICE and ATTRIBUTION.md in share/doc/picto/. Users install it with:

pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
tar -xzf picto-<v>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local

The job signs nothing, so it runs outside the release environment and gets no secrets. The version, build date and commit reach the VM through the action's envs: (by name, never templated into the script), and the build happens in /var/tmp so only dist/ is copied back out of the VM. The binaries aren't signed: FreeBSD has no code-signing scheme for loose binaries, so check the tarball against SHA256SUMS.txt.

The FreeBSD CI workflow runs the same script after its build and test (on pushes to main, and on PRs that touch packaging/freebsd/, freebsd.yml or release.yml), so the release job isn't the first place it runs. Locally, on any OS, packaging/freebsd/package.sh --dry-run stages the tree from stub binaries and lists the tarball, which checks the layout without a FreeBSD machine.

Web

packaging/web/package.sh runs trunk build --release for the app (apps/picto-web, into dist/web) and for the module that starts it (apps/picto-web-boot, into dist/web-boot). It gzips the app's wasm, cuts it into parts of at most 20 MiB and zips the boot module's page with the parts, sample _headers and .htaccess files and the hosting guide. The site only uses relative URLs, so it works under any path and in an iframe. The wasm builds with the size-optimized wasm-release Cargo profile (set in apps/picto-web/index.html), and the script fails if any file in the site exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit; the app's wasm itself may be larger, since it ships as parts. packaging/web/README.md covers MIME types, compression, caching, the iframe snippet and the ?webgl / ?cpu flags.

Secrets

All secrets live in the repository's release environment (Settings → Environments → release). Restrict its deployment branches to release. Every job in release.yml that signs or publishes declares environment: release (the Flatpak and FreeBSD jobs sign nothing and don't), so only pushes to that branch, or manual runs on it, can read the secrets. Each secret is optional. If one is missing, that platform's artifacts are unsigned and the run shows a ::warning::.

Secret Used for
APPLE_CERTIFICATE base64 .p12 with "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 base64 .pfx code-signing certificate
WINDOWS_CERTIFICATE_PASSWORD password for that .pfx
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET service principal for Azure Trusted Signing (an alternative to the .pfx)
AZURE_SIGNING_ENDPOINT e.g. https://eus.codesigning.azure.net
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE Trusted Signing account and certificate profile names

GITHUB_TOKEN creates the release. Only the final job gets contents: write.

On macOS, packaging/macos/import-cert.sh decodes the .p12 into a temporary keychain (security create-keychain, then security import, then set-key-partition-list so codesign can use the key without a prompt). It exports the identity's SHA-1 as MACOS_SIGN_IDENTITY. The keychain is deleted at the end of the job.

Icons

assets/app-icon/picto.svg is the canonical icon: a geometric "P" with a centre dot, drawn as SVG (MIT OR Apache-2.0, see LICENSE.txt there; palette and geometry in README.md). packaging/icons.sh regenerates the 1024 px PNG, the .icns, the .ico (packed by cargo xtask ico) and the hicolor PNGs from it. It needs resvg, plus iconutil on macOS. The outputs are committed, so packaging never needs those tools.

Checks

.github/workflows/packaging-lint.yml runs in seconds on any change to packaging/, the workflows or the icons. It runs actionlint, shellcheck, a PowerShell parse, xmllint, desktop-file-validate, appstreamcli validate, and a YAML check that the two Flatpak manifests agree on the runtime and permissions.