Skip to content

Latest commit

 

History

History
579 lines (513 loc) · 35.7 KB

README.md

File metadata and controls

579 lines (513 loc) · 35.7 KB

Release Packaging

Run with Bash; packaging scripts never build or execute the GUI, bundle a daemon, or publish artifacts. Output directories must already exist. Existing artifacts are refused. Versions are calendar versions YYYYMMDD.COUNTER: an eight-digit UTC date and a same-day counter starting at 1, without v, prerelease/build suffixes, or leading zeros.

Build Identity

Packaging and just bundle require Python 3 and read a versioned identity record embedded in the supplied executable, never the packaging checkout's Git state. On Linux, linked-worktree binaries select assets/icons/herdr-linux-worktree.svg; other builds select assets/icons/herdr-linux.svg. Both circular variants are installed as share/icons/hicolor/scalable/apps/herdr-gpui.svg, so manual archives and distribution packages use the same layout without a pixmaps fallback. Regenerate both SVGs with just icons-linux; see the icon guide for the composition and desktop lookup behavior. On macOS, linked-worktree binaries select assets/icons/Herdr-worktree.icns and Herdr-worktree.car; other builds use Herdr.icns and Herdr.car. The installed icons keep their standard filenames. No binary is executed, so foreign Linux architectures and both macOS slices work on the packaging host. macOS inputs must have identical identities (branch and PR included). Missing, malformed, or conflicting records fail closed; older binaries must be rebuilt.

herdr-gpui --build-info prints three newline-terminated key=value lines: worktree=0|1, branch=..., and pr=..., before any GUI or daemon startup. The same record is retained in optimized binaries for packaging. This metadata is an identity hint, not a signature or proof of provenance; supply trusted binaries.

At build time, Git is anchored at the crate manifest. Only differing canonical Git/common directories mark a linked worktree, not branch names, --dev, or an ordinary checkout with a separate Git directory. Stable builds have an empty branch; linked builds use the branch or detached short SHA. Printable Unicode, including trailing Unicode whitespace, is preserved; branch names containing control characters use the short SHA instead. Missing Git/source archives degrade to stable. Cargo watches existing HEAD, current ref, packed refs, and checkout pointer files, not the entire ordinary .git directory. When a loose ref is missing, its nearest existing refs directory is watched for creation. Source archives do not automatically detect later Git initialization.

HERDR_BUILD_PR_NUMBER=123 cargo build ... overrides PR lookup; an explicitly empty value disables it. Values must be empty or positive ASCII decimal digits. Otherwise attached linked branches get a best-effort gh pr list --head BRANCH --state open --limit 1 --json number --jq '.[0].number' lookup in the manifest repository, with prompts disabled and a two-second timeout. Only open PRs are considered; numeric and #numeric branches are treated as branch names, never PR numbers. Missing gh/auth/network/open PR never prevents a build. There is no runtime lookup. Cargo does not poll remote PR changes: force a rebuild or change the override when PR metadata changes without a local HEAD/ref change. Set an explicit override for reproducible builds and matching cross-architecture PR metadata.

Packaging Commands

The release workflow's ci-gate job runs scripts/release/check-ci.py against the exact release SHA, waiting for a CI run that is still going. A successful CI workflow alone is insufficient: every platform check (both its default-features and all-features legs), native Windows test job, and Linux optimized build must have completed successfully in the same owner-triggered push or manual run on main. Skipped or missing jobs fail the gate. CI runs the macOS optimized builds on pull requests only, where the final gate requires them: on main they would hold the account-wide macOS runners this workflow's own macOS builds wait for. just release dispatches without waiting, so the release builds overlap CI; sign, windows-sign, and everything after them wait behind ci-gate, so nothing is signed or published before CI passes. CI never cancels a run on main, so a later merge cannot strand a release commit without one. For documentation-only commits, start a full run with gh workflow run ci.yml --repo penso/herdr-gpui --ref main before releasing.

cargo install cargo-about --version 0.9.2 --locked --features cli
python3 scripts/release/generate-notices.py OUTPUT_DIR/THIRD-PARTY-NOTICES.txt
bash scripts/release/package-macos.sh VERSION ARM64_BINARY X86_64_BINARY OUTPUT_DIR OUTPUT_DIR/THIRD-PARTY-NOTICES.txt
bash scripts/release/sign-macos.sh VERSION OUTPUT_DIR/Herdr.app OUTPUT_DIR
bash scripts/release/package-linux.sh VERSION TARGET BINARY OUTPUT_DIR OUTPUT_DIR/THIRD-PARTY-NOTICES.txt
bash scripts/release/render-cask.sh VERSION SHA256 > /path/to/rendered/herdr-gpui.rb

macOS assembly requires Apple command-line tools. It produces unsigned Herdr.app, containing the universal GUI, plist, icon, root LICENSE and NOTICE, protocol license/attribution, Octicons license, and third-party notices. Both plist versions are set to the supplied version. The package floor is macOS 14.2 (LSMinimumSystemVersion; Homebrew can only express :sonoma). The binary strongly links CoreAudio's process-tap functions, introduced in 14.2, through rodio's cpal backend, so dyld refuses to launch it on 14.0 and 14.1. GPUI checks for macOS 15 at runtime before using its newer window behavior. The macos-14 CI job that ran the workspace tests on that floor is commented out in ci.yml to save macOS runners; re-enable it if a macOS 14 regression appears. Release builds run on macos-15 runners. Build release inputs with MACOSX_DEPLOYMENT_TARGET=14.2 to match the plist; do not supply binaries targeting a newer OS.

Signing requires macOS, Apple tools, openssl, jq, network access to Apple's notary service, and these environment variables:

  • MACOS_CERTIFICATE_P12_BASE64: base64-encoded Developer ID Application P12.
  • MACOS_CERTIFICATE_PASSWORD: nonempty P12 password.
  • MACOS_SIGNING_IDENTITY: full Developer ID Application: ... identity.
  • APPLE_API_PRIVATE_KEY: literal multiline App Store Connect API .p8 contents, not a filename or base64 value.
  • APPLE_API_KEY_ID: API key ID.
  • APPLE_API_ISSUER_ID: team API issuer UUID.

Test P12 import with Apple's security tool, not only an OpenSSL roundtrip. Some modern OpenSSL exports are rejected by Apple's importer with a misleading MAC/password error. The provisioned pair uses password-protected PBE-SHA1-3DES for key/certificate encryption and SHA-1 for the P12 MAC; both local and CI exports were validated in a temporary keychain. This archive compatibility setting does not change the executable's code-signing algorithm.

Use a trusted disposable signing runner and trusted binaries. Do not use shell tracing around secret setup. The script uses a private temporary directory and explicit temporary keychain and never changes the default keychain. It snapshots the user search list, restores it after keychain creation (which may alter it), and restores it again during cleanup. It traps exit/INT/TERM to delete credentials and the keychain. Do not run concurrent keychain-changing jobs on that user account. As with any trap, SIGKILL/power loss cannot be cleaned up. Apple security tools receive passwords as arguments, so the runner must not have untrusted local users. No GUI executable is run, including for version probing. Signing works on a copy of the input app.

The bundle holds four universal executables: Contents/MacOS/Herdr, the herdr-acp the app starts chats with, herdr-remote-control, the remote control daemon it runs as a user service, and herdr-automate, the automation daemon; package-macos.sh takes the last three from beside each architecture's herdr-gpui. Those three are signed first, without the app's entitlements, then Herdr, then the bundle, all with hardened runtime and secure timestamps. The zipped app must receive JSON status Accepted, then the app is stapled and validated. The DMG contains Herdr.app and an /Applications symlink; it is signed, separately notarized, stapled, and checked with codesign and Gatekeeper before publishing the local output filename: Herdr-VERSION-universal-apple-darwin.dmg. Any failed operation aborts. The signer also emits herdr-gpui-VERSION-macos-universal.app.tar.gz using the updater's bounded USTAR packager. It archives the complete signed, stapled copy used for the DMG, including signature, ticket, resources and notices, before the temporary directory is removed. The caller's unsigned app is never repackaged as an update. Existing DMG or updater outputs are refused before loading credentials.

Linux targets are x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu. The release workflow builds both natively on Ubuntu 24.04 architecture runners, using the same scripts/install-linux-deps.sh library setup as CI. Both archives are mandatory in the single immutable asset manifest; signing, provenance and consumer verification cover both. The SBOM unions both Linux, both macOS, and the Windows target graphs, each the app's joined with those of the programs shipped beside it (SHIPPED in generate-sbom.py). No separate per-platform manifest or publication job is used. The caller must supply the matching release binary; packaging does not cross-build or resolve shared libraries. Output is Herdr-VERSION-TARGET.tar.gz, with a same-named root containing bin/herdr-gpui, bin/herdr-acp, bin/herdr-remote-control, bin/herdr-automate, and bin/herdr-nearby, the Bluetooth helper for nearby pairing and presence (all taken from beside the binary), a scalable SVG icon, desktop entry, license and notice under share/. Install that tree into a chosen prefix with its bin on PATH. Archives are not promised to be bit-for-bit reproducible.

scripts/release/package-linux-distro.py VERSION TARGET ARCHIVE OUTPUT_DIR --nfpm NFPM then repackages that manual archive as Herdr-VERSION-TARGET.deb, .rpm and .pkg.tar.zst. It extracts only the expected regular files, installs them under /usr, and builds all three formats before publishing any, so every Linux format ships the tarball's exact bytes. nfpm is the only packaging tool; scripts/release/install-nfpm.sh downloads its pinned release and checks a SHA-256 copied from that release's Sigstore-verified checksums.txt. Package dependencies are declared explicitly, not inferred: the binary requires GLIBC_2.39 (it is built on Ubuntu 24.04), links ALSA, FreeType, Fontconfig, xcb and xkbcommon, and dlopens the Vulkan loader and libwayland-client. RPM requirements are sonames so they resolve on any RPM distribution. Entries are stamped with the release date, and the release host name is kept out of the RPM header, so repackaging one release produces identical bytes. scripts/release/smoke-linux-packages.sh VERSION TARGET DIST_DIR then installs each package into digest-pinned Ubuntu 24.04, Debian 13 and Fedora 42 containers, plus Arch Linux on x86_64 (no official ARM64 image exists), runs herdr-gpui --help and the shipped programs' --help or --version, fails if ldd reports a missing library or a dlopened loader is absent, and removes the package again. When packaged dependencies change, update DEPENDS in package-linux-distro.py and rerun the smoke script.

The workflow separately packages herdr-gpui-VERSION-TARGET-update.tar.gz with scripts/update-manifest.py package-linux. This bounded USTAR contains exactly one regular executable named herdr-gpui-VERSION-TARGET, mode 0755; it does not replace or alter the manual archive's desktop/icon/license tree. Both native Linux builds and both macOS builds embed HERDR_UPDATE_PUBLIC_KEY and the validated HERDR_RELEASE_VERSION before packaging.

The Windows targets are x86_64-pc-windows-msvc and aarch64-pc-windows-msvc, built natively on windows-2025 and windows-11-arm after clippy, the protocol/client suites and the CLI tests pass there. scripts/release/package-windows.py VERSION TARGET BINARY LAUNCHER OUTPUT_DIR THIRD_PARTY_NOTICES (standard-library Python, since the runner has no zip tool) writes one Herdr-VERSION-TARGET.zip per target with a same-named root containing herdr-gpui.exe, the windowed herdr-gpui-launcher.exe that the installer's Start menu shortcut opens, herdr-acp.exe, which the app starts chats with, herdr-remote-control.exe, the remote control daemon it runs as a scheduled task, herdr-automate.exe, the automation daemon, and herdr-nearby.exe, the Bluetooth helper for nearby pairing and presence (all taken from beside BINARY), and licenses/ holding the same license and notice files as the Linux tree. Entries carry a fixed timestamp.

scripts/release/windows-installer.py then installs the pinned Inno Setup compiler (install-iscc, checked against its SHA-256), compiles herdr-gpui.iss over the extracted zip tree into Herdr-VERSION-TARGET-setup.exe (build), and installs and uninstalls it on the runner (smoke), checking the files, their subsystems, herdr-gpui.exe's output, the Start menu shortcut's target (the launcher) and AppUserModelID, the user PATH entry, and the Settings entry, and that an update and the uninstaller stop a running herdr-automate.exe daemon (the update starts the new copy again) rather than failing to replace it. CI's windows-package job runs the same optimized build, packaging, installer build, and install/uninstall check on windows-2025 for any pull request that changes their inputs (scripts/ci-changes.sh lists them), and the required gate then requires it. The installer is per-user (no elevation), installs to %LOCALAPPDATA%\Programs\Herdr UI as site/install.ps1 does, adds that directory to the user PATH, and creates the Start menu shortcut to the launcher. Both stop the installed automation daemon with herdr-automate shutdown, which lets its runs finish, before replacing or removing its files. Both zips and both installers are mandatory in the asset manifest and receive checksums, Sigstore sidecars and provenance like every other asset, after the Authenticode signing below when it is set up. The installers are also listed in their own Ed25519-signed windows-manifest.json, so a signed copy in the standard install location updates itself in the app (see App Updates); other copies update by manual download, or through winget.

Windows Signing

The windows job's zip and installer are unsigned, and so are every local and pull request build: windows-installer.py build signs only when given --sign, and herdr-gpui.iss only sets SignTool when ISCC receives one. The release workflow then signs in windows-sign, one job per target on windows-2025, gated by the protected release environment like sign.

Signing is optional until it is set up: validate turns it on once the WINDOWS_SIGNING_SUBJECT repository variable (below) is set, and warns while it is not. Until then windows-sign, windows-smoke and winget are skipped, sign and attest take the unsigned windows-package-TARGET artifacts, and the release ships unsigned Windows zips and installers. Those builds embed no publisher, so their in-app updater stays off rather than trusting an unsigned installer, and Windows SmartScreen warns on first run. Once the variable is set, windows-sign requires the Azure setup below, and a release without it fails instead of falling back to unsigned. When signing is on:

  1. windows-signing.py unpack extracts the tested zip without running anything.
  2. azure/login signs in to Azure with the job's OIDC token, and azure/artifact-signing-action signs herdr-gpui.exe, herdr-gpui-launcher.exe, herdr-acp.exe, herdr-remote-control.exe, herdr-automate.exe, herdr-nearby.exe, and herdr-core.exe with SHA-256 and an RFC 3161 timestamp from http://timestamp.acs.microsoft.com.
  3. windows-signing.py repack refuses unless the tree holds exactly the zip's files and each executable differs from the tested one only by its appended or replaced trailing signature (checksum, certificate table entry, alignment padding). The pinned upstream herdr-core.exe already carries a signature; replacing it must preserve the certificate table offset and every preceding byte except the checksum and certificate table entry. The script then writes the zip again with the same entry order and timestamps.
  4. windows-installer.py build --sign compiles the installer from the signed zip. ISCC calls windows-sign-file.ps1 as its SignTool, with SignedUninstaller=yes, to sign the uninstaller it embeds and the final -setup.exe, through the ArtifactSigning module and Azure CLI login of step 2 and the same digest and timestamp.
  5. windows-signing.py verify requires Windows to report every signature as valid and timestamped, from exactly WINDOWS_SIGNING_SUBJECT.

windows-smoke then installs and uninstalls each signed installer on its own architecture, outside the signing environment, and checks the signatures of the installer and of the installed app, launcher and uninstaller. attest then takes only the signed windows-signed-TARGET artifacts, so checksums, Sigstore and GitHub provenance (actions/attest-build-provenance) cover the bytes users download.

Signing needs this one-time Azure setup, which only the maintainer can do:

  1. An Azure Artifact Signing (formerly Trusted Signing) account with a completed identity validation and a public-trust certificate profile.

  2. A Microsoft Entra app registration with a federated credential for issuer https://token.actions.githubusercontent.com and subject repo:penso/herdr-gpui:environment:release, holding the Artifact Signing Certificate Profile Signer role on the certificate profile and nothing else. No client secret: only an OIDC token from a job in the protected release environment can use it.

  3. These variables on the release environment (not the repository), none secret, except WINDOWS_SIGNING_SUBJECT, below:

    Variable Value
    AZURE_CLIENT_ID the app registration's client ID
    AZURE_TENANT_ID its tenant ID
    AZURE_SUBSCRIPTION_ID the subscription holding the signing account
    ARTIFACT_SIGNING_ENDPOINT the account's regional endpoint, such as https://eus.codesigning.azure.net/
    ARTIFACT_SIGNING_ACCOUNT the signing account name
    ARTIFACT_SIGNING_PROFILE the certificate profile name
  4. WINDOWS_SIGNING_SUBJECT as a repository variable: the certificate subject Windows reports, such as CN=..., O=..., L=..., S=..., C=.... The unsigned windows build job, which runs outside the release environment, embeds it as the publisher the in-app updater requires (HERDR_WINDOWS_SIGNER), and windows-sign verifies against the same value. The subject is the validated identity, so read it from a first signed file ((Get-AuthenticodeSignature file.exe).SignerCertificate.Subject). Set it only once the Azure variables above are in place: it is the switch that turns signing on. Artifact Signing certificates last days, so pin the subject, never a thumbprint.

Winget

After a stable release publishes, the winget job (and the same job in promote.yml when a beta is promoted) submits it to microsoft/winget-pkgs as Penso.HerdrUI. It downloads the published SHA256SUMS, installs the pinned Komac after checking its SHA-256, and runs winget.py submit: Komac writes the new version's manifests from the previous winget-pkgs version and the -setup.exe release URLs (x64, plus arm64 whenever the release lists it), and the script refuses to submit unless every installer is a per-user (Scope: user) Inno Setup installer with the release's URL and SHA-256, the publisher is Herdr UI, and the product code is the installer's AppId. Only then does komac submit open the pull request. Betas never reach winget.

Penso.HerdrUI and the publisher must never change: winget matches installed copies and upgrades by them. winget.py holds both, and its tests tie them to herdr-gpui.iss.

The maintainer has to:

  1. Create the protected winget environment, with required reviewers and deployments limited to main, like homebrew.
  2. Fork microsoft/winget-pkgs to the account that will submit, and store a classic personal access token for that account with only the public_repo scope as the winget environment secret WINGET_TOKEN. Komac cannot open pull requests with a fine-grained token.
  3. Submit the first version by hand, after the first signed release: Komac's update needs an existing version to start from. Run komac new Penso.HerdrUI --version VERSION --urls URL_X64 URL_ARM64 with the two published -setup.exe URLs, check that it detects inno and user scope, set the publisher and package name to Herdr UI, the license and URLs, and submit. Until that version is merged, the job only prints a warning.

Updater Signing

Configure HERDR_UPDATE_PUBLIC_KEY as a repository Actions variable (64 lowercase hex characters). Store the Ed25519 PEM HERDR_UPDATE_SIGNING_KEY only in the protected release environment, not in repository secrets. Only the manifest signing step of sign receives it; builds, tests, metadata, OIDC and publication never do. The public key is validated before builds and matched against the private key before signing exact JSON bytes. See updater setup.

The exact release base set is twenty-seven files: the DMG, two manual Linux archives, their .deb, .rpm and Arch packages, two Windows zips and two Windows installers, SBOM, three updater archives, update-manifest.json, and its raw 64-byte Ed25519 update-manifest.sig, plus package-manifest.json and package-manifest.sig for the six Linux packages, windows-manifest.json and windows-manifest.sig for the two signed Windows installers, and host-manifest.json and host-manifest.sig (schema 4) for the two static herdr-remote-control-VERSION-ARCH-unknown-linux-musl host builds. Each Linux job builds its host build with build-host.sh; Herdr UI copies it onto SSH and cloud machines of that architecture to pair a phone with them (docs/remote-control-plan.md, "Other machines"). All base files receive SHA-256 and Sigstore sidecars and GitHub provenance in the separate protected OIDC job. The raw signature is not overwritten: its Sigstore sidecar is update-manifest.sig.sig; the JSON's is update-manifest.json.sig (and likewise for the package, Windows, and host manifests). Starting with 20261102.2, artifact-manifest.py create validates and combines the signer's SHA-512 sidecars into one SHA512SUMS file. SHA256SUMS covers every other published file, including SHA512SUMS. This keeps the current set at 110 assets, below the 128-asset limit in installed updaters, without dropping any platform, signature, or checksum algorithm. Creation refuses sets of 128 or more assets. Older immutable releases retain their per-file SHA-512 contract and remain verifiable. Missing or additional files fail closed. artifact-manifest.py base-names VERSION DIRECTORY lists the twenty-seven base names. The publication job verifies downloaded draft bytes and the complete exact asset set before making it public; Homebrew still verifies and uses only the final DMG.

Beta Channel

just release-beta publishes a normal release as a GitHub prerelease and skips Homebrew; just release-promote VERSION runs .github/workflows/promote.yml to make that published beta the latest stable release and update the tap, without rebuilding or re-uploading anything. generate-changelog.sh VERSION OUTPUT_DIR [BETA_TAG...] folds the listed unpromoted beta tags into the release after them. See beta releases and promotion.

Homebrew

homebrew/Casks/herdr-gpui.rb is deliberately a template, not an installable unverified release. Render with the final stapled DMG's SHA-256 (64 hex digits). Rendering is deterministic, normalizes hexadecimal to lowercase, and writes only stdout. Do not redirect onto the input template. The rendered cask belongs at Casks/herdr-gpui.rb in penso/homebrew-tap; install as brew install penso/tap/herdr-gpui. Downloads use repository penso/herdr-gpui, tag vVERSION, and the exact DMG filename above.

bash scripts/release/update-homebrew.sh VERSION RENDERED_CASK publishes that cask to the resolved default branch of penso/homebrew-tap. Run only after the published DMG checksum has been verified. It requires git, ssh, curl, jq, and the literal private deploy key in HOMEBREW_TAP_SSH_KEY, supplied only to the approved Homebrew workflow step. It uses HTTPS GitHub metadata for strict SSH host verification, stages only the cask, and skips identical same-version content. It rejects numeric version downgrades, changed same-version content (including checksums), and malformed or missing versions in an existing cask without evaluating Ruby. Leave newer tap releases intact; publish a new version for changed assets, and manually review/repair malformed tap metadata before retrying. The key and clone are trap-cleaned on exit/INT/TERM (not SIGKILL or power loss). No personal SSH keys, PAT, credential helper, or global author configuration is used. Keep tap Actions disabled and the key scoped to this tap; see the root README's protection setup.

Third-Party Notices

Python 3.9+, the pinned Rust toolchain, and cargo-about 0.9.2 are required:

cargo install cargo-about --version 0.9.2 --locked --features cli
python3 scripts/release/generate-notices.py OUTPUT_DIR/THIRD-PARTY-NOTICES.txt

The Python wrapper checks that exact tool version, then runs cargo about generate --locked --all-features --workspace --fail --config scripts/release/about.toml --format json. It renders the tool's unique license texts and package associations directly, preserving copyright in its evidence rather than inventing holders from Cargo authors. No separate Handlebars template is needed. Cargo-about may download package sources and retrieve upstream license evidence; when no matching text is found, its pinned standard SPDX corpus supplies canonical text. Each report section identifies collected evidence versus canonical text. Canonical text does not by itself recover every upstream attribution. Network/cache differences may affect retrieved evidence, so byte-identical reports across machines are not guaranteed.

about.toml filters to the union of aarch64-apple-darwin, x86_64-apple-darwin, aarch64-unknown-linux-gnu, and x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc, and aarch64-pc-windows-msvc, matching the packaging targets. All features and build/dev dependencies remain included, a conservative superset of any one release binary, not its exact linked inventory. The accepted-license list covers the current graph's reviewed choices; Apache is preferred for dual licenses. CDLA-Permissive-2.0 covers webpki-roots trust data and requires its agreement text with redistribution. MPL is accepted only for cbindgen 0.28.0 (build tool), option-ext 0.2.0, nucleo-matcher 0.3.1, cssparser 0.38.0 and dtoa-short 0.3.5 (via ammonia), the symphonia MP3 crates, and dwrote 0.11.5 (Windows only). The wrapper rejects version changes to those exceptions until reviewed. The report gives version-specific crate-source download links, including these unmodified MPL sources. Keep those sources available and re-review obligations if dependencies are modified.

The UTF-8 report includes the lockfile SHA-256, package name/version, Cargo source, declared license expression and cargo-about license texts. A supplemental scan preserves available package-root LICENSE, LICENCE, COPYING, COPYRIGHT and NOTICE files (including suffixes), all files recursively in root license, licenses, licence, licences, legal directories, and every explicit Cargo license-file. This preserves notices and alternative license texts even when they were not selected by cargo-about. Escaping paths, empty/unreadable discovered files, unresolved or unaccepted licenses, failed tool invocations, missing package coverage, and a changed lockfile fail generation. A missing source license file is not alone a failure when cargo-about resolves a standard SPDX license. No report is written until resolution and collection succeed; existing reports are refused to prevent stale reuse. Neither Cargo.lock nor dependency versions are updated by this command.

All package interfaces require a nonempty report. macOS includes it in Contents/Resources/THIRD-PARTY-NOTICES.txt; Linux includes it in share/licenses/herdr-gpui/THIRD-PARTY-NOTICES.txt; Windows includes it in licenses/THIRD-PARTY-NOTICES.txt. Secret-free build jobs generate reports before packaging. Each macOS binary artifact carries its report; assembly requires the two reports to match byte-for-byte before signing. Local DMG builds also generate notices before sourcing signing configuration.

All bundles also preserve root LICENSE, NOTICE, protocol LICENSE-APACHE and NOTICE.md, and assets/icons/LICENSE-octicons as separate files.

Public release review is required: generation is an inventory, not legal approval or proof that every embedded third-party component was found. Review license expressions (including unknown/custom and dual-license choices), text coverage, copyright and NOTICE obligations, nested vendored code, native libraries, and non-Cargo assets before approving the release environment. Do not treat the presence of a report or a configured allowlist as legal approval. Where review finds missing upstream notices, add version-bound evidence with provenance rather than fabricated fallback text or a broad license exception.

Local DMG

just dmg VERSION (or bash scripts/release/build-macos.sh VERSION) builds both architectures locally, assembles the app, and signs/notarizes it without publishing. Use macOS with Xcode command-line tools, the pinned Rust toolchain, jq, cargo-about 0.9.2 installed as above, and the signing prerequisites above. Install both targets explicitly first:

rustup target add aarch64-apple-darwin x86_64-apple-darwin
# Public key only, obtained from the reviewed repository Actions variable:
export HERDR_UPDATE_PUBLIC_KEY=YOUR_64_LOWERCASE_HEX_PUBLIC_KEY
just dmg 20260920.1

Any valid YYYYMMDD.COUNTER version works locally; only the release workflow derives and publishes versions. Builds use --locked, --target-dir target, and MACOSX_DEPLOYMENT_TARGET=14.2 and HERDR_RELEASE_VERSION=VERSION on both targets. The caller must supply a valid HERDR_UPDATE_PUBLIC_KEY before building. All six Apple signing variables listed above and HERDR_UPDATE_SIGNING_KEY are removed from Cargo's environment, including metadata queries. No local .envrc is sourced until both builds and unsigned assembly succeed. Host build dependencies use CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_STRIP=none: Apple stripping produced unloadable, misaligned proc-macro dylibs during local Intel cross-compilation. The setting does not disable optimization or change stripping policy for the distributed executable.

A missing root .envrc fails before building. This ignored file contains no credentials and defines herdr_sign VERSION APP OUT, calling the ignored .envrc.sign.rb helper. The helper and its credential storage are machine-specific: cloning this repository does not replicate or provision them. Provision them separately on a trusted signing machine; do not commit either file or credentials, and do not export signing secrets globally through direnv.

Alternatively, define herdr_sign in your ignored .envrc to invoke bash scripts/release/sign-macos.sh "$@" with the six variables supplied directly to that command's environment by your credential manager (env NAME=value ... bash scripts/release/sign-macos.sh "$@"). Retrieve them only inside the function, not when sourcing .envrc; never put literal credentials in shell history or the file. This direct environment interface does not require the machine-local Ruby helper.

Output is an unsigned assembly at target/distribution/VERSION/Herdr.app and the signed, notarized Herdr-VERSION-universal-apple-darwin.dmg and herdr-gpui-VERSION-macos-universal.app.tar.gz in the same directory when using sign-macos.sh. Local packaging does not sign an Ed25519 update manifest or publish anything. An existing version directory (including a symlink) is refused before building and checked again before promotion. Notices, assembly, and signing use a temporary directory under target/distribution, cleaned on failure so the command can be retried directly. Only after signing succeeds and produces a nonempty DMG is the directory promoted to VERSION. Helper diagnostics remain visible on stderr; the final DMG path is printed on stdout after promotion. Existing version artifacts are never overwritten.

Contributor CI Approval

In repository Settings > Actions > General, keep Require approval for all external contributors enabled. This GitHub setting, outside the workflow YAML, controls whether fork pull-request workflows can start. Review the proposed changes (especially workflows and build scripts), then use Approve workflows to run. Any maintainer with write access can approve; adding collaborators with write access also gives them that authority.

The CI workflow accepts pull_request runs without requiring the PR author, original actor, or source repository to be the owner. An owner rerun only changes github.triggering_actor; approval does not change the other identities. Pushes and manual dispatches remain restricted to owner-triggered main runs. The final Format, lint, and test gate requires successful platform checks for code changes, and the macOS optimized builds on pull requests, and permits skipped platform jobs only for confirmed non-code edits.

Fork PR execution uses GitHub-hosted runners and read-only permissions. GitHub withholds repository secrets from fork pull_request runs, including after approval. PR jobs must not request protected environments or OIDC, and checkouts must not persist credentials. Do not switch to pull_request_target to run PR code or add a privileged workflow_run consumer of PR artifacts. CI caches are public data: never place credentials in them. Release builds use their own workflow and must not consume fork-produced artifacts or caches; signing and publication retain their owner/main restrictions and protected environments.

After changing this policy, validate with a real external fork: confirm its run waits for approval, approve it and confirm the platform jobs execute, then push another contributor commit and check approval is required again. Offline tests cover job eligibility and credential declarations, not GitHub's approval service.

Local Validation

for script in scripts/release/*.sh; do bash -n "$script" || exit; done
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/release/tests -v
PYTHONDONTWRITEBYTECODE=1 python3 scripts/release/test-release-security.py
OPENSSL="$(brew --prefix openssl@3)/bin/openssl" \
  python3 -m unittest discover -s scripts -p 'test_*manifest.py' -v
actionlint .github/workflows/release.yml
zizmor --offline .github/workflows/release.yml

Tests run on macOS and Linux with Python 3, Git and jq. They use dummy credentials and mocked Apple tools/OS detection, check rejection and cleanup paths (including macOS-only production guards), and inspect both Linux target tarballs. Tap tests mock HTTPS/SSH transport and push only to disposable local repositories; they cover default-branch resolution, cask-only commits, no-op updates and failures. They do not perform real signing, notarization, Gatekeeper assessment or native launch testing. Real release validation still needs the trusted signing runner.