Packaging - D0n9X1n/SonicTerm GitHub Wiki
Choose your host platform below to build a package under dist/. These commands
do not publish it. Release approval and publication are separate steps in
Release Process; native behavior is described
in Platform Integration.
The root Cargo.toml [workspace.package].version is the version source for every
first-party crate. When a local command needs the version, read all workspace
members from Cargo metadata and require one shared value:
version="$(cargo metadata --no-deps --format-version 1 | python3 -c '
import json, sys
metadata = json.load(sys.stdin)
members = set(metadata["workspace_members"])
versions = {p["version"] for p in metadata["packages"] if p["id"] in members}
assert len(versions) == 1, sorted(versions)
print(versions.pop())
')"Release tags add the v prefix. scripts/prepare-release-assets.py check-version rejects a tag that does not match every workspace package.
First-party packaging executables are direct children of scripts/.
Every package includes the pinned, statically linked winit dependency's
crates/sonicterm-winit/LICENSE as LICENSE-winit-Apache-2.0.
Build and package on the target macOS architecture, on the same host whose
Homebrew libraries linked the executable. The bundle's minimum macOS version is
the maximum of 14.0 and the deployment targets of its executable and every
bundled dylib. Official Apple Silicon packages support macOS 14+, and Intel
packages support macOS 15+; CI enforces those explicit ceilings and refuses newer
dependency floors. Check LSMinimumSystemVersion and
Resources/native-libraries.json for the artifact's actual requirement. Official
release packages are assembled on their architecture-specific CI runners. Install Cairo/pkg-config
for the Rust build and create-dmg plus ImageMagick for packaging:
brew install cairo pkg-config create-dmg imagemagick
cargo build --release -p sonicterm-mac
bash scripts/bake-icons.sh
version="$(cargo metadata --no-deps --format-version 1 | python3 -c '
import json, sys
metadata = json.load(sys.stdin)
members = set(metadata["workspace_members"])
versions = {p["version"] for p in metadata["packages"] if p["id"] in members}
assert len(versions) == 1, sorted(versions)
print(versions.pop())
')"
case "$(uname -m)" in
arm64) suffix=mac-aarch64 ;;
x86_64) suffix=mac-x86_64 ;;
*) printf 'unsupported architecture: %s\n' "$(uname -m)" >&2; exit 1 ;;
esac
bash scripts/make-macos-dmg.sh \
target/release/sonicterm-mac \
"$version" \
"$suffix"The output is dist/SonicTerm-<version>-<suffix>.dmg. The script's
macos-bundle.py dmg image phase uses create-dmg when available and falls back
to hdiutil after an ordinary nonzero exit with no leftover process-group members.
A missing create-dmg selects hdiutil directly; launch failures, timeouts,
signals, interruptions, unavailable exit status, and positive or unknown leftover
counts fail packaging instead of falling back.
Image commands and retry waits share a 300-second monotonic budget, with 60 seconds
reserved for process-group inspection, reaping and output draining. Each command
gets at most 120 seconds and only starts with at least 30 seconds left before the
reserve. Only the exact hdiutil: create failed - Resource busy diagnostic from
an ordinary failed, settled command permits a retry: at most three attempts,
10 seconds apart, and only when the wait and another minimum attempt still fit.
A delayed wake rechecks the budget. This is a supervised command budget, not an
OS scheduling or filesystem deadline for the complete bundle-assembly phase.
Each attempt starts without the previous attempt's private partial image. Only a
nonempty regular, non-symlink .dmg from a successful settled command atomically
replaces the destination; failure preserves any previous destination. Raw command
logs and result.json remain in a fresh <output>.creation-* sibling directory.
CI and Release failure uploads include those logs and JSON, never staged images.
The supervisor checks and kills the command's process group; descendants that
leave it are outside that guarantee. No shared disk-image service is killed, and
an empty process group does not prove that no image remains attached.
The DMG contains SonicTerm.app:
SonicTerm.app/Contents/
โโโ MacOS/sonicterm-mac
โโโ Info.plist
โโโ Frameworks/*.dylib
โโโ Resources/
โโโ assets/{fonts,themes,keymaps,icons,i18n}/
โโโ licenses/
โโโ native-libraries.json
โโโ sonic.icns
Info.plist records the supplied version, bundle id
com.d0n9x1n.sonicterm, ATSApplicationFontsPath=assets/fonts, and alternate handlers
for public.shell-script and com.apple.terminal.shell-script. All four unchanged
Rec Mono faces are stored once: the runtime and AppKit/CoreText share this path.
scripts/macos-bundle.py recursively collects the executable's non-system dylib
closure from installed Homebrew kegs, verifies architecture, rewrites loads to
bundle-relative paths, and removes build-host rpaths. Required licenses, receipts,
source/packaged hashes and resolved library versions accompany the bundle;
missing attribution or unresolved dependencies stop packaging. System libraries
remain supplied by macOS. Dylibs are individually signed before the final app
seal, and the verifier checks the resulting closure and manifest. This does not
change the source-pinned FreeType/HarfBuzz stack or require Homebrew on the user's
Mac.
After native dependency collection, the script adds LICENSE-winit-Apache-2.0
to Contents/Resources/licenses. After assembling all resources, it applies and
verifies an ad-hoc signature. It does not use an Apple Developer ID and does not
notarize. A downloaded package can therefore show the standard
unidentified-developer warning; Finder's Open context-menu action allows the first launch.
CI and Release run each architecture's just-built sonicterm-mac --runtime-smoke before its binary can enter DMG packaging. The bounded wrapper
uses separate scratch config/log roots, preserves HOME, removes inherited
NO_COLOR, and requires native window, renderer/device, live-grid PTY marker,
later presentation, and the complete default warm-renderer lifecycle.
Both Apple Silicon and Intel CI lanes also build a DMG on their native host and
run python3 scripts/test-macos-package.py --dmg <image> --state-dir <new-directory>.
The validator installs a read-only-mounted image into a scratch path, checks
signatures and the dependency manifest, then exercises the app and Cairo drawing
with Homebrew filesystem reads denied for those child processes. A native
LaunchServices probe verifies all four font URLs point into the bundle, rather
than accidentally finding installed copies. It checks basic Cairo gradient
pixels; that probe is not an exhaustive COLR glyph test. Only the exact open -W
exit-before-wait kevent/โNo such processโ error is accepted when the fresh report
already ends with one complete passing verdict; other launch failures and timeouts
still fail validation. A same-binary UDZO pair
with single versus duplicated fonts reports actual compressed savings separately
from logical file bytes and added Cairo-library bytes. No host libraries are
moved or renamed. Logs and package-evidence.json retain the checks and sizes.
The validator's commands share a 420-second admission deadline from its start,
with 75 seconds reserved for attachment cleanup. Only a transient Resource busy
failure from measurement-image hdiutil create is retried, at most twice;
attachment itself runs once, including on Resource temporarily unavailable.
Attachment uses the local-gate process-group supervisor. Before mounting, a
complete hdiutil info -plist census must show that neither the resolved image
nor the private mountpoint is attached. A successful command is not enough:
the next census must identify one new image and its exact private mount/device
before validation reads it. Whole-disk and partition entries are retained,
including unmounted devices; malformed, truncated or contaminated inventories,
conflicting mounts, and unknown process settlement fail closed. Command payloads
are extracted only after the exact supervisor envelope matches: the POSIX
header/footer, or one successful strict Windows execution phase with complete
custody evidence. Phase metadata stays in the raw log, while child bytes remain
unchanged; unexpected phases or altered framing fail, even after exit zero.
Portable tests exercise both envelopes without performing a DMG attach on Windows.
Raw supervised logs and attachment-result.json record the commands and image
inventories in the validator's state directory. Failures permit up to three
read-only observations at 0, 2 and 5 seconds while budget permits; a skipped
required observation fails rather than implying absence. Detachment is attempted
only when a current census proves new ownership and its command budget fits,
never for an ambiguous or foreign image. A subsequent census must confirm
absence; a skipped or failed confirmation remains a failure even when detach
succeeded. Earlier observation errors remain recorded separately after a later
owned detach and confirmed absence. An unreadable image alias prevents proof,
including during cleanup, rather than being assumed foreign. The original
validation error takes precedence when cleanup also fails. No shared disk-image
service is killed.
Queries are capped at 3 seconds, attach at 60 seconds, and detach at 20 seconds. Validation commands leave their runner's 10-second timeout-cleanup allowance outside the 75-second attachment reserve. Attachment command admission includes a 15-second ordinary supervisor allowance. The first cleanup census protects 35 seconds for detach; later observations protect 55 seconds for detach and confirmation. Detach may use its own 35-second allowance even if confirmation no longer fits. Slow process-group scans can exceed those allowances, so the admission deadline is not a hard wall-clock bound; the workflow timeout remains the outer stop. Neither attachment release nor cleanup of escaped helpers is guaranteed. Process-group settlement and finite inventory samples do not prove that an escaped helper cannot attach later.
SONICTERM_PACKAGE_DIR chooses an isolated output directory. The optional fourth
argument --bundle-only assembles and verifies the app without creating a DMG.
SONICTERM_MAX_MACOS_MINIMUM defaults to 14.0; use 15.0 for the official Intel
policy. A local developer may explicitly choose a higher ceiling for experiments
with newer Homebrew bottles, but that artifact is not a supported release. Pass
the same ceiling to the validator with --max-minimum-macos; it never rewrites
Mach-O deployment targets to pretend compatibility.
Use a Windows x64 host with the MSVC target, vcpkg, cargo-wix 0.3.9, and WiX
Toolset 3.14.1.20250415. CI logic coverage uses cargo-llvm-cov 0.9.0.
scripts/setup-windows-cairo.ps1 looks for vcpkg.exe through VCPKG_ROOT,
VCPKG_INSTALLATION_ROOT, or C:\vcpkg and installs static Cairo plus pkgconf.
rustup target add x86_64-pc-windows-msvc
cargo install cargo-wix --version 0.3.9 --locked
choco install wixtoolset --version 3.14.1.20250415 --no-progress -y
. .\scripts\setup-windows-cairo.ps1
cargo build --release --target x86_64-pc-windows-msvc -p sonicterm-windows
$version = (cargo metadata --no-deps --format-version 1 | ConvertFrom-Json).packages |
Where-Object name -eq sonicterm-windows | Select-Object -ExpandProperty version
$numericVersion = ($version -split '[-+]')[0]
New-Item -ItemType Directory -Force -Path dist | Out-Null
Push-Location .\crates\sonicterm-windows
cargo wix --package sonicterm-windows --target x86_64-pc-windows-msvc `
--install-version $numericVersion --no-build --nocapture --output ..\..\dist\
Pop-Location
$msi = Get-ChildItem .\dist\*.msi -ErrorAction Stop
.\scripts\validate-windows-msi.ps1 -MsiPath $msi.FullName -ExpectedVersion "v$version"Dot-source the Cairo script in the same PowerShell process as the build. It sets
PKG_CONFIG, PKG_CONFIG_PATH, and SYSTEM_DEPS_CAIRO_LINK=static for that
process. Starting a different shell loses those values. If WiX was just
installed, restart the shell or add its bin directory to PATH.
An unelevated build may print LGHT1105: Validation could not run due to system policy. That warning means ICE validation did not run; it does not by itself
change the MSI contents. The independent COM validator still checks the MSI
Property, Component, Feature, FeatureComponents, and SummaryInformation data. It
requires the numeric SemVer core as ProductVersion, stable UpgradeCode, nonempty
ProductCode, x64;1033 template, and the exact ten 64-bit Binaries components.
Prerelease/build suffixes remain part of tag provenance but cannot enter MSI
ProductVersion.
CI and Release run the just-built sonicterm-windows.exe --runtime-smoke before
the MSI artifact can advance. Normal CI separately requires the GDI capability
probe's unique EXERCISED verdict; HOST_INCAPABLE is informational only. The
runtime smoke uses real ConPTY/cmd.exe, separate scratch config/log roots, and
the same window, renderer, marker, presentation, and warm-renderer lifecycle
contract as macOS and Linux.
Tooling updates use a dedicated tooling pull request. Change the central
workflow version, both Packaging language files, and the consistency test together;
then run the mutation tests and validate a newly built MSI before merging. Do not
float a tool first and document the selected version afterwards.
cargo wix consumes crates/sonicterm-windows/wix/main.wxs. The per-machine
MSI installs this core layout under Program Files\SonicTerm:
SonicTerm/
โโโ sonicterm-windows.exe
โโโ LICENSE-winit-Apache-2.0
โโโ assets/
โโโ themes/*.toml
โโโ keymaps/*.toml
โโโ fonts/*.ttf
โโโ icons/exports/{sonic.ico,sonic.icns}
It creates a Start-menu shortcut and sets the INSTALLDESKTOPSHORTCUT property
to 1 by default. It registers SonicTerm ProgIDs, Default Apps capabilities, and
OpenWithProgids for .ps1, .cmd, .bat, and .sh, then broadcasts
SHCNE_ASSOCCHANGED after install or uninstall. It never writes an extension
default or UserChoice, and uninstall removes only SonicTerm's values. The MSI
is unsigned.
Linux packages target x86_64 with a glibc 2.35 maximum symbol baseline. The
release builder is Ubuntu 22.04. A local full build needs the native Rust/Cairo,
Fontconfig, X11, and Wayland development dependencies plus tar, gzip,
dpkg-deb, dpkg-shlibdeps, readelf, file, Perl, and Python 3.
cargo build --release -p sonicterm-linux
version="$(cargo metadata --no-deps --format-version 1 | python3 -c '
import json, sys
metadata = json.load(sys.stdin)
members = set(metadata["workspace_members"])
versions = {p["version"] for p in metadata["packages"] if p["id"] in members}
assert len(versions) == 1, sorted(versions)
print(versions.pop())
')"
tag="v${version}"
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" \
bash scripts/make-linux-packages.sh target/release/sonicterm "$tag" dist
bash scripts/test-linux-packages.sh \
"dist/SonicTerm-${tag}-linux-x86_64.tar.gz" \
"dist/SonicTerm-${tag}-linux-x86_64.deb"On a non-Linux host, scripts/make-linux-packages.sh --stage-only can assemble
the common payload, but it cannot create or validate the ELF packages.
Both artifacts come from one normalized staged payload. Timestamps use
SOURCE_DATE_EPOCH; tar ownership is root/root with numeric ids.
The relocatable archive is
SonicTerm-<tag>-linux-x86_64.tar.gz:
SonicTerm-<tag>-linux-x86_64/
โโโ sonicterm
โโโ assets/{fonts,themes,keymaps,icons,i18n}/
โโโ share/applications/com.d0n9x1n.SonicTerm.desktop
โโโ share/metainfo/com.d0n9x1n.SonicTerm.metainfo.xml
โโโ share/icons/hicolor/256x256/apps/com.d0n9x1n.SonicTerm.png
โโโ LICENSE
โโโ LICENSE-Rec-Mono-OFL-1.1
โโโ LICENSE-winit-Apache-2.0
โโโ README.md
The Debian package is SonicTerm-<tag>-linux-x86_64.deb and installs:
/usr/bin/sonicterm
/usr/share/sonicterm/assets/{fonts,themes,keymaps,icons,i18n}/
/usr/share/applications/com.d0n9x1n.SonicTerm.desktop
/usr/share/metainfo/com.d0n9x1n.SonicTerm.metainfo.xml
/usr/share/icons/hicolor/256x256/apps/com.d0n9x1n.SonicTerm.png
/usr/share/doc/sonicterm/{copyright,LICENSE-Rec-Mono-OFL-1.1,LICENSE-winit-Apache-2.0,README.md}
The builder checks x86_64 ELF identity and rejects GLIBC requirements newer
than 2.35. dpkg-shlibdeps derives linked Depends; the script also adds
libxkbcommon-x11-0 because winit loads it dynamically for X11. The portable
archive's host must provide libxkbcommon-x11.so.0 when using X11. The package
script verifies all four Rec Mono faces plus themes, keymaps, icons, and English
and Simplified Chinese catalogs.
scripts/test-linux-packages.sh checks the source contract and, when paths are
provided, both built layouts. CI additionally validates the desktop entry,
AppStream metadata, and Debian dependency field. lintian findings are advisory
in CI.
scripts/smoke-linux-packages.sh requires root in an ephemeral Linux container.
It extracts the tarball, installs the Debian package, forces Vulkan through Mesa
lavapipe, and runs both layouts first on X11/Xvfb and then on headless
Wayland/Weston. Its optional third argument is default, frame-validation, or
device-recovery; an omitted argument selects default, while empty or unknown names fail before
package installation or display startup. Each layout passes the scenario before
-- to native-smoke-runner.py, with a distinct scenario/display/package state
root and log. The wrapper removes inherited NO_COLOR, preserves HOME, and
bounds each child to 45 seconds. On POSIX it kills that process group; descendants
that leave it are outside that bound.
CI and Release run the default, frame-validation and device-recovery matrices in
separate steps without workflow timeout overrides; each native process retains its
deadline. Default smoke requires a native window and renderer/device,
a /bin/sh marker in the live grid, later presentation, the warm-renderer
lifecycle, and isolated/retained-resource/device-loss fault checks. Frame validation
starts a fresh process, injects a persistent fault after initial presentation, and
requires stopped presentation plus a newly executed PTY marker. Device recovery
starts another process with two live windows and one warm renderer, destroys their
shared device, and requires one rebuild, new marker-bearing presentations in both
windows on the replacement generation, and release back to the original renderer
count. The original PTY identities must survive, and an old-generation callback
must not trigger another rebuild. Fault-containment failures exit 17, device-loss
failures 18, recovery failures 19, and otherwise successful smoke with unsettled
PTY teardown 20; earlier failures take precedence.
The first failed case stops its matrix and preserves its exit code. Failure logs
use sonicterm-<scenario>-<display>-<package>-smoke.log, matching the upload glob.
The script refuses to replace an existing SonicTerm Debian installation.
Local packages are not published automatically. The tag-driven release validates
all workspace versions and typed package fragments before uploading the five
packages, release-assets.json, and SHA256SUMS.txt. Exact tag rules and release
steps are in Release Process.