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
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. After about 30 to 45 minutes (notarization is the slow part), the Releases page has a draft
Picto 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 look at the job summaries. Any
::warning::there means a signing secret was missing and that artifact is unsigned.Add the scorecard deltas to the draft's notes: what moved in
docs/scorecard.mdsince 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.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 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:
Info.plistis generated fromInfo.plist.in. The bundle id iscom.ncdev.picto. The plist setsLSMinimumSystemVersion11.0,NSHighResolutionCapable, and document types:.pcraft(Owner), plus PSD/PSB and the image formats Picto reads (Alternate, so it never takes over Preview's defaults). The icon isassets/app-icon/picto.icns.- Signing goes inside-out with the hardened runtime
and a secure timestamp. The executable is signed first, then the bundle.
There's no
--deepon the final signature. The entitlements (entitlements.plist) are deliberately empty. - Notarization: the app is zipped and sent with
xcrun notarytool submit --wait, then the ticket is stapled to the app. The app goes on a DMG (hdiutil, with anApplicationslink to drag onto). The DMG is signed, notarized and stapled too. The script checks the results withcodesign --verify --strict,stapler validateandspctl -a -vvv. Its Finder window (background, icon size and positions) comes frompackaging/macos/dmg/, and its volume is namedPictowithout the version, which the window's background needs; the DMG file name keeps the version. - CLI: the universal
picto-cliis signed with the same Developer ID, the hardened runtime and a secure timestamp (identifiercom.ncdev.picto-cli), zipped, and the zip is sent tonotarytool. Only.app,.dmgand.pkgcan hold a stapled ticket, not a bare Mach-O, so Gatekeeper looks the CLI's ticket up online the first time a downloaded (quarantined) copy runs. Offline, that first run can be refused until the Mac is online again. - Verification:
packaging/macos/verify.shchecks the artifacts as users download them. It unpacks the CLI zip and requires, for the binary inside,codesign --verify --strict, both architectures, the hardened runtime flag, aDeveloper ID Applicationauthority fromAPPLE_TEAM_ID, a timestamp, andspctl --assess --type installreportingsource=Notarized Developer ID(the same online lookup Gatekeeper does). For the DMG it runscodesign --verify,stapler validateandspctl --type open. The release workflow runs it as its own step after packaging. With signing secrets it fails the job on any miss; without them it only checks signature integrity and warns, likepackage.sh. Run it locally after a build:packaging/macos/verify.sh --arch aarch64.
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.
apps/picto/build.rsandapps/picto-cli/build.rsembed the icon (assets/app-icon/picto.ico) and VERSIONINFO with thewinresourcecrate (build dependency0.1). They only do this when targeting Windows. Elsewhere each script is a no-op, even ifPICTO_REQUIRE_WINRESis set, and the web build doesn't touch that crate. Both set ProductName to Picto, CompanyName to Learning Machines LLC and the same copyright line. The GUI description is "Picto image editor" (picto.exe). The CLI description is "Picto command-line interface", with OriginalFilenamepicto-cli.exeand InternalNamepicto-cli. Version strings are the Cargo package version, so a pre-release tag is kept; the numeric file and product versions are winresource'smajor.minor.patch.0. A missing resource compiler warns, so an optional cross-compile still links.package.ps1setsPICTO_REQUIRE_WINRES=1. The CLI build script then returns an error namingrc.exeorwindresand does not panic.Release builds use the GUI subsystem, so Start Menu launches don't open a console window. The CLI stays console subsystem 3 so its output reaches the terminal.
picto.wxs(WiX v5) is a per-machine install into Program Files with an advertised Start Menu shortcut. Picto becomes the default app for.pcraftand is listed under "Open with" for PSD/PSB and image files. It also registers App Paths (Win+Rpicto). The MSI version is the numericX.Y.Z, because MSI has no pre-release field. Same-version upgrades are allowed so that release candidates replace each other.Double-clicking the MSI opens a setup wizard (WixUI_InstallDir without the licence page): Welcome, install folder (remembered for upgrades in
HKLM\Software\Picto\InstallDir), Ready, a progress page, and a Finish page that says where to find the app, with a ticked "Launch Picto" box. The pages name the full version (picto.en-us.wxl).package.ps1draws the wizard's banner and side bitmaps from the app icon, so no WiX stock art ships.msiexec /qnstill installs silently. Building needs the WiX UI and Util extensions:wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2.Shortcut icon identifiers keep the executable's
.exeextension: MSI uses the identifier as the cached icon filename, and an extensionless filename can render as a blank document icon.packaging/windows/check-icons.ps1checks the references and extensions in CI;package.ps1also validates the built MSI with ICE50 before signing it.Portable zip: it ships
packaging/windows/portable.txtbesidepicto.exe. That marker (or aPicto.portablefile) switches on portable mode: preferences, presets, recovery autosaves and the GPU startup marker go toPictoData\next to the exe instead of%APPDATA%\Picto. If that folder isn't writable the app warns and uses%APPDATA%. The MSI has no marker. The logic is inapps/picto/src/app_dirs.rsand works the same on macOS and Linux.Signing:
packaging/windows/sign.ps1signs both.exefiles and then the.msiwithsigntool, using SHA-256 and an RFC 3161 timestamp. It uses whichever material is present:- a
.pfxfile (WINDOWS_CERTIFICATE,WINDOWS_CERTIFICATE_PASSWORD), timestamped by DigiCert, or - Azure Trusted Signing (
AZURE_*): the script downloads theMicrosoft.Trusted.Signing.Clientdlib and timestamps with Microsoft's server.
The script is the single place to change when the Windows signing setup changes.
- a
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:
AppImage runs on any distribution without installing anything. The download-and-go option and the fallback for distros the packages don't cover. It's built with the maintained
AppImage/appimagetool, whose static runtime doesn't need libfuse2. The packaging script verifies pinned AppImageTool 1.9.1 and type2 runtime 20251108 binaries. For an offline build, pre-seed$CARGO_TARGET_DIR/appimagetool-1.9.1-$ARCH.AppImageand$CARGO_TARGET_DIR/type2-runtime-20251108-$ARCHwith the matching official release assets; the script checks both SHA-256 digests and makes the cached AppImageTool executable after verification; the runtime is only read as data. AnAPPIMAGETOOLoverride must point to that same verified AppImageTool binary and already be executable (chmod +xit before packaging). Each AppImage embeds update information (gh-releases-zsync|…|latest|…, #349) and ships with a.zsyncbeside it, so AppImageUpdate, AppImageLauncher and similar tools can find the next release and download only the blocks that changed. ItsAppRun(packaging/linux/AppRun) also integrates with the desktop on launch: on Wayland the dock icon comes from the compositor resolving the window's app_id against an installed.desktopfile, which an AppImage alone never provides — without this every AppImage run shows the generic Wayland logo (#593). Best effort: it installs~/.local/share/applications/com.ncdev.picto.desktopwithExecrewritten to the AppImage's path (TryExecdropped, the binary isn't on the host PATH) and the hicolor icons beside it, rewriting only when the content changed, and any failure leaves the launch untouched.PICTO_NO_DESKTOP_INTEGRATION=1opts out; to uninstall,rm ~/.local/share/applications/com.ncdev.picto.desktopandfind ~/.local/share/icons/hicolor -name 'com.ncdev.picto*' -delete. The script is covered bypackaging/linux/apprun-test.shin the packaging-lint workflow..deb covers Debian, Ubuntu, Mint, Pop!_OS and elementary. .rpm covers Fedora, RHEL and its clones, and openSUSE. Both integrate with the menu, MIME and icon caches (the
postinst.shhook) and uninstall cleanly. Both are built by nfpm: one maintained, dependency-free tool and one config (nfpm.yaml). It doesn't need cargo-deb and cargo-generate-rpm metadata in our crates, and it can build both formats on Ubuntu..tar.gz is for people who manage their own
/optor~/.local.Flatpak bundle (
.flatpak) is a single file that installs into Flatpak, sandboxed and updatable by installing a newer bundle, for people who prefer Flatpak to AppImage.packaging/linux/flatpak-bundle.shrepackages the job's.tar.gzwithpackaging/linux/flatpak/com.ncdev.picto.bundle.ymlon the freedesktop 26.08 runtime. It does no Rust build and needs no network insideflatpak-builder, so the bundle holds the same binaries as the other formats. A separateflatpakjob per architecture (ubuntu-24.04andubuntu-24.04-arm, for flatpak-builder 1.4) downloads the Linux job's artifact, runs the script, installs the bundle and runspicto-cli --versioninside the sandbox as a smoke test. The bundle names Flathub as its runtime repo, so users install it with:flatpak install --user picto-<v>-linux-x86_64.flatpak # pulls org.freedesktop.Platform//26.08 from Flathub if missing flatpak run com.ncdev.pictoSandbox permissions (justified in the manifest): Wayland with X11 fallback, IPC (X11 shared memory),
drifor the GPU, and read/write access to Pictures and Documents. Every other file goes through the file-chooser and document portals. There's no network, so the--controlserver is only reachable from inside the sandbox until the user runsflatpak override --user --share=network com.ncdev.picto. Host fonts are read from/run/host/fontsand/run/host/user-fonts. Locally (on Linux, withflatpakandflatpak-builder):packaging/linux/package.sh --formats tar && packaging/linux/flatpak-bundle.sh.Flathub:
packaging/linux/flatpak/com.ncdev.picto.ymlbuilds from source and is ready for a Flathub submission (it keeps the same runtime andfinish-argsas the bundle manifest, which packaging-lint checks). CI doesn't build it. A real build needs vendored crate sources (cargo-sources.jsonfromflatpak-cargo-generator.py) and adds 20+ minutes per architecture. Build it by hand with the commands in the manifest's header.
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.