Skip to content

Desktop release packages; a release workflow that keeps signing out of the build - #257

Merged
ymote merged 2 commits into
mainfrom
feat/desktop-packaging-v2
Oct 3, 2026
Merged

ymote merged 2 commits into
mainfrom
feat/desktop-packaging-v2

Conversation

@ymote

@ymote ymote commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Installable desktop packages (macOS .app/.dmg, Windows NSIS, Linux .deb/.AppImage), plus a release workflow that keeps signing out of the build. Replaces #94, with every review finding fixed.

Stacked on #256 (the packaged-kernel finder). The base is feat/desktop-kernel-finder. Merge #256 first, then retarget this PR to main.

Packaging (from #94, updated)

release-desktop.yml (rewritten for the review)

Job Runs for Secrets
plan always none. Resolves the ref, commit, tag, sign and release.
package (macOS 14, Windows 2022, Ubuntu 22.04) always none. Builds and scans unsigned packages and uploads them as artifacts.
sign-macos, sign-windows tag push, or a manual run from main release environment. They download the artifacts and run codesign/notarytool and signtool only; nothing is checked out or built.
release tag push, or a manual run from main with a tag and dry_run off release environment, contents: write

How each review finding is fixed:

  1. HIGH, secrets in the build env. The build job has no secrets at all. Signing happens in separate jobs that only download artifacts. The Windows .pfx is used from a file by signtool and never imported into a certificate store.
  2. HIGH, wrong checkout on manual runs. A tag push or a manual run naming a tag checks out refs/tags/<tag>. Every job verifies HEAD against the planned commit, and the release job also checks HEAD against the tag's commit on the server before attaching.
  3. MEDIUM, secrets on pull requests. A pull request runs plan and package only: no secrets, no signing. The signing secrets are environment secrets of release.
  4. MEDIUM, AuthKey.p8 left on disk. AuthKey.p8 is written with umask 077 and removed after notarytool. An always() step also removes it, along with the temporary keychain, the .p12 and the .pfx.
  5. MEDIUM, unpinned actions and toolchain. Every action is pinned to a full commit SHA with its version in a comment: checkout v6.1.0, setup-python v6.3.0, upload-artifact v7.0.1, download-artifact v8.0.1, and dtolnay/rust-toolchain at master 2026-09-12. Rust is pinned to 1.97.1.
  6. MEDIUM, project root from the working directory. See "No checkout lookup when packaged" above.
  7. BLOCKING, drift check. tools/ci_local.py lists release-desktop.yml in NOT_LOCAL with its reason. --check-drift skips it, and reports drift if the entry ever names no workflow or a workflow ci-local runs. check_drift() == [], and a test covers the exclusion. The desktop job now also runs desktop/scripts/test_package.py; tools/test_release_scan.py already runs there.
  8. No stale pins. No pin from Package the desktop Octos kernel and update hosted Rinx #85 or Desktop release packages (macOS .app/.dmg, Windows NSIS, Linux .deb/.AppImage) and release-desktop workflow #94 is carried over. The only Cargo.lock change in the stack is Desktop: find the packaged octos kernel and refuse one of another revision #256's sha2.

Signing detail: codesign rewrites the kernel, and #256 checks the kernel's SHA-256. So sign-macos signs the kernel first, rewrites the receipt in Contents/Resources to the signed bytes, then seals the app. After that it builds a new .dmg, signs it, notarizes it and staples both. Without the secrets the sign jobs pass the packages through unsigned, with a warning.

Verified locally (macOS arm64, unsigned)

  • Built from a neutral directory (/private/tmp/…, CARGO_HOME outside the home directory) with python3 desktop/scripts/package.py. Output: OctoSense.app (358 MB) and OctoSense_0.1.0_aarch64.dmg (148 MB). The kernel was built from source at the pinned ae230ce (octos 2.0.3-rc.13 (ae230ce 2026-10-01)). The receipt is in Contents/Resources and its SHA-256 matches Contents/MacOS/octos-kernel.
  • Private-path scan: tools/release-scan.py over the .dmg (mounted), the .app.zip, the package receipt, the staged resources, the kernel, the binary and the .app came back clean: 7 artifact(s), 907 file(s), 8 pattern(s).
  • The packaged app finds its kernel through Desktop: find the packaged octos kernel and refuse one of another revision #256. I copied the .app out of the .dmg, renamed the build tree out of reach, and ran it hidden with OCTOS_APP_CORE_BIN unset and its working directory inside another OctoSense checkout's desktop/. Opening the Assistant (F8) with a fixture provider profile (no LLM request) logged octos-core: starting kernel 1: …/OctoSense.app/Contents/MacOS/octos-kernel serve --stdio …, and the pane showed "The system agent · ready".
    • In the same run, the launcher found no "Reference" (a row of that checkout's config/apps.json), so the checkout was ignored.
    • With the receipt's revision changed, the pane said "The assistant isn't available on this device: no octos kernel…" and the log gave the full refusal. No kernel process started.
  • desktop/scripts/system_apps_remote.sh with OCTOSENSE_BIN set to the packaged binary: every check passed (launcher lists all system apps, Photos draws, Mail's host sign-in sheet, AI providers, bundles unpacked, no panic, no unexpected [ui-hang]).
  • Signing logic, ad hoc: the workflow's receipt-rewrite step (extracted verbatim) worked after codesign --options runtime --sign - of the kernel (its SHA-256 changed). After that, codesign --entitlements … --sign - of the app passed codesign --verify --deep --strict, and the re-signed app started its kernel.
  • Tests and checks:
    • python3 -m unittest discover -s tools -p 'test_*.py': 91 tests pass.
    • desktop/scripts/test_package.py: 10 tests pass.
    • tools/test_ci_local.py: 15 tests pass, including the PyYAML comparison of every workflow.
    • check_drift() returns [].
    • actionlint is clean.
    • tools/setup.py --check --cargo passes.
    • cargo check --locked passes for -p octosense-shell -p octosense and -p octosense --features mobile-apps.
    • The shell's octosense::paths tests pass (new: a_packaged_build_has_no_checkout_even_inside_one).

Not verified

  • Windows and Linux packages cannot be built here. They are built and scanned only in CI by this workflow, and nobody has run them.
  • Real Developer ID signing, notarization and Windows signtool have not run (no certificates). Only the ad hoc macOS path above was exercised.
  • On Windows only the installer is signed. The octosense.exe and octos-kernel.exe it installs are not, because re-signing them would mean rebuilding the NSIS installer in the signing job.
  • No desktop-v tag was created and nothing was released.

Repository settings needed before signing works

  1. Create the environment release (Settings → Environments). Restrict its deployment refs to main and tags matching desktop-v*, and add required reviewers if wanted.

  2. Add the secrets there, as environment secrets:

    • Apple: APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_API_KEY, APPLE_API_ISSUER, APPLE_API_KEY_P8.
    • Windows: WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD.
    • Optional: RELEASE_SCAN_EXTRA.

    If any of these exist as repository secrets, move them and delete the repository copies.

tools/ci-local.sh does not run this workflow, by design.

🤖 Generated with Claude Code

ymote and others added 2 commits October 2, 2026 15:06
…t of the build

Port of #94 onto the packaged-kernel lookup of the previous commit, with
its review findings fixed.

desktop/scripts/package.py builds the desktop in Makepad's packaged-resource
mode with remapped paths, stages every linked crate's resources, builds the
pinned octos kernel with tools/kernel-artifact.py --host and stages it with
its receipt (the kernel beside the executable, the receipt with the
resources, where crates/kernel looks), and packages it with cargo-packager:
macOS .app/.dmg, Windows NSIS, Linux .deb/.AppImage. It needs no secret and
always builds unsigned: it strips cargo-packager's signing variables from
the build environment. tools/release-scan.py refuses private paths and names
inside the packages (new: --dmg-bytes-only, for the Linux release job).

A packaged build never looks for an OctoSense checkout: not where it was
built, not its working directory, not its executable's ancestors
(paths::packaged()), so starting it inside someone else's checkout cannot
make it build and run that code. Its theme sheets come from the binary.

.github/workflows/release-desktop.yml:
- plan resolves what to build. A tag push or a manual run naming a tag
  checks out that tag (not the branch the run started from), and every job
  verifies HEAD is the planned commit; the release job verifies it is the
  tag's commit before attaching.
- package (macOS, Windows, Linux) builds and scans unsigned packages with no
  secrets and uploads them as artifacts.
- sign-macos and sign-windows run in the protected `release` environment,
  only for a tag push or a manual run from main. They download the packages
  and run codesign/notarytool/signtool only; nothing checks out or builds
  code there. The kernel's receipt is updated to its signed bytes before
  the app is sealed. The Windows certificate is used from a file, never
  imported; the keychain, the .p12 and AuthKey.p8 are deleted in always()
  steps. Without the secrets they pass the packages through unsigned.
- Pull requests get plan and package only: no secrets, no signing.
- Every action is pinned to a full commit SHA, and Rust to 1.97.1.

tools/ci_local.py lists release-desktop.yml in NOT_LOCAL with its reason,
so --check-drift skips it (and fails if the entry goes stale); the desktop
job runs the packaging tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Windows package job failed on "mDNS .local host name: sen***
(16 chars)" in octosense.exe. It is no machine: matrix-sdk, which Rinx
links, names media still in its send queue mxc://send-queue.localhost/...
(LOCAL_MXC_SERVER_NAME) and compares server names with it. x86-64 code
does that 16 bytes at a time, so the constant's first 16 bytes,
"send-queue.local", are a constant of their own, and what follows them is
whichever constant the linker put next. In the Windows build that one
starts with a non-name byte, so the scanner reads a complete .local name.
Linux has the same 16 bytes followed by a letter (no match), and an
earlier Windows build had them followed by "uthorized_client".

DEPENDENCY_LOCAL_NAMES exempts that exact name, beside the product's own
(PRODUCT_LOCAL_NAMES), with a comment saying whose constant it is and why
it appears. Every other .local name is still found, including any that
merely contains it (my-send-queue.local, send-queue2.local); tests cover
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ymote
ymote force-pushed the feat/desktop-packaging-v2 branch from 72711e3 to e1bca5a Compare October 2, 2026 22:07
@ymote
ymote merged commit af39bdc into main Oct 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant