From bfaabbf06b6653193ae6092c3256b524a42561d3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:24:20 -0700 Subject: [PATCH 1/2] Desktop release packages and a release workflow that keeps signing out 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) --- .github/workflows/desktop.yml | 2 + .github/workflows/release-desktop.yml | 462 +++++++++++++++++++++ .gitignore | 3 + README.md | 3 +- README.zh-CN.md | 3 +- crates/shell/src/octosense/paths.rs | 40 +- crates/shell/src/octosense/style.rs | 11 +- desktop/README.md | 61 ++- desktop/README.zh-CN.md | 61 ++- desktop/packaging/icons/icon.ico | Bin 0 -> 6878 bytes desktop/packaging/icons/icon.svg | 1 + desktop/packaging/icons/icon_128.png | Bin 0 -> 1394 bytes desktop/packaging/icons/icon_256.png | Bin 0 -> 3408 bytes desktop/packaging/icons/icon_32.png | Bin 0 -> 346 bytes desktop/packaging/icons/icon_512.png | Bin 0 -> 8139 bytes desktop/packaging/icons/icon_512@2x.png | Bin 0 -> 20323 bytes desktop/packaging/icons/icon_64.png | Bin 0 -> 621 bytes desktop/packaging/macos/Info.plist | 17 + desktop/packaging/macos/entitlements.plist | 13 + desktop/packaging/make_icons.py | 124 ++++++ desktop/packaging/release.json | 33 ++ desktop/scripts/package.py | 358 ++++++++++++++++ desktop/scripts/system_apps_remote.sh | 6 +- desktop/scripts/test_package.py | 156 +++++++ docs/local-ci.md | 2 +- tools/ci_local.py | 17 +- tools/release-scan.py | 237 +++++++++++ tools/test_ci_local.py | 15 + tools/test_release_scan.py | 120 ++++++ 29 files changed, 1724 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release-desktop.yml create mode 100644 desktop/packaging/icons/icon.ico create mode 100644 desktop/packaging/icons/icon.svg create mode 100644 desktop/packaging/icons/icon_128.png create mode 100644 desktop/packaging/icons/icon_256.png create mode 100644 desktop/packaging/icons/icon_32.png create mode 100644 desktop/packaging/icons/icon_512.png create mode 100644 desktop/packaging/icons/icon_512@2x.png create mode 100644 desktop/packaging/icons/icon_64.png create mode 100644 desktop/packaging/macos/Info.plist create mode 100644 desktop/packaging/macos/entitlements.plist create mode 100644 desktop/packaging/make_icons.py create mode 100644 desktop/packaging/release.json create mode 100644 desktop/scripts/package.py create mode 100644 desktop/scripts/test_package.py create mode 100644 tools/release-scan.py create mode 100644 tools/test_release_scan.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index a701d92a..ac2b3151 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -83,6 +83,8 @@ jobs: - name: Desktop tools and scripts run: | python3 -m unittest discover -s tools -p 'test_*.py' + # The release packaging script (no build; release-desktop.yml builds). + python3 -m unittest discover -s desktop/scripts -p 'test_package.py' - name: One Makepad, App Hub, octos and Rinx in the locked graph run: python3 tools/setup.py --check --cargo - name: The native apps' generated places match native-apps.json diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 00000000..f642f8dd --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,462 @@ +name: Release desktop + +# The OctoSense desktop as installable packages (desktop/scripts/package.py): +# macOS .app (zipped) and .dmg (arm64), Windows NSIS installer (x64), Linux +# .deb and .AppImage (x86_64, built on Ubuntu 22.04 for an older glibc). +# +# - plan: which commit to build, whether to sign and whether to release. +# A pushed `desktop-v` tag, or a manual run naming one, builds +# THAT TAG (never the branch the run was started from). +# - package (macOS, Windows, Linux): builds UNSIGNED packages and scans them +# for private paths. It has no secrets: nothing a build script or a +# dependency runs can read a signing key. +# - sign-macos, sign-windows: in the protected `release` environment, only +# for a tag push or a manual run from main. They download the unsigned +# packages and run codesign/notarytool and signtool on them; they check +# out no code and never run Cargo. Without the secrets they pass the +# packages through unsigned, with a warning. +# - release: for a tag push, or a manual run from main naming a tag with +# dry_run off. Checks out the tag, verifies HEAD is the tag's commit, +# scans again and attaches everything with SHA256SUMS to a DRAFT release +# for the tag, not marked latest (home-v* and rom-v* share this +# repository). A person reviews and publishes the draft. +# +# A pull request that changes the packaging runs plan and package only: no +# secrets, no signing, nothing released. +# +# Secrets, in the `release` environment (see desktop/README.md, "Signing"): +# APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, +# APPLE_API_KEY, APPLE_API_ISSUER, APPLE_API_KEY_P8, +# WINDOWS_CERTIFICATE, WINDOWS_CERTIFICATE_PASSWORD, +# RELEASE_SCAN_EXTRA (optional: regexes the final scan also refuses) +# +# Not run by tools/ci-local.sh (tools/ci_local.py NOT_LOCAL): a release +# workflow, with a matrix, environments and artifacts it does not model. + +on: + push: + tags: ['desktop-v*'] + pull_request: + paths: + - 'desktop/packaging/**' + - 'desktop/scripts/package.py' + - 'tools/release-scan.py' + - 'tools/kernel-artifact.py' + - '.github/workflows/release-desktop.yml' + workflow_dispatch: + inputs: + tag: + description: A desktop-v tag to build (empty builds the branch, as a dry run) + type: string + default: '' + dry_run: + description: Build, sign (from main) and scan only; attach nothing to the tag's release + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: release-desktop-${{ github.event_name }}-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + ref: ${{ steps.plan.outputs.ref }} + commit: ${{ steps.plan.outputs.commit }} + tag: ${{ steps.plan.outputs.tag }} + sign: ${{ steps.plan.outputs.sign }} + release: ${{ steps.plan.outputs.release }} + steps: + - name: What to build, sign and release + id: plan + env: + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + INPUT_TAG: ${{ inputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + tag="" + if [ "$EVENT" = push ]; then tag="$REF_NAME"; fi + if [ "$EVENT" = workflow_dispatch ]; then tag="$INPUT_TAG"; fi + if [ -n "$tag" ]; then + if ! [[ "$tag" =~ ^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::$tag is not a desktop release tag (desktop-v..[-
])"; exit 1
+            fi
+            # The tag's commit on the server (an annotated tag peeled).
+            commit=$(gh api "repos/$GH_REPO/commits/refs/tags/$tag" --jq .sha)
+            ref="refs/tags/$tag"
+          else
+            commit="$SHA"
+            ref="$SHA"
+          fi
+          # Secrets only for a tag push or a manual run from main; never for
+          # a pull request or a manual run from another branch.
+          sign=false
+          if [ "$EVENT" = push ] || { [ "$EVENT" = workflow_dispatch ] && [ "$REF" = refs/heads/main ]; }; then
+            sign=true
+          fi
+          release=false
+          if [ -n "$tag" ] && [ "$sign" = true ] && { [ "$EVENT" = push ] || [ "$DRY_RUN" = false ]; }; then
+            release=true
+          fi
+          {
+            echo "ref=$ref"
+            echo "commit=$commit"
+            echo "tag=$tag"
+            echo "sign=$sign"
+            echo "release=$release"
+          } | tee -a "$GITHUB_OUTPUT"
+
+  package:
+    name: package (${{ matrix.platform }})
+    needs: plan
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - os: macos-14
+            platform: macos-arm64
+          - os: windows-2022
+            platform: windows-x64
+          - os: ubuntu-22.04
+            platform: linux-x64
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 150
+    permissions:
+      contents: read
+    defaults:
+      run:
+        shell: bash
+    env:
+      TAG: ${{ needs.plan.outputs.tag }}
+      # linuxdeploy (AppImage) runs without FUSE on the runner.
+      APPIMAGE_EXTRACT_AND_RUN: '1'
+      CARGO_TERM_COLOR: always
+      # Cargo's JSON is UTF-8; Windows' Python would decode it as cp1252.
+      PYTHONUTF8: '1'
+    steps:
+      - name: Check out byte for byte
+        # Windows' Git converts to CRLF by default, which breaks the
+        # runtime patch's recorded digest and the system apps' packed ones.
+        run: git config --global core.autocrlf false
+      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
+        with:
+          ref: ${{ needs.plan.outputs.ref }}
+          persist-credentials: false
+      - name: The checkout is the planned commit
+        env:
+          WANT: ${{ needs.plan.outputs.commit }}
+        run: |
+          head=$(git rev-parse HEAD)
+          [ "$head" = "$WANT" ] || { echo "::error::checked out $head, planned $WANT"; exit 1; }
+      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+        with:
+          python-version: '3.12'
+      - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master, 2026-09-12
+        with:
+          toolchain: 1.97.1
+      - name: Linux build and packaging dependencies
+        if: runner.os == 'Linux'
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends pkg-config libssl-dev libx11-dev libxcursor-dev \
+            libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev \
+            libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdrm-dev libgbm-dev libva-dev dpkg-dev file \
+            desktop-file-utils libfuse2
+      - name: Install cargo-packager
+        run: cargo install cargo-packager --locked --version 0.11.8
+      - name: Neutral build paths
+        # Crates embed their CARGO_MANIFEST_DIR as strings, which
+        # --remap-path-prefix does not reach: build where no user directory
+        # appears. macOS checkouts live under /Users/runner and Windows'
+        # CARGO_HOME under C:\Users\runneradmin, so move those; Linux's
+        # /home/runner is a CI path already.
+        run: |
+          case "$RUNNER_OS" in
+            macOS)
+              src=/private/tmp/octosense-build/OctoSense
+              mkdir -p "$src" && cp -R "$GITHUB_WORKSPACE/." "$src/"
+              echo "CARGO_HOME=/private/tmp/octosense-build/cargo" >> "$GITHUB_ENV" ;;
+            Windows)
+              src="$GITHUB_WORKSPACE"
+              echo "CARGO_HOME=D:\\octosense-cargo" >> "$GITHUB_ENV" ;;
+            *)
+              src="$GITHUB_WORKSPACE" ;;
+          esac
+          echo "SRC=$src" >> "$GITHUB_ENV"
+      - name: Prepare the pinned framework sources
+        run: cd "$SRC" && python tools/setup.py
+      - name: One Makepad, App Hub, octos and Rinx; the shell graph guards
+        run: |
+          cd "$SRC"
+          python tools/setup.py --check --cargo
+          bash tools/check-shell-graph.sh -p octosense
+      - name: Build and package, unsigned (paths remapped, resources and kernel bundled)
+        run: |
+          cd "$SRC"
+          if [ -n "$TAG" ]; then
+            python desktop/scripts/package.py --version-from-tag "$TAG"
+          else
+            python desktop/scripts/package.py
+          fi
+      - name: Collect the packages
+        run: |
+          cd "$SRC/target/octosense-package"
+          version=$(python -c 'import json; print(json.load(open("receipt.json"))["version"])')
+          rm -rf upload && mkdir -p upload/packages upload/signing
+          case "$RUNNER_OS" in
+            macOS)
+              cp dist/*.dmg upload/packages/
+              ditto -c -k --keepParent dist/OctoSense.app "upload/packages/OctoSense_${version}_macos_aarch64.app.zip"
+              # What sign-macos needs besides the packages (it checks out nothing).
+              cp "$SRC/desktop/packaging/macos/entitlements.plist" upload/signing/ ;;
+            Windows)
+              cp dist/*-setup.exe upload/packages/ ;;
+            *)
+              cp dist/*.deb dist/*.AppImage upload/packages/ ;;
+          esac
+          cp receipt.json "upload/packages/OctoSense_${version}_${{ matrix.platform }}.receipt.json"
+          ls -la upload/packages
+      - name: Refuse private paths and names (the packages and their payloads)
+        run: |
+          cd "$SRC/target/octosense-package"
+          # The packages (inside .dmg, .deb, .AppImage, .zip, the NSIS
+          # installer), plus what went into them: the binary, the kernel and
+          # the staged resources.
+          exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe
+          python "$SRC/tools/release-scan.py" upload/packages/* resources kernel "$SRC/target/release/$exe"
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: octosense-desktop-unsigned-${{ matrix.platform }}
+          path: ${{ env.SRC }}/target/octosense-package/upload
+          if-no-files-found: error
+          retention-days: 14
+
+  sign-macos:
+    needs: [plan, package]
+    if: needs.plan.outputs.sign == 'true'
+    runs-on: macos-14
+    timeout-minutes: 90
+    environment: release
+    permissions:
+      contents: read
+    steps:
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: octosense-desktop-unsigned-macos-arm64
+          path: in
+      - name: Sign, notarize and staple (unsigned without the secrets)
+        env:
+          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+          APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
+          APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+          APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
+        run: |
+          set -euo pipefail
+          mkdir -p out
+          if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_SIGNING_IDENTITY" ]; then
+            echo "::warning::no Apple signing secrets in the release environment: the macOS packages stay unsigned"
+            cp in/packages/* out/
+            exit 0
+          fi
+          keychain="$RUNNER_TEMP/signing.keychain-db"
+          password=$(openssl rand -hex 24)
+          (umask 077; printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/signing.p12")
+          security create-keychain -p "$password" "$keychain"
+          security set-keychain-settings -lut 3600 "$keychain"
+          security unlock-keychain -p "$password" "$keychain"
+          security import "$RUNNER_TEMP/signing.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
+          rm -f "$RUNNER_TEMP/signing.p12"
+          security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null
+          security list-keychains -d user -s "$keychain"
+          sign() { codesign --force --timestamp --options runtime --keychain "$keychain" --sign "$APPLE_SIGNING_IDENTITY" "$@"; }
+
+          zip=$(ls in/packages/*.app.zip)
+          dmg=$(ls in/packages/*.dmg)
+          receipt=$(ls in/packages/*.receipt.json)
+          mkdir -p work
+          ditto -x -k "$zip" work
+          app=work/OctoSense.app
+          kernel="$app/Contents/MacOS/octos-kernel"
+          if [ -f "$kernel" ]; then
+            sign "$kernel"
+            # codesign rewrites the kernel, and the desktop runs it only when
+            # its receipt records these bytes: record the signed ones (in
+            # Contents/Resources, sealed by the app's signature below).
+            python3 - "$app/Contents/Resources/octos-kernel.json" "$receipt" "$kernel" <<'PY'
+          import hashlib, json, sys
+          kernel_receipt, package_receipt, kernel = sys.argv[1:]
+          digest = hashlib.sha256(open(kernel, "rb").read()).hexdigest()
+          record = json.load(open(kernel_receipt))
+          record["sha256"] = digest
+          open(kernel_receipt, "w").write(json.dumps(record, indent=2) + "\n")
+          package = json.load(open(package_receipt))
+          package["kernel"]["sha256"] = digest
+          package["signed"] = True
+          open(package_receipt, "w").write(json.dumps(package, indent=2) + "\n")
+          PY
+          fi
+          sign --entitlements in/signing/entitlements.plist "$app"
+          codesign --verify --deep --strict --verbose=2 "$app"
+
+          # The .dmg again, around the signed app.
+          mkdir -p work/dmg
+          ditto "$app" work/dmg/OctoSense.app
+          ln -s /Applications work/dmg/Applications
+          signed_dmg="out/$(basename "$dmg")"
+          hdiutil create -volname OctoSense -srcfolder work/dmg -fs HFS+ -format UDZO -ov "$signed_dmg"
+          codesign --force --timestamp --keychain "$keychain" --sign "$APPLE_SIGNING_IDENTITY" "$signed_dmg"
+
+          if [ -n "$APPLE_API_KEY" ] && [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_P8" ]; then
+            key="$RUNNER_TEMP/AuthKey.p8"
+            (umask 077; printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$key")
+            xcrun notarytool submit "$signed_dmg" --key "$key" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait
+            rm -f "$key"
+            xcrun stapler staple "$signed_dmg"
+            xcrun stapler staple "$app"
+            spctl --assess --type open --context context:primary-signature -v "$signed_dmg"
+          else
+            echo "::warning::no notarization key in the release environment: the macOS packages are signed, not notarized"
+          fi
+          ditto -c -k --keepParent "$app" "out/$(basename "$zip")"
+          cp "$receipt" out/
+          ls -la out
+      - name: Remove the signing keychain and the notarization key
+        if: always()
+        run: |
+          security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2> /dev/null || true
+          rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/signing.p12"
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: octosense-desktop-macos-arm64
+          path: out
+          if-no-files-found: error
+          retention-days: 14
+
+  sign-windows:
+    needs: [plan, package]
+    if: needs.plan.outputs.sign == 'true'
+    runs-on: windows-2022
+    timeout-minutes: 30
+    environment: release
+    permissions:
+      contents: read
+    steps:
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: octosense-desktop-unsigned-windows-x64
+          path: in
+      - name: Sign the installer (unsigned without the secrets)
+        shell: pwsh
+        env:
+          WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
+          WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+        run: |
+          $ErrorActionPreference = 'Stop'
+          New-Item -ItemType Directory -Force out | Out-Null
+          Copy-Item in/packages/* out/
+          if (-not $env:WINDOWS_CERTIFICATE) {
+            Write-Output "::warning::no Windows signing secrets in the release environment: the installer stays unsigned"
+            exit 0
+          }
+          # The certificate is used from a file for signtool alone, never
+          # imported into a certificate store.
+          $pfx = Join-Path $env:RUNNER_TEMP 'signing.pfx'
+          [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
+          $signtool = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | Sort-Object FullName | Select-Object -Last 1).FullName
+          foreach ($installer in Get-ChildItem out/*-setup.exe) {
+            & $signtool sign /f $pfx /p $env:WINDOWS_CERTIFICATE_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /td sha256 $installer.FullName
+            if ($LASTEXITCODE -ne 0) { exit 1 }
+            & $signtool verify /pa $installer.FullName
+            if ($LASTEXITCODE -ne 0) { exit 1 }
+          }
+          Remove-Item -Force $pfx
+      - name: Remove the certificate file
+        if: always()
+        shell: pwsh
+        run: Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $env:RUNNER_TEMP 'signing.pfx')
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: octosense-desktop-windows-x64
+          path: out
+          if-no-files-found: error
+          retention-days: 14
+
+  release:
+    name: draft release
+    needs: [plan, package, sign-macos, sign-windows]
+    if: needs.plan.outputs.release == 'true'
+    runs-on: ubuntu-24.04
+    timeout-minutes: 30
+    environment: release
+    permissions:
+      contents: write
+    env:
+      TAG: ${{ needs.plan.outputs.tag }}
+      GH_TOKEN: ${{ github.token }}
+      GH_REPO: ${{ github.repository }}
+    steps:
+      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
+        with:
+          ref: ${{ needs.plan.outputs.ref }}
+          sparse-checkout: tools
+          persist-credentials: false
+      - name: HEAD is the tag's commit
+        env:
+          WANT: ${{ needs.plan.outputs.commit }}
+        run: |
+          head=$(git rev-parse HEAD)
+          tagged=$(gh api "repos/$GH_REPO/commits/refs/tags/$TAG" --jq .sha)
+          if [ "$head" != "$WANT" ] || [ "$head" != "$tagged" ]; then
+            echo "::error::HEAD $head, built $WANT, $TAG is $tagged: not attaching"
+            exit 1
+          fi
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: octosense-desktop-macos-arm64
+          path: packages
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: octosense-desktop-windows-x64
+          path: packages
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: octosense-desktop-unsigned-linux-x64
+          path: linux
+      - name: SHA256SUMS
+        run: |
+          mv linux/packages/* packages/
+          cd packages
+          sha256sum -- * | sort -k2 > SHA256SUMS
+          cat SHA256SUMS
+      - name: Scan again what is attached
+        env:
+          RELEASE_SCAN_EXTRA: ${{ secrets.RELEASE_SCAN_EXTRA }}
+        run: |
+          # A .dmg cannot be opened here: its contents were scanned on macOS
+          # before signing, and the same app is in the .app.zip scanned now.
+          python3 tools/release-scan.py --dmg-bytes-only packages/*
+      - name: Attach to the tag's draft release
+        run: |
+          version="${TAG#desktop-v}"
+          if gh release view "$TAG" > /dev/null 2>&1; then
+            [ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true ] \
+              || { echo "::error::$TAG is already published; not replacing its files"; exit 1; }
+            gh release upload "$TAG" packages/* --clobber
+          else
+            gh release create "$TAG" packages/* --draft --latest=false --verify-tag \
+              --title "OctoSense desktop $version" \
+              --notes "OctoSense desktop $version, built from the $TAG tag by the release-desktop workflow (run $GITHUB_RUN_ID). Signed only when the release environment has the signing secrets (see desktop/README.md, Signing). Check the files against SHA256SUMS."
+          fi
diff --git a/.gitignore b/.gitignore
index 48213365..f2dfb13e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,6 @@ __pycache__/
 
 # Framework checkouts prepared by tools/setup.py.
 /.sources
+
+# desktop/scripts/package.py writes this beside release.json while it packages.
+/desktop/packaging/.release.generated.json
diff --git a/README.md b/README.md
index b614364d..d5ef9f90 100644
--- a/README.md
+++ b/README.md
@@ -392,12 +392,13 @@ Path-filtered workflows in `.github/workflows/`, so a change runs only the jobs
 | `phone.yml` | `phone/`, `crates/`, `apps/`, the workspace files, `tools/` | compiles Home and its bundled modules, the shell graph guards, and runs the tests of the shell, Home, the AI services, App Hub admission and runtime policy on macOS; the longest job |
 | `apps.yml` | `apps/`, `crates/`, the workspace files, `tools/setup.py` | the kernel service, app peers, AI providers config, the Mail and `llm` host services, the shell's AI services (`crates/ai-host`), AppCard |
 | `rom.yml` | `rom/`, `phone/android/`, the phone's Android resources and tests, `tools/kernel-artifact.py` | product tests, the generated Agent Binder client, the web installer |
+| `release-desktop.yml` | a pushed `desktop-v*` tag, a manual run, or a pull request that changes the packaging (build and scan only) | unsigned desktop packages for macOS, Windows and Linux, the private-path scan; for a tag, signing in the `release` environment and a draft release ([desktop/README.md](desktop/README.md#release-builds)). Not run by `tools/ci-local.sh`. |
 
 Each workflow's graph check (`tools/setup.py --check --cargo`) asserts one Makepad, one App Hub, one octos and one Rinx in the locked graph.
 
 ## Releases
 
-ADR 0001 tags each product on its own: `desktop-v*`, `home-v*` (APK), `rom-v*` (image), with build receipts that record the repository commit. System apps ship only inside the shells, admitted by digest; they are not released separately. The ROM release published before the merge, `20260919-j`, is here as [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j). Phones read `update.json` from the moving `rom-latest` release, not from `releases/latest` ([rom/docs/updates.md](rom/docs/updates.md)). Images `20260919-j` and earlier check the retired OctoSense-ROM repository instead, so a phone flashed with one must be reflashed once to receive updates over the air.
+ADR 0001 tags each product on its own: `desktop-v*`, `home-v*` (APK), `rom-v*` (image), with build receipts that record the repository commit. A `desktop-v*` tag builds the desktop packages (`.dmg`, Windows installer, `.deb`, `.AppImage`) into a draft release ([Release builds](desktop/README.md#release-builds)). System apps ship only inside the shells, admitted by digest; they are not released separately. The ROM release published before the merge, `20260919-j`, is here as [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j). Phones read `update.json` from the moving `rom-latest` release, not from `releases/latest` ([rom/docs/updates.md](rom/docs/updates.md)). Images `20260919-j` and earlier check the retired OctoSense-ROM repository instead, so a phone flashed with one must be reflashed once to receive updates over the air.
 
 ## Contributing
 
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ad7c8646..43011c79 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -392,12 +392,13 @@ python3 ../rom/scripts/build-home.py --help                     # the Home and B
 | `phone.yml` | `phone/`、`crates/`、`apps/`、工作区文件、`tools/` | 在 macOS 上编译 Home 及其内置模块,Shell 依赖图检查,并运行 Shell、Home、AI 服务、App Hub 准入和运行时策略的测试;耗时最长的任务 |
 | `apps.yml` | `apps/`、`crates/`、工作区文件、`tools/setup.py` | 内核服务、app peers、AI 服务商配置、邮件与 `llm` 宿主服务、Shell 的 AI 服务(`crates/ai-host`)、AppCard |
 | `rom.yml` | `rom/`、`phone/android/`、手机端的 Android 资源与测试、`tools/kernel-artifact.py` | 产品测试、生成的 Agent Binder 客户端、网页安装器 |
+| `release-desktop.yml` | 推送的 `desktop-v*` 标签、手动运行,或修改打包的 pull request(只构建和扫描) | macOS、Windows、Linux 的未签名桌面包和私有路径扫描;对标签还会在 `release` 环境中签名并生成草稿 release([desktop/README.zh-CN.md](desktop/README.zh-CN.md#发布构建))。`tools/ci-local.sh` 不运行它。 |
 
 每个工作流的依赖图检查(`tools/setup.py --check --cargo`)确保锁定的依赖图中只有一个 Makepad、一个 App Hub、一个 octos 和一个 Rinx。
 
 ## 发布
 
-按 ADR 0001,每个产品单独打标签:`desktop-v*`、`home-v*`(APK)、`rom-v*`(镜像),构建回执记录仓库提交。系统应用只随 Shell 一起发布、按摘要准入,不单独发布。仓库合并前发布的 ROM 版本 `20260919-j` 现为本仓库的 [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j)。手机从固定移动的 `rom-latest` release 读取 `update.json`,而不是 `releases/latest`([rom/docs/updates.md(英文)](rom/docs/updates.md))。`20260919-j` 及更早的镜像检查的是已停用的 OctoSense-ROM 仓库,因此刷了这些镜像的手机需要重新刷写一次,才能收到 OTA 更新。
+按 ADR 0001,每个产品单独打标签:`desktop-v*`、`home-v*`(APK)、`rom-v*`(镜像),构建回执记录仓库提交。`desktop-v*` 标签会把桌面包(`.dmg`、Windows 安装包、`.deb`、`.AppImage`)构建到草稿 release 中([发布构建](desktop/README.zh-CN.md#发布构建))。系统应用只随 Shell 一起发布、按摘要准入,不单独发布。仓库合并前发布的 ROM 版本 `20260919-j` 现为本仓库的 [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j)。手机从固定移动的 `rom-latest` release 读取 `update.json`,而不是 `releases/latest`([rom/docs/updates.md(英文)](rom/docs/updates.md))。`20260919-j` 及更早的镜像检查的是已停用的 OctoSense-ROM 仓库,因此刷了这些镜像的手机需要重新刷写一次,才能收到 OTA 更新。
 
 ## 参与贡献
 
diff --git a/crates/shell/src/octosense/paths.rs b/crates/shell/src/octosense/paths.rs
index aa0f18cf..18dd1dd9 100644
--- a/crates/shell/src/octosense/paths.rs
+++ b/crates/shell/src/octosense/paths.rs
@@ -89,17 +89,43 @@ pub fn scope_linked_app_data() {
     }
 }
 
+/// Whether this is a packaged (installed) build: `desktop/scripts/package.py`
+/// builds with `MAKEPAD_PACKAGE_DIR` set, and Makepad then reads every
+/// `crate_resource` from the package instead of the source tree.
+pub const fn packaged() -> bool {
+    option_env!("MAKEPAD_PACKAGE_DIR").is_some()
+}
+
 static PACKAGE_DIR: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
 
 /// The running package's source directory (desktop/ or phone/), set by its
-/// `octosense_main!` before the app starts. The shell's own
-/// `CARGO_MANIFEST_DIR` is crates/shell, which carries no catalog.
+/// `octosense_main!` before the app starts (not in a packaged build). The
+/// shell's own `CARGO_MANIFEST_DIR` is crates/shell, which carries no catalog.
 pub fn set_package_dir(dir: &'static str) {
+    if packaged() {
+        return;
+    }
     let _ = PACKAGE_DIR.set(dir);
 }
 
-/// A source tree belongs to OctoSense only when it has our provenance marker.
+/// The OctoSense checkout this shell runs from: its developer catalog
+/// (`config/apps.json`), whose programs it builds and runs, and the Makepad
+/// sources. A source tree belongs to OctoSense only when it has our
+/// provenance marker.
+///
+/// A packaged build has none, wherever it runs: not where it was built (that
+/// path belongs to the build machine), not the working directory and not its
+/// executable's ancestors, so starting the installed app inside someone
+/// else's checkout can never make it build and run that checkout's code.
+/// `--apps ` still names a catalog explicitly.
 pub fn project_root() -> Option {
+    project_root_for(packaged())
+}
+
+fn project_root_for(packaged: bool) -> Option {
+    if packaged {
+        return None;
+    }
     let starts = [
         PACKAGE_DIR.get().map(PathBuf::from),
         std::env::current_exe()
@@ -123,6 +149,14 @@ pub fn project_root() -> Option {
 mod tests {
     use super::*;
 
+    #[test]
+    fn a_packaged_build_has_no_checkout_even_inside_one() {
+        // The tests run inside the checkout (cwd and CARGO_MANIFEST_DIR).
+        assert!(project_root_for(false).is_some(), "a source build finds its checkout");
+        assert_eq!(project_root_for(true), None, "a packaged build never does");
+        assert!(!packaged(), "tests are a source build");
+    }
+
     #[test]
     fn a_linked_rinx_keeps_its_data_under_a_chosen_home_only() {
         let home = Path::new("/tmp/os-home");
diff --git a/crates/shell/src/octosense/style.rs b/crates/shell/src/octosense/style.rs
index 157846a6..c70285c9 100644
--- a/crates/shell/src/octosense/style.rs
+++ b/crates/shell/src/octosense/style.rs
@@ -55,11 +55,14 @@ pub fn load_sheet(style: DesktopStyle, dark: bool) -> StyleSheet {
         return sheet;
     }
     let read = |name: &str, bundled: &str| {
-        // Source checkouts reload on selection; installed/mobile builds use embedded data.
+        // Source checkouts reload on selection; packaged and mobile builds
+        // use the embedded data (never a path on the build machine).
         #[cfg(not(target_arch = "wasm32"))]
-        if let Ok(text) = std::fs::read_to_string(
-            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/themes/octosense").join(name)
-        ) { return text; }
+        if !super::paths::packaged() {
+            if let Ok(text) = std::fs::read_to_string(
+                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/themes/octosense").join(name)
+            ) { return text; }
+        }
         let _ = name;
         bundled.to_string()
     };
diff --git a/desktop/README.md b/desktop/README.md
index ee6a2727..93d2b63b 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -31,7 +31,8 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh
 | `src/main.rs` | The entry point (package `octosense`): `octosense_main!()` over the shell's `App`. The shell (tiling, launcher, dock, bar, hosting, the app registry, `shell/`, `octosense/`) is [`../crates/shell/src`](../crates/shell/src). |
 | `config/apps.json` | The default developer-program catalog. `apps.makepad.json` is an identical copy for `--apps`; `apps.overlay.json` holds the adaptations applied when regenerating them. |
 | `system-apps.json` | Which system apps this build packs, and from where (`../apps`). |
-| `scripts/` | `upstream.py` (WM provenance and catalog regeneration), `smoke.py` (native smoke test), their Python tests, `system_apps_remote.sh`, `ai_providers_remote.sh` and `glance_remote.sh` (hidden `--remote` end-to-end runs of the system apps and of AI providers), and `provision-appcard-llm.sh` (Android). |
+| `scripts/` | `package.py` (the release packages, [Release builds](#release-builds)), `upstream.py` (WM provenance and catalog regeneration), `smoke.py` (native smoke test), their Python tests, `system_apps_remote.sh`, `ai_providers_remote.sh` and `glance_remote.sh` (hidden `--remote` end-to-end runs of the system apps and of AI providers), and `provision-appcard-llm.sh` (Android). |
+| `packaging/` | The release packages' cargo-packager config (`release.json`), app icon (`icons/`, `make_icons.py`) and macOS `Info.plist` additions and entitlements. |
 | `upstream/makepad.json` | Provenance of every file imported from Makepad's `apps/wm`. |
 | `resources/android/` | The Android manifest template. Themes, wallpapers, icons and the startup script are the shell's, in [`../crates/shell/resources`](../crates/shell/resources). |
 | `docs/` | [Validation record](docs/validation.md), [upstream sync](docs/upstream.md), [local AI](docs/local-ai.md), [Android AppCard build](docs/android-appcard-build.md), dated plans. |
@@ -80,7 +81,7 @@ Developer programs from `config/apps.json` build on first launch (progress shows
 | Android | `cargo makepad android run -p octosense --release`; see [Phones](#phones). |
 | iOS | Startup policy is tested, but a full build currently fails in the pinned Makepad Metal backend ([validation](docs/validation.md)). |
 
-A relocatable `.app`, installers and a Linux session compositor are not provided.
+Installable packages (macOS `.app`/`.dmg`, Windows installer, Linux `.deb`/`.AppImage`) come from [Release builds](#release-builds). A Linux session compositor is not provided.
 
 ### Cargo features
 
@@ -126,6 +127,58 @@ App Hub's modules have no process form and always open in-process.
 | `OCTOSENSE_GLANCE_DEMO=mail` | Publish two fake Mail action cards (L0, as `os.mail`, with a toast each) at startup: clicking a toast opens that card in the card window, where Reply, Send (demo), Ask and Track work on fake data. They work the same in the glance panel, which opens with them, and on a phone style's glance page. No mail is read and no model is called. `scripts/mail_card_remote.sh` drives it hidden. |
 | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). |
 
+## Release builds
+
+`desktop/scripts/package.py` turns a checkout into installable packages that need neither `.sources/` nor the repository at run time. It needs no secret and always builds **unsigned** packages, so it is also how to test packaging locally. From the repository root, after setup, with [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) installed (`cargo install cargo-packager --locked --version 0.11.8`):
+
+```sh
+python3 desktop/scripts/package.py                     # this OS's formats, version from desktop/Cargo.toml
+python3 desktop/scripts/package.py --formats app       # macOS: just OctoSense.app
+python3 desktop/scripts/package.py --kernel     # ship a kernel you built (its --version must be the pinned revision)
+python3 tools/release-scan.py target/octosense-package/dist/*   # refuse private paths before sharing anything
+```
+
+| OS | Packages (in `target/octosense-package/dist/`) | Resources in the package |
+| --- | --- | --- |
+| macOS (arm64) | `OctoSense.app`, `OctoSense__aarch64.dmg` | `OctoSense.app/Contents/Resources//resources/` |
+| Windows (x64) | `octosense__x64-setup.exe` (NSIS, per-user) | beside `octosense.exe` in the install directory |
+| Linux (x86_64) | `octosense__amd64.deb`, `octosense__x86_64.AppImage` | `/usr/lib/octosense/` (`usr/lib/octosense/` in the AppImage) |
+
+What a package contains and how it is found at run time:
+
+- **Resources.** The build sets `MAKEPAD_PACKAGE_DIR` (and `MAKEPAD=apple_bundle` on macOS), so Makepad reads every `crate_resource` from the package: `Contents/Resources` through `NSBundle` on macOS, the executable's directory on Windows, `../lib/octosense` from `usr/bin/octosense` on Linux. The script stages the `resources/` of every git or path crate the app links: Makepad's widgets (fonts, icons, textures), the shell's icons and themes, App Hub, Rinx and its article crates.
+- **No checkout.** A packaged build never looks for an OctoSense checkout: not the one it was built in, not its working directory, not its executable's ancestors (`crates/shell/src/octosense/paths.rs`, `packaged()`). Starting the installed app inside someone else's checkout therefore cannot make it build and run that code. It has no developer-program catalog (those rows build from source); `--apps ` with `executable` rows still works.
+- **System apps** (News, Photos, Maps, Camera, Mail, AI providers) are already in the binary: App Hub packs the bundles `system-apps.json` selects at build time. Nothing else is read from `apps/` or `desktop/config/`.
+- **The octos kernel.** The script builds octos at the revision `Cargo.lock` pins with `tools/kernel-artifact.py --host`'s steps and stages it with its `stage` (which checks the binary's `--version`). It ships as `octos-kernel` beside the executable (`Contents/MacOS/` in the app), with its receipt `octos-kernel.json` among the resources; the kernel service runs it only when the receipt names the pinned revision and the binary's SHA-256 ([Build and run](#build-and-run)). `--no-kernel` ships none, and the app then runs without an assistant. `target/octosense-package/receipt.json` records the version, resource crates and the kernel's receipt.
+- **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does). `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…` and homes other than a CI runner's (`/home/runner`, `C:\Users\runneradmin`), `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers.
+- **Identity.** Product name **OctoSense**, identifier `org.octosense.desktop` (`desktop/packaging/release.json`), icon from `desktop/packaging/icons/` (`make_icons.py` renders it). Android keeps `dev.makepad.octosense`.
+
+### Cutting a desktop release
+
+`.github/workflows/release-desktop.yml` (not part of `tools/ci-local.sh`):
+
+1. Dry-run it on `main`: **Actions → Release desktop → Run workflow** on `main`, or `gh workflow run release-desktop.yml --ref main`. It builds, scans and (from `main`) signs all three platforms and keeps the packages as workflow artifacts for 14 days.
+2. Tag the commit and push the tag: `git tag desktop-v0.1.0  && git push origin desktop-v0.1.0`. The version is the tag's (`desktop-v..[-
]`). A manual run from `main` with `tag` set and `dry_run` off does the same for an existing tag; either way the workflow checks out **the tag**, not the branch, and verifies the commit before attaching anything.
+3. The `package` jobs (macOS 14 arm64, Windows 2022, Ubuntu 22.04 x86_64, whose older glibc keeps the `.deb` and AppImage usable on older distributions) run `tools/setup.py`, the graph checks, `package.py` in a neutral directory and the scan, and upload **unsigned** packages. They have no secrets.
+4. `sign-macos` and `sign-windows` run in the `release` environment and only download those packages: codesign (hardened runtime, `entitlements.plist`) of the kernel and the app, the kernel's receipt updated to the signed bytes, a new `.dmg`, notarytool and stapling; signtool for the installer. They never check out or build code.
+5. `release` checks out the tag, verifies HEAD is the tag's commit, scans again and attaches every package, a receipt per platform and `SHA256SUMS` to a **draft** release for the tag, not marked latest (`home-v*` and `rom-v*` share this repository). Review the draft, try the packages, and publish it by hand.
+
+Pull requests that change the packaging run the `package` jobs only: no secrets, no signing, nothing released. x86_64 macOS builds are not produced (the macOS runner is arm64).
+
+### Signing
+
+Without the secrets the signing jobs pass the packages through **unsigned**, with a warning: macOS Gatekeeper then asks to confirm the first open (right-click → Open), and Windows SmartScreen warns. To sign, create a GitHub environment named `release` (**Settings → Environments**), limit it to `main` and `desktop-v*` tags (and add required reviewers if wanted), and add these as its environment secrets, not repository secrets:
+
+| Secret | For |
+| --- | --- |
+| `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD` | base64 of the **Developer ID Application** certificate and key (`.p12`) and its password; imported into a temporary keychain, deleted at the end |
+| `APPLE_SIGNING_IDENTITY` | `Developer ID Application:  ()` |
+| `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` | App Store Connect API key id, issuer id and base64 of `AuthKey_.p8`, for notarytool; the key file is deleted in an `always()` step |
+| `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD` | base64 of the code-signing `.pfx` and its password; signtool signs the installer from the file (never imported into a certificate store) |
+| `RELEASE_SCAN_EXTRA` | optional: comma-separated regular expressions the final scan also refuses |
+
+The signing paths have not run yet (no certificates): they are **unverified**. 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 installer in the signing job.
+
 ## The app model
 
 The launcher lists four kinds of app together:
@@ -323,7 +376,7 @@ python3 tools/setup.py --check --cargo
 
 It also fails when a shell source file exists in two of `crates/shell/src`, `desktop/src` and `phone/src`.
 
-The desktop job does **not** run `cargo test` (the shell's tests run in `phone.yml`), the `desktop/scripts` tests or the smoke tests; run those locally before opening a pull request:
+The desktop job does **not** run `cargo test` (the shell's tests run in `phone.yml`), the `desktop/scripts` tests other than `test_package.py`, or the smoke tests; run those locally before opening a pull request:
 
 ```sh
 (cd phone && cargo test --locked --features mobile-apps -p octosense-shell)   # the shell's tests, as phone.yml runs them
@@ -344,7 +397,7 @@ Results for each change are recorded in [docs/validation.md](docs/validation.md)
 ## Known gaps
 
 - Only macOS is validated. Windows and Linux are untested; the iOS build fails in the pinned Metal backend.
-- No packaged `.app` or installer; source builds read fonts and resources from the `.sources/makepad` checkout, so keep it in place.
+- Source builds (`cargo run`) read fonts and resources from the `.sources/makepad` checkout, so keep it in place; [release builds](#release-builds) carry their own. Release packages are unsigned until the `release` environment has the signing secrets, and the Windows and Linux packages are built in CI but not run by us.
 - Photos on desktop has thumbnails only unless you mount a photo directory.
 - The hosted AppCard assistant does not yet wire notifications, share or the WebView overlay.
 - Mobile Sheets needs grid-label and toolbar fixes ([BACKLOG.md](BACKLOG.md)).
diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md
index ac38df62..3819a8a1 100644
--- a/desktop/README.zh-CN.md
+++ b/desktop/README.zh-CN.md
@@ -31,7 +31,8 @@
 | `src/main.rs` | 入口(包名 `octosense`):在 Shell 的 `App` 上调用 `octosense_main!()`。Shell 本身(平铺、launcher、dock、顶栏、托管、应用注册表、`shell/`、`octosense/`)在 [`../crates/shell/src`](../crates/shell/src)。 |
 | `config/apps.json` | 默认的开发者程序目录。`apps.makepad.json` 是供 `--apps` 使用的相同副本;`apps.overlay.json` 保存重新生成时应用的调整。 |
 | `system-apps.json` | 本构建打包哪些系统应用,以及从哪里打包(`../apps`)。 |
-| `scripts/` | `upstream.py`(WM 来源记录与目录重新生成)、`smoke.py`(原生冒烟测试)及它们的 Python 测试,`system_apps_remote.sh`、`ai_providers_remote.sh` 和 `glance_remote.sh`(以隐藏窗口 `--remote` 端到端运行系统应用、AI 提供商与一览屏),以及 `provision-appcard-llm.sh`(Android)。 |
+| `scripts/` | `package.py`(发布包,见[发布构建](#发布构建))、`upstream.py`(WM 来源记录与目录重新生成)、`smoke.py`(原生冒烟测试)及它们的 Python 测试,`system_apps_remote.sh`、`ai_providers_remote.sh` 和 `glance_remote.sh`(以隐藏窗口 `--remote` 端到端运行系统应用、AI 提供商与一览屏),以及 `provision-appcard-llm.sh`(Android)。 |
+| `packaging/` | 发布包的 cargo-packager 配置(`release.json`)、应用图标(`icons/`、`make_icons.py`)以及 macOS `Info.plist` 补充项和 entitlements。 |
 | `upstream/makepad.json` | 从 Makepad `apps/wm` 导入的每个文件的来源记录。 |
 | `resources/android/` | Android manifest 模板。主题、壁纸、图标和启动脚本属于 Shell,在 [`../crates/shell/resources`](../crates/shell/resources)。 |
 | `docs/` | [验证记录](docs/validation.md)、[上游同步](docs/upstream.md)、[本地 AI](docs/local-ai.md)、[Android AppCard 构建](docs/android-appcard-build.md)、按日期的计划。 |
@@ -80,7 +81,7 @@ cargo run --release -p octosense
 | Android | `cargo makepad android run -p octosense --release`;见[手机](#手机)。 |
 | iOS | 启动策略已测试,但完整构建目前在固定版本 Makepad 的 Metal 后端中失败([验证记录](docs/validation.md))。 |
 
-不提供可移动的 `.app`、安装包或 Linux 会话合成器。
+可安装的包(macOS `.app`/`.dmg`、Windows 安装包、Linux `.deb`/`.AppImage`)见[发布构建](#发布构建)。不提供 Linux 会话合成器。
 
 ### Cargo features
 
@@ -126,6 +127,58 @@ App Hub 的模块没有进程形态,总是在进程内打开。
 | `OCTOSENSE_GLANCE_DEMO=mail` | 启动时以 `os.mail` 身份发布两张假的邮件操作卡片(L0,各带一条通知):点击通知会在卡片窗口中打开对应卡片,可用假数据试用回复、发送(演示)、提问和跟踪。在随卡片打开的一览面板里,以及手机风格的一览页上,它们同样可用。不读取邮件,也不调用模型。`scripts/mail_card_remote.sh` 以隐藏窗口驱动它。 |
 | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 |
 
+## 发布构建
+
+`desktop/scripts/package.py` 把一个检出构建成可安装的包,运行时既不需要 `.sources/` 也不需要仓库。它不需要任何密钥,总是构建**未签名**的包,因此也是在本地测试打包的方式。完成准备后,在仓库根目录、安装 [cargo-packager](https://github.com/crabnebula-dev/cargo-packager)(`cargo install cargo-packager --locked --version 0.11.8`)后运行:
+
+```sh
+python3 desktop/scripts/package.py                     # this OS's formats, version from desktop/Cargo.toml
+python3 desktop/scripts/package.py --formats app       # macOS: just OctoSense.app
+python3 desktop/scripts/package.py --kernel     # ship a kernel you built (its --version must be the pinned revision)
+python3 tools/release-scan.py target/octosense-package/dist/*   # refuse private paths before sharing anything
+```
+
+| 系统 | 包(在 `target/octosense-package/dist/` 中) | 包内资源位置 |
+| --- | --- | --- |
+| macOS(arm64) | `OctoSense.app`、`OctoSense_<版本>_aarch64.dmg` | `OctoSense.app/Contents/Resources//resources/` |
+| Windows(x64) | `octosense_<版本>_x64-setup.exe`(NSIS,按用户安装) | 安装目录中 `octosense.exe` 旁边 |
+| Linux(x86_64) | `octosense_<版本>_amd64.deb`、`octosense_<版本>_x86_64.AppImage` | `/usr/lib/octosense/`(AppImage 中为 `usr/lib/octosense/`) |
+
+包里有什么、运行时如何找到:
+
+- **资源。** 构建时设置 `MAKEPAD_PACKAGE_DIR`(macOS 上还有 `MAKEPAD=apple_bundle`),Makepad 因此从包中读取所有 `crate_resource`:macOS 上通过 `NSBundle` 读 `Contents/Resources`,Windows 上读可执行文件所在目录,Linux 上读 `usr/bin/octosense` 旁的 `../lib/octosense`。脚本放入应用链接的每个 git 或 path crate 的 `resources/`:Makepad 组件(字体、图标、纹理)、Shell 的图标与主题、App Hub、Rinx 及其文章 crate。
+- **不找检出。** 打包构建从不查找 OctoSense 检出:不找构建它的那个,不找工作目录,也不找可执行文件的上级目录(`crates/shell/src/octosense/paths.rs`,`packaged()`)。因此在别人的检出目录中启动已安装的应用,也不会让它构建并运行那里的代码。它没有开发者程序目录(那些条目需要从源码构建);`--apps <文件>` 配合 `executable` 条目仍然可用。
+- **系统应用**(新闻、相册、地图、相机、邮件、AI 提供商)已在二进制中:App Hub 在构建时打包 `system-apps.json` 选中的应用包。运行时不再读取 `apps/` 或 `desktop/config/`。
+- **octos 内核。** 脚本按 `tools/kernel-artifact.py --host` 的步骤构建 `Cargo.lock` 固定版本的 octos,并用其 `stage` 放置(会检查二进制的 `--version`)。内核以 `octos-kernel` 放在可执行文件旁(应用中为 `Contents/MacOS/`),收据 `octos-kernel.json` 随资源一起;只有收据中的版本与固定版本一致且二进制 SHA-256 相符时,内核服务才运行它(见[构建与运行](#构建与运行))。`--no-kernel` 不附带内核,应用随后在没有助手的情况下运行。`target/octosense-package/receipt.json` 记录版本、资源 crate 和内核收据。
+- **不含私有路径。** 二进制中的路径被重映射(对主目录、`CARGO_HOME` 和检出目录使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会以普通字符串嵌入源码目录,重映射无法处理,所以要在任何用户主目录之外构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的)。`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装包内部查找:`/Users/…`、`C:\Users\…` 以及 CI 运行器之外的主目录(`/home/runner`、`C:\Users\runneradmin` 除外)、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名,以及任何 `RELEASE_SCAN_EXTRA` 模式,发现即失败。
+- **标识。** 产品名 **OctoSense**,标识符 `org.octosense.desktop`(`desktop/packaging/release.json`),图标来自 `desktop/packaging/icons/`(由 `make_icons.py` 生成)。Android 仍为 `dev.makepad.octosense`。
+
+### 发布桌面版本
+
+`.github/workflows/release-desktop.yml`(不属于 `tools/ci-local.sh`):
+
+1. 先在 `main` 上试运行:在 `main` 上 **Actions → Release desktop → Run workflow**,或 `gh workflow run release-desktop.yml --ref main`。它会构建、扫描并(从 `main` 运行时)签名三个平台,包作为工作流产物保留 14 天。
+2. 给提交打标签并推送:`git tag desktop-v0.1.0  && git push origin desktop-v0.1.0`。版本号取自标签(`desktop-v..[-
]`)。从 `main` 手动运行并设置 `tag`、关闭 `dry_run`,对已有标签效果相同;两种方式都会检出**该标签**而不是分支,并在附加任何文件前核对提交。
+3. `package` 任务(macOS 14 arm64、Windows 2022、Ubuntu 22.04 x86_64,较旧的 glibc 让 `.deb` 和 AppImage 能在旧发行版上使用)运行 `tools/setup.py`、依赖图检查、在中性目录中运行 `package.py` 和扫描,并上传**未签名**的包。这些任务没有任何密钥。
+4. `sign-macos` 和 `sign-windows` 在 `release` 环境中运行,只下载这些包:用 codesign(hardened runtime,`entitlements.plist`)签名内核和应用、把内核收据更新为签名后的字节、重新生成 `.dmg`、notarytool 公证并装订;用 signtool 签名安装包。它们从不检出或构建代码。
+5. `release` 检出标签,核对 HEAD 就是标签的提交,再扫描一次,把所有包、每个平台的收据和 `SHA256SUMS` 附加到该标签的**草稿** release(不标记为 latest,`home-v*` 和 `rom-v*` 也在本仓库)。检查草稿、试用安装包后手动发布。
+
+修改打包的 pull request 只运行 `package` 任务:没有密钥、不签名、不发布。不构建 x86_64 macOS 版本(macOS 运行器是 arm64)。
+
+### 签名
+
+没有密钥时,签名任务会把包原样以**未签名**状态传下去并给出警告:macOS Gatekeeper 首次打开时要求确认(右键 → 打开),Windows SmartScreen 会提示警告。要签名,请创建名为 `release` 的 GitHub 环境(**Settings → Environments**),限制为 `main` 和 `desktop-v*` 标签(需要时添加必需的审批人),并把下面这些作为该环境的密钥添加,而不是仓库密钥:
+
+| 密钥 | 用途 |
+| --- | --- |
+| `APPLE_CERTIFICATE`、`APPLE_CERTIFICATE_PASSWORD` | **Developer ID Application** 证书与私钥(`.p12`)的 base64 及其密码;导入临时钥匙串,结束时删除 |
+| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <名称> ()` |
+| `APPLE_API_KEY`、`APPLE_API_ISSUER`、`APPLE_API_KEY_P8` | App Store Connect API 密钥 ID、issuer ID 和 `AuthKey_.p8` 的 base64,供 notarytool 使用;密钥文件在 `always()` 步骤中删除 |
+| `WINDOWS_CERTIFICATE`、`WINDOWS_CERTIFICATE_PASSWORD` | 代码签名 `.pfx` 的 base64 及其密码;signtool 直接从文件签名安装包(从不导入证书存储) |
+| `RELEASE_SCAN_EXTRA` | 可选:最终扫描还要拒绝的正则表达式,逗号分隔 |
+
+签名流程还没有实际运行过(没有证书):**未经验证**。Windows 上只签名安装包,它安装的 `octosense.exe` 和 `octos-kernel.exe` 未签名,因为重新签名它们需要在签名任务中重新构建安装包。
+
 ## 应用模型
 
 launcher 把四类应用列在一起:
@@ -323,7 +376,7 @@ python3 tools/setup.py --check --cargo
 
 若某个 Shell 源文件同时出现在 `crates/shell/src`、`desktop/src`、`phone/src` 中的两处,CI 也会失败。
 
-桌面端任务**不**运行 `cargo test`(Shell 的测试在 `phone.yml` 中运行)、`desktop/scripts` 的测试或冒烟测试;提交 pull request 前请在本地运行:
+桌面端任务**不**运行 `cargo test`(Shell 的测试在 `phone.yml` 中运行)、`test_package.py` 以外的 `desktop/scripts` 测试或冒烟测试;提交 pull request 前请在本地运行:
 
 ```sh
 (cd phone && cargo test --locked --features mobile-apps -p octosense-shell)   # the shell's tests, as phone.yml runs them
@@ -344,7 +397,7 @@ python3 desktop/scripts/smoke.py --cargo-run --default-catalog
 ## 已知不足
 
 - 只在 macOS 上验证过。Windows 和 Linux 未测试;iOS 构建在固定版本的 Metal 后端中失败。
-- 没有打包好的 `.app` 或安装包;源码构建从 `.sources/makepad` 检出中读取字体和资源,所以请保留该检出。
+- 源码构建(`cargo run`)从 `.sources/makepad` 检出中读取字体和资源,所以请保留该检出;[发布构建](#发布构建)自带资源。在 `release` 环境配置签名密钥之前,发布包都是未签名的;Windows 和 Linux 包在 CI 中构建,但我们没有实际运行过。
 - 桌面端的相册只有缩略图,除非挂载照片目录。
 - 托管的 AppCard 助手尚未接通通知、分享和 WebView 覆盖层。
 - 手机上的 Sheets 需要修复网格标签和工具栏([BACKLOG.md](BACKLOG.md))。
diff --git a/desktop/packaging/icons/icon.ico b/desktop/packaging/icons/icon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..74401af86b41cee9e3c520e0f1aaa8a34af86eaa
GIT binary patch
literal 6878
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

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon.svg b/desktop/packaging/icons/icon.svg
new file mode 100644
index 00000000..c430cb09
--- /dev/null
+++ b/desktop/packaging/icons/icon.svg
@@ -0,0 +1 @@
+
diff --git a/desktop/packaging/icons/icon_128.png b/desktop/packaging/icons/icon_128.png
new file mode 100644
index 0000000000000000000000000000000000000000..8711cb21682cc34584372396ac41463197b3d72b
GIT binary patch
literal 1394
zcmV-&1&#WNP)ibTY&N-fzR%p}g%`qZazEz0^Ks6cnW-uO0000000000000000DwTabp1-*
za@umQo&(Z3XoH+evQ$$afVAV20oCq4q0o
z1)7)wQ04kPe|tPw0P-7cE+M1;3;6ZdyTJmG-{1}G7QnRh=3j9)FeUW6#RrS~)&h{<
z5*j`)fJNvUEB&vG#0|wazxVWRwEo|993ams&%F5#a;*q0%0jgO4%Jo(*-F7)KDrvj
z{c~{#<(cH!Hbcw_S{1zl7}8e{5rWMCaL7Qgui+z&E%XY&rl}SK9h%LvHNN4Y*+d(G
z1#~g>3c#hQ-WR_QC4fHj3JD^O;}n4K_2-v6y9D0?kbB6zT&{@#0ti6(>BpOMBJe2y
zxwhQH^_mF4bCbGH1il2|%lLt(ob?Jod}kkpyDb7A0`O^kp>0f~_&C52SLzT=c8`nU
z%byiZIF_H1h
z7Qp7xZr3Ewo3o>}p(HXVl+W@v*Cf&mmYC}kfaJ$X%`=(6`+`8yw90pEy@NgpnYL-<
z@=Wq<`??T$HSedOPz6tKlKG6FyVbOCe$3<4Ph
z;s)Xd5(pv?L@1b0Fq1({1~DDXbTA1aB!rL>MoJh-p(KTp7EW3?3qx2K!qPC7hOs!5
z#i1+@XL&dqM6f{wTg0$M44Xu;Nfg_}u}vHsMY2(3LIJQzT=8pWYe92>{6aU2}U!I9&e3*hM3`~|?_(IY=Rn&aa~etdkbtWc~5f_(;n8M3Zo
zpuN#bQ^DAb0|L5gp}4AIp}kpCdN-i3`EOzhV5Y5k9O4cJjMRnXET#Y^+6rh$E1kga^qU}?9W+P~Eb
ztdthqdNwc8f{kFVU$WY-H8flp=T#2S78fXlO_d8ex9}9BMB{06w+|6ud_;ipdy9ZE
zL4ZC#d*ko$4ptb>i>o)UG@^?WEHR>ekSDpHj}S1t@A1K`>(A&O{%_`+u52dNSkSc$
z({8-57#Zi?yVn%}00000000000000006-x81>y3aQ^C^f9{>OV07*qoM6N<$f-o_S
AmH+?%

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_256.png b/desktop/packaging/icons/icon_256.png
new file mode 100644
index 0000000000000000000000000000000000000000..54def551f49846fbb940d1620c7056acef7007e5
GIT binary patch
literal 3408
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
b!SG+xyYKX!S@a~9@!QnO%+|EXgcR{VvK|l~

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_32.png b/desktop/packaging/icons/icon_32.png
new file mode 100644
index 0000000000000000000000000000000000000000..0cb96aceb1c9d5505b92c4faa522e8436aa35187
GIT binary patch
literal 346
zcmV-g0j2(lP)#R5=`|EY{YW(pAF09sfMcLXuPPLyH#UKaoNU0)8y
zAT|k(NWkZS^~4%9X-(_@D{pszF^Elq1J;uq{Ds{)|I-?hQG5#WIm`hdHgd>-)PnSo
z91YkU05S+>(aA>}{s&|QfyH5BFbom{$$`|s#Bex*GzWlugKilqViY?76r{+$f`tUS
zW!TieU2mONpk=w_(0hUIkh9>nrS6)
zLqTk8YCw8HW}rm{aSp&`G0f-aK0^-)bcYN*2Ml-=40s|OG}&;#3!(upiU+(hqIY#X
s;0=<2ZITUmqjXR-%Llw;G79Jo02rrgj9|DQ1^@s607*qoM6N<$f-6RW7XSbN

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_512.png b/desktop/packaging/icons/icon_512.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad240ec7b2b153b729a44333632db166def538b7
GIT binary patch
literal 8139
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
CR@%A%

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_512@2x.png b/desktop/packaging/icons/icon_512@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..490ceea960f7eed690f5c706fabd4ea2ada6d610
GIT binary patch
literal 20323
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

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_64.png b/desktop/packaging/icons/icon_64.png
new file mode 100644
index 0000000000000000000000000000000000000000..40ba3ab84d041f0e8912600c0ebc5a0f2cd7162e
GIT binary patch
literal 621
zcmV-z0+RiSP)cW2clIWY3BWUW)^s1njD`SAO`B0rR*~RB0PeMs
z3Si7=2rx}k9SL>!xftIfd6FdnPO45}wH)f4pl#ZhVREv)5a2osLZL=7Qr5#Z~^ed-DqkN_?LIzfg(CPGYwSq!lp=7tEj#JDNSZE?OB;j1ye9PK9+UY6J6
zD}O*@wU5ZG{2?hGljA{29+l-`X&#s72NV2chC-oGD9bFr4SuX}Gs<<100000NkvXX
Hu0mjf8Tu8K

literal 0
HcmV?d00001

diff --git a/desktop/packaging/macos/Info.plist b/desktop/packaging/macos/Info.plist
new file mode 100644
index 00000000..98c1988c
--- /dev/null
+++ b/desktop/packaging/macos/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+
+    NSCameraUsageDescription
+    Camera and the apps you open in OctoSense use the camera when you ask them to.
+    NSMicrophoneUsageDescription
+    OctoSense uses the microphone for voice input when you turn it on.
+    NSLocationUsageDescription
+    Maps shows where you are on the map and gives directions from there.
+    NSLocationWhenInUseUsageDescription
+    Maps shows where you are on the map and gives directions from there.
+
+
diff --git a/desktop/packaging/macos/entitlements.plist b/desktop/packaging/macos/entitlements.plist
new file mode 100644
index 00000000..f75a3593
--- /dev/null
+++ b/desktop/packaging/macos/entitlements.plist
@@ -0,0 +1,13 @@
+
+
+
+
+
+    com.apple.security.device.camera
+    
+    com.apple.security.device.audio-input
+    
+
+
diff --git a/desktop/packaging/make_icons.py b/desktop/packaging/make_icons.py
new file mode 100644
index 00000000..13ccd78a
--- /dev/null
+++ b/desktop/packaging/make_icons.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""The desktop package's app icon, rendered from icons/icon.svg's geometry.
+
+The mark is OctoSense's eight-petal flower on its green tile (the website's
+favicon), inset on a 1024 canvas the way macOS app icons are. This writes the
+PNG sizes cargo-packager turns into the macOS .icns, the Linux hicolor icons
+and the Windows .ico, and the .ico itself (PNG entries):
+
+    python3 desktop/packaging/make_icons.py          # rewrites icons/
+
+Stdlib only (Python 3.9+), deterministic: the output is committed, so a
+release build never renders anything. Edit TILE/PETAL/the geometry here and
+in icon.svg together.
+"""
+import math
+from pathlib import Path
+import struct
+import zlib
+
+OUT = Path(__file__).resolve().parent / "icons"
+SIZES = (32, 64, 128, 256, 512, 1024)  # the sizes an .icns takes
+ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
+
+# In icon.svg's 1024 user units.
+TILE = (0x24, 0x3F, 0x30)
+PETAL = (0xD4, 0xED, 0xB8)
+INSET, RADIUS = 100.0, 185.0          # the tile: 824 wide, macOS-like corners
+CENTER, SCALE = 512.0, 824.0 / 64.0   # the favicon's 64-unit flower, scaled to the tile
+PETAL_RX, PETAL_RY, PETAL_CY = 5.0 * SCALE, 10.0 * SCALE, -15.0 * SCALE
+
+
+def tile_distance(x, y):
+    """Signed distance (units) to the rounded tile; negative inside."""
+    lo, hi = INSET + RADIUS, 1024.0 - INSET - RADIUS
+    dx = max(lo - x, 0.0, x - hi)
+    dy = max(lo - y, 0.0, y - hi)
+    if dx > 0 and dy > 0:
+        return math.hypot(dx, dy) - RADIUS
+    inner = min(x - INSET, 1024.0 - INSET - x, y - INSET, 1024.0 - INSET - y)
+    return max(dx, dy) - RADIUS if (dx or dy) else -inner
+
+
+def petal_distance(x, y):
+    """Approximate signed distance to the nearest of the eight petals."""
+    px, py = x - CENTER, y - CENTER
+    best = float("inf")
+    for k in range(8):
+        a = -math.radians(45.0 * k)
+        rx = px * math.cos(a) - py * math.sin(a)
+        ry = px * math.sin(a) + py * math.cos(a)
+        f = math.hypot(rx / PETAL_RX, (ry - PETAL_CY) / PETAL_RY)
+        best = min(best, (f - 1.0) * PETAL_RX)
+    return best
+
+
+def render(size):
+    """RGBA rows, 4x4 supersampled where an edge crosses the pixel."""
+    unit = 1024.0 / size
+    rows = []
+    offsets = [(i + 0.5) / 4.0 for i in range(4)]
+    for j in range(size):
+        row = bytearray()
+        for i in range(size):
+            cx, cy = (i + 0.5) * unit, (j + 0.5) * unit
+            near = 1.5 * unit
+            dt, dp = tile_distance(cx, cy), petal_distance(cx, cy)
+            if abs(dt) > near and abs(dp) > near:
+                tile = 1.0 if dt < 0 else 0.0
+                petal = 1.0 if dp < 0 and tile else 0.0
+            else:
+                tile = petal = 0.0
+                for oy in offsets:
+                    for ox in offsets:
+                        sx, sy = (i + ox) * unit, (j + oy) * unit
+                        if tile_distance(sx, sy) < 0:
+                            tile += 1 / 16
+                            if petal_distance(sx, sy) < 0:
+                                petal += 1 / 16
+            if tile == 0:
+                row += b"\0\0\0\0"
+                continue
+            mix = petal / tile
+            rgb = [round(t + (p - t) * mix) for t, p in zip(TILE, PETAL)]
+            row += bytes(rgb + [round(255 * tile)])
+        rows.append(bytes(row))
+    return rows
+
+
+def png(size, rows):
+    def chunk(kind, data):
+        return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
+    raw = b"".join(b"\0" + r for r in rows)
+    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
+            + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
+
+
+def ico(images):
+    """An .ico of PNG entries (Vista+), smallest first."""
+    header = struct.pack("/`, the
+   layout Makepad looks up (`/resources/`). System apps
+   need nothing here: App Hub packs their bundles into the binary.
+3. **Kernel**: the octos kernel at the revision the workspace pins, checked
+   out and built for this machine by `tools/kernel-artifact.py --host` (with
+   the same remapped paths), then staged by its `stage` (which refuses a
+   binary whose `--version` is not the pinned revision), or `--kernel
+   `. It ships beside the executable as `octos-kernel[.exe]` (in a
+   `.app`, `Contents/MacOS`), and its receipt `octos-kernel.json` with the
+   resources; the kernel service runs it only when that receipt names the
+   pinned revision and the kernel's SHA-256 (`crates/kernel/src/launch.rs`).
+   `--no-kernel` ships none: the app then runs without an assistant (AI
+   providers are still saved) and says why.
+4. **Package** with cargo-packager from `desktop/packaging/release.json`:
+   macOS `.app` and `.dmg`, Windows NSIS `.exe`, Linux `.deb` and `.AppImage`.
+
+Where the resources land, and the `MAKEPAD_PACKAGE_DIR` that finds them:
+
+| Format | Resources | `MAKEPAD_PACKAGE_DIR` |
+| --- | --- | --- |
+| `.app`/`.dmg` | `OctoSense.app/Contents/Resources/` | `.` (+ `apple_bundle`: NSBundle's resource path) |
+| NSIS `.exe` | the install directory, beside `octosense.exe` | `.` (next to the executable) |
+| `.deb`, `.AppImage` | `usr/lib/octosense/` | `../lib/octosense` (from `usr/bin/octosense`) |
+
+The packages are always **unsigned**, and this script needs no secret: it
+removes cargo-packager's signing variables (`APPLE_SIGNING_IDENTITY`,
+`APPLE_CERTIFICATE`, ...) from the build's environment, so a stray one never
+signs, and a build never sees a signing key. Signing and notarization are a
+separate step on the finished packages (the release workflow's `sign-*`
+jobs, `.github/workflows/release-desktop.yml`), which never runs Cargo.
+
+Run `python3 tools/release-scan.py ` on the output before
+publishing anything.
+"""
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[2]
+DESKTOP = ROOT / "desktop"
+PACKAGING = DESKTOP / "packaging"
+PACKAGE = "octosense"
+KERNEL_NAME = "octos-kernel"
+RECEIPT_NAME = "octos-kernel.json"
+# cargo-packager signs when these are set; the build never does (see above).
+SIGNING_ENV = ("APPLE_SIGNING_IDENTITY", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_API_KEY",
+               "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID",
+               "WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_THUMBPRINT")
+DEFAULT_FORMATS = {"macos": ["app", "dmg"], "windows": ["nsis"], "linux": ["deb", "appimage"]}
+# Where Makepad reads packaged resources from, per OS (see the table above).
+PACKAGE_DIR = {"macos": ".", "windows": ".", "linux": "../lib/octosense"}
+ICON_SLOTS = {"32": "icon_32.png", "64": "icon_64.png", "128": "icon_128.png", "256": "icon_256.png",
+              "512": "icon_512.png", "1024": "icon_512@2x.png", "ICO": "icon.ico"}
+MACOS_MINIMUM = "11.0"
+
+
+def host_os():
+    return {"darwin": "macos", "win32": "windows"}.get(sys.platform, "linux")
+
+
+def version_from_tag(tag):
+    """`desktop-v1.2.3` (or a ref ending in it) -> `1.2.3`. Installers need a
+    numeric version, so anything else is refused."""
+    name = tag.rsplit("/", 1)[-1]
+    match = re.fullmatch(r"desktop-v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)", name)
+    if not match:
+        raise ValueError(f"{tag!r} is not a desktop release tag (desktop-v..[-
])")
+    return match.group(1)
+
+
+def remap_flags(root, home=None, cargo_home=None):
+    """`--remap-path-prefix` pairs for rustc, general first: when several
+    match, rustc applies the last, so the checkout (`.`) beats the home dir
+    (`~`) it may sit in."""
+    pairs = []
+    if home:
+        pairs.append((str(home), "~"))
+    if cargo_home:
+        pairs.append((str(cargo_home), "/cargo"))
+    pairs.append((str(root), "."))
+    flags = []
+    for src, dst in pairs:
+        flags += ["--remap-path-prefix", f"{src}={dst}"]
+    return flags
+
+
+def package_env(os_name, root, base_env):
+    """The environment the release build adds: packaged-resource mode, path
+    remapping, no debug info, the window icon, the macOS minimum."""
+    env = {"MAKEPAD_PACKAGE_DIR": PACKAGE_DIR[os_name], "CARGO_PROFILE_RELEASE_STRIP": "debuginfo"}
+    if os_name == "macos":
+        env["MAKEPAD"] = "apple_bundle"
+        env["MACOSX_DEPLOYMENT_TARGET"] = MACOS_MINIMUM
+    # Windows: the native profile path, not an MSYS `HOME` like /c/Users/...
+    home = base_env.get("USERPROFILE") if os_name == "windows" else base_env.get("HOME")
+    cargo_home = base_env.get("CARGO_HOME") or (str(Path(home) / ".cargo") if home else None)
+    existing = base_env.get("CARGO_ENCODED_RUSTFLAGS")
+    flags = existing.split("\x1f") if existing else base_env.get("RUSTFLAGS", "").split()
+    # CARGO_ENCODED_RUSTFLAGS carries paths with spaces intact (RUSTFLAGS
+    # splits on them) and takes precedence over RUSTFLAGS.
+    env["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join([f for f in flags if f] + remap_flags(root, home, cargo_home))
+    for slot, name in ICON_SLOTS.items():
+        env[f"MAKEPAD_APP_ICON_{slot}"] = str(PACKAGING / "icons" / name)
+    return env
+
+
+def cargo_json(args, env):
+    out = subprocess.run(["cargo", *args], cwd=ROOT, env=env, check=True, capture_output=True, text=True).stdout
+    return out
+
+
+def feature_args(features):
+    """`--features` for octosense, qualified (the workspace root is virtual)."""
+    if not features:
+        return []
+    return ["--features", ",".join(f if "/" in f else f"{PACKAGE}/{f}" for f in features.split(","))]
+
+
+def linked_packages(features, env):
+    """(name, version) of every package the app links: `cargo tree` resolves
+    features for octosense alone, as its build does (the workspace-wide
+    `cargo metadata` resolve would add what other members enable)."""
+    out = cargo_json(["tree", "--locked", "-p", PACKAGE, "-e", "normal", "--prefix", "none", "--target", host_triple(),
+                      "--format", "{p}", *feature_args(features)], env)
+    linked = set()
+    for line in out.splitlines():
+        match = re.match(r"(\S+) v(\S+)", line.strip())
+        if match:
+            linked.add((match.group(1), match.group(2)))
+    return linked
+
+
+def host_triple():
+    out = subprocess.run(["rustc", "-vV"], check=True, capture_output=True, text=True).stdout
+    return re.search(r"^host: (\S+)$", out, re.M).group(1)
+
+
+def resource_crates(metadata, linked):
+    """(crate_name, resources dir) of every linked git or path package that
+    has a `resources/` directory. Registry crates are left out: Makepad
+    resources are named by the crates that declare `crate_resource`s, and
+    none of those come from crates.io."""
+    found = []
+    for package in sorted(metadata["packages"], key=lambda p: p["name"]):
+        if (package["name"], package["version"]) not in linked:
+            continue
+        source = package.get("source") or ""
+        if source.startswith(("registry+", "sparse+")):
+            continue
+        resources = Path(package["manifest_path"]).parent / "resources"
+        if resources.is_dir():
+            found.append((package["name"].replace("-", "_"), resources))
+    return found
+
+
+def stage_resources(crates, dest):
+    """Copy each crate's resources to `//resources/`."""
+    if dest.exists():
+        shutil.rmtree(dest)
+    dest.mkdir(parents=True)
+    for name, resources in crates:
+        shutil.copytree(resources, dest / name / "resources",
+                        ignore=shutil.ignore_patterns(".DS_Store", "*.md"))
+    return dest
+
+
+def kernel_tool():
+    """tools/kernel-artifact.py: the one place that knows the pinned kernel."""
+    spec = importlib.util.spec_from_file_location("kernel_artifact", ROOT / "tools/kernel-artifact.py")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def build_kernel(env, work, offline=False):
+    """The octos kernel at the workspace's revision, built for this machine
+    (`kernel-artifact.py --host`'s steps) with the release environment: paths
+    remapped, no debug info, no Makepad packaging variables."""
+    tool = kernel_tool()
+    steps, binary, source = tool.kernel_plan(host=True, work=work, offline=offline)
+    env = {k: v for k, v in env.items() if not k.startswith("MAKEPAD") and k != "CARGO_BUILD_TARGET"}
+    for cwd, command in steps:
+        Path(cwd).mkdir(parents=True, exist_ok=True)
+        subprocess.run(command, cwd=cwd, env=env, check=True)
+    return binary, source
+
+
+def stage_kernel(kernel, source, out, resources):
+    """Stage (and check) the kernel with `kernel-artifact.py`'s `stage`, put
+    its receipt with the resources (a `.app`'s `Contents/Resources`, the
+    Windows install dir beside the kernel, Linux `usr/lib/octosense`: where
+    the kernel service looks), and name a copy the way cargo-packager's
+    `externalBinaries` expects (`-[.exe]`)."""
+    tool = kernel_tool()
+    receipt = tool.stage(kernel, out / "kernel", tool.octos_revision(), source)
+    shutil.copy2(out / "kernel" / tool.RECEIPT_NAME, resources / RECEIPT_NAME)
+    suffix = ".exe" if host_os() == "windows" else ""
+    sidecar = out / "bin" / KERNEL_NAME
+    sidecar.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copy2(out / "kernel" / tool.STAGED_NAME, sidecar.with_name(f"{KERNEL_NAME}-{host_triple()}{suffix}"))
+    return sidecar, receipt
+
+
+def debian_depends(binaries):
+    """The shared-library packages the binaries link, from dpkg-shlibdeps."""
+    if not shutil.which("dpkg-shlibdeps"):
+        return None
+    with tempfile.TemporaryDirectory() as temp:
+        (Path(temp) / "debian").mkdir()
+        (Path(temp) / "debian/control").write_text("Source: octosense\n\nPackage: octosense\nArchitecture: any\n")
+        out = subprocess.run(["dpkg-shlibdeps", "-O", "--ignore-missing-info", *(f"-e{b}" for b in binaries)],
+                             cwd=temp, check=True, capture_output=True, text=True).stdout
+    line = next((l for l in out.splitlines() if l.startswith("shlibs:Depends=")), "")
+    return [d.strip() for d in line.split("=", 1)[-1].split(",") if d.strip()]
+
+
+def packager_config(base, *, version, binaries_dir, out_dir, resources, kernel):
+    """release.json with this build's version, paths and kernel (never
+    signing: see the module docstring)."""
+    config = json.loads(json.dumps(base))
+    config["version"] = version
+    config["binariesDir"] = str(binaries_dir)
+    config["outDir"] = str(out_dir)
+    config["resources"] = [{"src": str(resources), "target": "."}]
+    if kernel:
+        # cargo-packager copies `-[.exe]` beside the
+        # main binary as `[.exe]`.
+        config["externalBinaries"] = [str(kernel)]
+    return config
+
+
+def build_env(added, base_env):
+    """The build's whole environment: this script's additions, RUSTFLAGS
+    folded into CARGO_ENCODED_RUSTFLAGS, and no signing variable."""
+    env = {**base_env, **added}
+    env.pop("RUSTFLAGS", None)
+    for name in SIGNING_ENV:
+        env.pop(name, None)
+    return env
+
+
+def cargo_version():
+    text = (DESKTOP / "Cargo.toml").read_text()
+    return re.search(r'^version = "([^"]+)"', text, re.M).group(1)
+
+
+def main(argv=None):
+    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("--formats", help="Comma-separated cargo-packager formats (default: this OS's: "
+                   + "; ".join(f"{k} {','.join(v)}" for k, v in DEFAULT_FORMATS.items()) + ")")
+    p.add_argument("--version", help="The package version (default: desktop/Cargo.toml's)")
+    p.add_argument("--version-from-tag", metavar="TAG", help="Take the version from a desktop-v tag")
+    p.add_argument("--features", help="Cargo features for octosense (default: its defaults)")
+    kernel = p.add_mutually_exclusive_group()
+    kernel.add_argument("--kernel", type=Path, help="Ship this prebuilt octos kernel instead of building it")
+    kernel.add_argument("--no-kernel", action="store_true", help="Ship no kernel: the app runs without an assistant")
+    p.add_argument("--out", type=Path, default=ROOT / "target/octosense-package",
+                   help="Work and output directory (default: target/octosense-package)")
+    p.add_argument("--skip-build", action="store_true", help="Reuse the last release build (resources and packaging only)")
+    p.add_argument("--offline", action="store_true")
+    p.add_argument("--print-env", action="store_true", help="Print the build environment as JSON and exit")
+    args = p.parse_args(argv)
+
+    os_name = host_os()
+    formats = args.formats.split(",") if args.formats else DEFAULT_FORMATS[os_name]
+    version = version_from_tag(args.version_from_tag) if args.version_from_tag else (args.version or cargo_version())
+    added = package_env(os_name, ROOT, os.environ)
+    if args.print_env:
+        print(json.dumps(added, indent=2))
+        return
+    env = build_env(added, os.environ)
+    out = args.out.resolve()
+    out.mkdir(parents=True, exist_ok=True)
+    offline = ["--offline"] if args.offline else []
+    features = feature_args(args.features)
+
+    if not args.skip_build:
+        print(f"==> cargo build --release -p {PACKAGE} (MAKEPAD_PACKAGE_DIR={added['MAKEPAD_PACKAGE_DIR']})", flush=True)
+        subprocess.run(["cargo", "build", "--locked", "--release", "-p", PACKAGE, *features, *offline],
+                       cwd=ROOT, env=env, check=True)
+
+    print("==> staging resources", flush=True)
+    metadata = json.loads(cargo_json(["metadata", "--locked", "--format-version", "1"], env))
+    crates = resource_crates(metadata, linked_packages(args.features, env))
+    resources = stage_resources(crates, out / "resources")
+    for name, src in crates:
+        size = sum(f.stat().st_size for f in (resources / name).rglob("*") if f.is_file())
+        print(f"    {name:<28} {size / 1e6:7.1f} MB  <- {src.relative_to(ROOT) if ROOT in src.parents else name}")
+
+    triple = host_triple()
+    kernel_receipt = None
+    sidecar = None
+    if not args.no_kernel:
+        if args.kernel:
+            binary, source = args.kernel.resolve(), "prebuilt"
+        else:
+            print("==> building the octos kernel for this machine (tools/kernel-artifact.py --host)", flush=True)
+            binary, source = build_kernel(env, out / "kernel-build", args.offline)
+        sidecar, kernel_receipt = stage_kernel(binary, source, out, resources)
+        print(f"    {KERNEL_NAME}: {kernel_receipt['version']} sha256 {kernel_receipt['sha256'][:16]}")
+    else:
+        print("==> no kernel: this package runs without an assistant", flush=True)
+
+    base = json.loads((PACKAGING / "release.json").read_text())
+    binaries_dir = ROOT / "target" / "release"
+    config = packager_config(base, version=version, binaries_dir=binaries_dir, out_dir=out / "dist",
+                             resources=resources, kernel=sidecar)
+    if os_name == "linux":
+        depends = debian_depends([binaries_dir / PACKAGE, *([sidecar.with_name(f"{KERNEL_NAME}-{triple}")] if sidecar else [])])
+        if depends:
+            config.setdefault("deb", {})["depends"] = depends
+    # Relative paths in the config resolve from its directory, so the
+    # generated copy sits beside release.json (git-ignored).
+    generated = PACKAGING / ".release.generated.json"
+    generated.write_text(json.dumps(config, indent=2) + "\n")
+    (out / "receipt.json").write_text(json.dumps({
+        "version": version, "target": triple, "formats": formats,
+        "resources": [name for name, _ in crates], "kernel": kernel_receipt,
+        "makepad_package_dir": added["MAKEPAD_PACKAGE_DIR"],
+    }, indent=2) + "\n")
+    print(f"==> cargo packager --formats {','.join(formats)}", flush=True)
+    try:
+        subprocess.run(["cargo", "packager", "--release", "--config", str(generated), "--formats", ",".join(formats)],
+                       cwd=PACKAGING, env=env, check=True)
+    finally:
+        generated.unlink(missing_ok=True)
+    print(f"==> packages in {out / 'dist'}; receipt {out / 'receipt.json'}")
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except (subprocess.CalledProcessError, RuntimeError, ValueError) as e:
+        sys.exit(f"package.py: {e}")
diff --git a/desktop/scripts/system_apps_remote.sh b/desktop/scripts/system_apps_remote.sh
index f6e70bba..518093d3 100755
--- a/desktop/scripts/system_apps_remote.sh
+++ b/desktop/scripts/system_apps_remote.sh
@@ -166,8 +166,10 @@ get "log?n=2000" >"$WORK/remote-log.json"
 grep -q "panicked" "$LOG" "$WORK/remote-log.json" && fail "panic in the log"
 # Known: the catalog's startup Makepad-source probe, and App Hub unpacking and
 # hashing a system bundle, or building an app's Splash VM, on the UI thread
-# the first time it opens.
-KNOWN='makepad_source::makepad_root|octosense_appstore::system::prepare|CxSplashVmExt>::alloc_splash_vm'
+# the first time it opens; a packaged build (OCTOSENSE_BIN=, see
+# desktop/scripts/package.py) reading its resources and fonts from the
+# package on the UI thread at startup and first use.
+KNOWN='makepad_source::makepad_root|octosense_appstore::system::prepare|CxSplashVmExt>::alloc_splash_vm|script::res::load_packaged_resource'
 UNKNOWN=$(grep "\[ui-hang\]" "$LOG" | grep -Ev "$KNOWN" || true)
 [ -z "$UNKNOWN" ] || { echo "$UNKNOWN" | cut -c1-400; fail "unexpected UI hang"; }
 echo "note: $(grep -c "\[ui-hang\]" "$LOG" || true) known UI hang(s) (startup catalog probe, first-open bundle unpack or VM)"
diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py
new file mode 100644
index 00000000..e5450d9d
--- /dev/null
+++ b/desktop/scripts/test_package.py
@@ -0,0 +1,156 @@
+"""Tests for desktop/scripts/package.py (no build, no network)."""
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[1]
+spec = importlib.util.spec_from_file_location("package", HERE / "package.py")
+package = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(package)
+
+
+class VersionTests(unittest.TestCase):
+    def test_the_version_comes_from_a_desktop_tag(self):
+        self.assertEqual(package.version_from_tag("desktop-v0.2.0"), "0.2.0")
+        self.assertEqual(package.version_from_tag("refs/tags/desktop-v1.0.0-rc.1"), "1.0.0-rc.1")
+        for tag in ("home-v0.2.0", "rom-v20260919-j", "desktop-0.2.0", "desktop-v0.2", "desktop-vX"):
+            with self.assertRaises(ValueError, msg=tag):
+                package.version_from_tag(tag)
+
+    def test_the_default_version_is_the_packages(self):
+        self.assertRegex(package.cargo_version(), r"^\d+\.\d+\.\d+")
+
+
+class EnvironmentTests(unittest.TestCase):
+    def test_each_os_reads_resources_where_its_package_puts_them(self):
+        root = Path("/build/OctoSense")
+        mac = package.package_env("macos", root, {"HOME": "/home/me"})
+        self.assertEqual((mac["MAKEPAD"], mac["MAKEPAD_PACKAGE_DIR"]), ("apple_bundle", "."))
+        self.assertEqual(mac["MACOSX_DEPLOYMENT_TARGET"], "11.0")
+        win = package.package_env("windows", root, {"USERPROFILE": r"C:\Users\me"})
+        self.assertEqual(win["MAKEPAD_PACKAGE_DIR"], ".")
+        self.assertNotIn("MAKEPAD", win)
+        linux = package.package_env("linux", root, {"HOME": "/home/me"})
+        # usr/bin/octosense -> usr/lib/octosense, in the .deb and the AppImage.
+        self.assertEqual(linux["MAKEPAD_PACKAGE_DIR"], "../lib/octosense")
+        for env in (mac, win, linux):
+            self.assertEqual(env["CARGO_PROFILE_RELEASE_STRIP"], "debuginfo")
+            self.assertTrue(Path(env["MAKEPAD_APP_ICON_ICO"]).is_file())
+            self.assertTrue(Path(env["MAKEPAD_APP_ICON_1024"]).is_file())
+
+    def test_paths_are_remapped_with_the_checkout_last(self):
+        env = package.package_env("linux", Path("/home/me/src/OctoSense"),
+                                  {"HOME": "/home/me", "RUSTFLAGS": "-C target-cpu=native"})
+        flags = env["CARGO_ENCODED_RUSTFLAGS"].split("\x1f")
+        self.assertEqual(flags[:2], ["-C", "target-cpu=native"], "existing flags are kept")
+        remaps = [flags[i + 1] for i, f in enumerate(flags) if f == "--remap-path-prefix"]
+        self.assertEqual(remaps, ["/home/me=~", "/home/me/.cargo=/cargo", "/home/me/src/OctoSense=."])
+        custom = package.package_env("macos", Path("/b/OctoSense"), {"HOME": "/h", "CARGO_HOME": "/c h/cargo"})
+        self.assertIn("/c h/cargo=/cargo", custom["CARGO_ENCODED_RUSTFLAGS"].split("\x1f"), "a space survives")
+
+
+class ResourceTests(unittest.TestCase):
+    def test_linked_git_and_path_crates_with_resources_are_staged(self):
+        with tempfile.TemporaryDirectory() as temp:
+            temp = Path(temp)
+
+            def crate(name, source=None, resources=True, version="1.0.0"):
+                directory = temp / name
+                (directory / "resources/icons").mkdir(parents=True) if resources else directory.mkdir()
+                if resources:
+                    (directory / "resources/icons/a.svg").write_text("")
+                    (directory / "resources/README.md").write_text("provenance")
+                return {"name": name, "version": version, "source": source,
+                        "manifest_path": str(directory / "Cargo.toml")}
+
+            metadata = {"packages": [
+                crate("makepad-widgets"),
+                crate("rinx", "git+https://github.com/hagency-org/Rinx.git?rev=abc#abc"),
+                crate("some-registry-crate", "registry+https://github.com/rust-lang/crates.io-index"),
+                crate("octosense-appcard"),  # in the workspace, not linked by this build
+                crate("no-resources", resources=False),
+            ]}
+            linked = {("makepad-widgets", "1.0.0"), ("rinx", "1.0.0"), ("some-registry-crate", "1.0.0"),
+                      ("no-resources", "1.0.0")}
+            crates = package.resource_crates(metadata, linked)
+            self.assertEqual([name for name, _ in crates], ["makepad_widgets", "rinx"])
+            staged = package.stage_resources(crates, temp / "out/resources")
+            self.assertTrue((staged / "makepad_widgets/resources/icons/a.svg").is_file(),
+                            "the layout Makepad looks up: /resources/")
+            self.assertFalse((staged / "rinx/resources/README.md").exists(), "docs stay out of the package")
+            package.stage_resources(crates[:1], staged)
+            self.assertFalse((staged / "rinx").exists(), "a restage starts clean")
+
+
+class ConfigTests(unittest.TestCase):
+    def test_the_release_config_is_filled_in_per_build(self):
+        base = json.loads((HERE.parent / "packaging/release.json").read_text())
+        self.assertEqual(base["identifier"], "org.octosense.desktop")
+        self.assertEqual(base["productName"], "OctoSense")
+        for icon in base["icons"]:
+            self.assertTrue((HERE.parent / "packaging" / icon).is_file(), icon)
+        config = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t/release"), out_dir=Path("/t/dist"),
+                                         resources=Path("/t/res"), kernel=Path("/t/bin/octos-kernel"))
+        self.assertEqual(config["version"], "0.2.0")
+        self.assertEqual(config["resources"], [{"src": "/t/res", "target": "."}])
+        self.assertEqual(config["externalBinaries"], ["/t/bin/octos-kernel"])
+        self.assertNotIn("signingIdentity", config["macos"], "the build is always unsigned")
+        self.assertNotIn("windows", config)
+        self.assertNotIn("version", base, "the base config is not modified")
+        none = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t"), out_dir=Path("/t"),
+                                       resources=Path("/t"), kernel=None)
+        self.assertNotIn("externalBinaries", none, "--no-kernel ships none")
+
+    def test_the_build_never_sees_a_signing_variable(self):
+        base = {"PATH": "/bin", "RUSTFLAGS": "-C x", "APPLE_SIGNING_IDENTITY": "Developer ID Application: X (T)",
+                "APPLE_CERTIFICATE": "secret", "APPLE_API_KEY_PATH": "/k.p8", "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"}
+        env = package.build_env({"MAKEPAD_PACKAGE_DIR": "."}, base)
+        self.assertEqual(env["PATH"], "/bin")
+        self.assertEqual(env["MAKEPAD_PACKAGE_DIR"], ".")
+        self.assertNotIn("RUSTFLAGS", env, "folded into CARGO_ENCODED_RUSTFLAGS")
+        for name in package.SIGNING_ENV:
+            self.assertNotIn(name, env)
+        self.assertIn("APPLE_SIGNING_IDENTITY", base, "the caller's environment is not modified")
+
+    @unittest.skipIf(package.host_os() == "windows", "the stand-in kernel is a POSIX shell script")
+    def test_the_kernel_ships_with_its_receipt_where_the_desktop_looks(self):
+        tool = package.kernel_tool()
+        revision = tool.octos_revision()
+        with tempfile.TemporaryDirectory() as temp:
+            temp = Path(temp)
+            kernel = temp / "octos"
+            kernel.write_text(f"#!/bin/sh\necho 'octos 2.0.3 ({revision[:7]} 2026-10-01)'\n")
+            kernel.chmod(0o755)
+            resources = temp / "resources"
+            resources.mkdir()
+            sidecar, receipt = package.stage_kernel(kernel, "prebuilt", temp / "out", resources)
+            self.assertEqual(receipt["revision"], revision)
+            self.assertEqual(json.loads((resources / package.RECEIPT_NAME).read_text()), receipt,
+                             "the receipt travels with the resources (Contents/Resources, usr/lib/octosense)")
+            shipped = sidecar.with_name(f"{package.KERNEL_NAME}-{package.host_triple()}")
+            self.assertTrue(shipped.is_file(), "cargo-packager's externalBinaries name")
+            self.assertEqual(receipt["sha256"], hashlib.sha256(shipped.read_bytes()).hexdigest())
+            wrong = temp / "wrong"
+            wrong.write_text("#!/bin/sh\necho 'octos 2.0.3 (0000000 2026-10-01)'\n")
+            wrong.chmod(0o755)
+            with self.assertRaises(RuntimeError):
+                package.stage_kernel(wrong, "prebuilt", temp / "out2", resources)
+
+    def test_the_kernel_is_built_by_kernel_artifact(self):
+        steps, binary, source = package.kernel_tool().kernel_plan(host=True, work=Path("/w"))
+        self.assertTrue(source.startswith("https://github.com/octos-org/octos.git@"))
+        self.assertIn("--target-dir", steps[-1][1])
+        self.assertFalse((ROOT / "tools/build-desktop.py").exists(), "one kernel tool, not two")
+
+    def test_the_sidecar_name_is_the_one_the_kernel_service_looks_for(self):
+        launch = (HERE.parents[1] / "crates/kernel/src/launch.rs").read_text()
+        self.assertIn(f'"{package.KERNEL_NAME}"', launch)
+        self.assertIn(f'"{package.RECEIPT_NAME}"', launch)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/docs/local-ci.md b/docs/local-ci.md
index 2348a5dc..1a588d94 100644
--- a/docs/local-ci.md
+++ b/docs/local-ci.md
@@ -13,7 +13,7 @@ tools/ci-local.sh --check-drift            # the local mapping still fits the wo
 tools/ci-local.sh --only apps --linux-host  # the ubuntu jobs on the Linux build host (below)
 ```
 
-- **Same commands as GitHub.** The runner reads the workflow files and runs each job's `run:` steps verbatim: same working directory, same `env:`, same `bash --noprofile --norc -eo pipefail`. Nothing is copied, so nothing drifts. The few things it maps (the actions: checkout, setup-python, rust-toolchain, rust-cache, the kernel cache, setup-node; expressions like `${{ runner.temp }}`) are checked by `--check-drift`. Every run checks them too, and so does `tools/test_ci_local.py` (part of the desktop job). A workflow that starts using a new action, job-level `if:`, a matrix or an unknown expression fails that check until `tools/ci_local.py` is taught about it.
+- **Same commands as GitHub.** The runner reads the workflow files and runs each job's `run:` steps verbatim: same working directory, same `env:`, same `bash --noprofile --norc -eo pipefail`. Nothing is copied, so nothing drifts. The few things it maps (the actions: checkout, setup-python, rust-toolchain, rust-cache, the kernel cache, setup-node; expressions like `${{ runner.temp }}`) are checked by `--check-drift`. Every run checks them too, and so does `tools/test_ci_local.py` (part of the desktop job). A workflow that starts using a new action, job-level `if:`, a matrix or an unknown expression fails that check until `tools/ci_local.py` is taught about it. The one workflow left out on purpose is `release-desktop.yml` (`NOT_LOCAL` in `tools/ci_local.py`, with its reason): a release workflow with a matrix, environments, secrets and artifacts. ci-local never runs it and `--check-drift` skips it; its packaging scripts' tests (`tools/test_release_scan.py`, `desktop/scripts/test_package.py`) run in the desktop job.
 - **Output.** Each step is printed as PASS, FAIL, SKIPPED or NOT RUN (after a failure, without `--keep-going`), followed by a summary table with times. The full output goes to `target/ci-local/.log`. `target/ci-local/last.json` records the commit, whether the tree was dirty, the workflows run, and every step's result. The exit status is non-zero on any FAIL.
 - **Skips are never passes.** A step that cannot run here is SKIPPED and the summary says why. Today that is the rom product tests, unless a JDK works (`javac -version`, from `PATH` or `JAVA_HOME`; macOS's `/usr/bin/javac` is only a stub), the rom `Check generated Agent Binder client` step, unless the Android SDK has `build-tools;35.0.0` and `platforms;android-35` (found through `ANDROID_HOME`), and the web installer job, unless `node`/`npm`/`npx` are on `PATH`. Without `--linux-host`, jobs GitHub runs on `ubuntu-latest` (apps `services` and `kernel-security`, rom) run on the Mac. Their `#[cfg(target_os = "linux")]` code is not exercised, and the summary notes this.
 - **The octos kernel.** The real-kernel steps (phone's app-peers relay and two-lane scenario, apps' `kernel-security`) need `octos` at the revision `Cargo.lock` pins. The workflow's own `Build octos (unless cached)` step builds it once (`tools/kernel-artifact.py --host`). The binary is then kept in a per-user cache that every clone shares, keyed by OS, architecture and octos revision (`~/Library/Caches/octosense-ci-local/octos-kernel/`, `$XDG_CACHE_HOME` or `OCTOSENSE_CI_LOCAL_CACHE`). The copy is written atomically, under a per-revision lock, so two clones never build the same kernel at once.
diff --git a/tools/ci_local.py b/tools/ci_local.py
index 72c29567..8c246162 100644
--- a/tools/ci_local.py
+++ b/tools/ci_local.py
@@ -66,6 +66,13 @@
 WORKFLOWS = ROOT / ".github/workflows"
 GROUPS = {"desktop": ["desktop.yml"], "phone": ["phone.yml"], "apps": ["apps.yml"], "rom": ["rom.yml"]}
 GROUPS["all"] = [w for g in ("desktop", "phone", "apps", "rom") for w in GROUPS[g]]
+# Workflows ci-local deliberately does not run, and why. --check-drift skips
+# them (an entry naming no workflow, or one ci-local runs, is drift).
+NOT_LOCAL = {
+    "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, "
+                           "with a matrix, environments, secrets and artifacts; nothing a pull request merges "
+                           "on depends on it, and its packaging scripts' tests run in desktop.yml",
+}
 KERNEL_BINARY = "octos-kernel/target/release/octos"
 EXIT_BUSY = 75
 
@@ -391,8 +398,16 @@ def jobs_of(workflow_name, data=None):
 def check_drift(workflows=None):
     """Problems where the workflows and the local mapping disagree."""
     problems = []
-    names = workflows or sorted(p.name for p in WORKFLOWS.glob("*.yml"))
+    present = {p.name for p in WORKFLOWS.glob("*.yml")}
+    names = workflows or sorted(present - set(NOT_LOCAL))
     seen_jobs, seen_steps = set(), set()
+    for name, why in NOT_LOCAL.items():
+        if name not in present:
+            problems.append(f"{name}: listed in NOT_LOCAL but no such workflow")
+        if name in GROUPS["all"]:
+            problems.append(f"{name}: both run by ci-local (GROUPS) and listed in NOT_LOCAL")
+        if not why.strip():
+            problems.append(f"{name}: NOT_LOCAL needs the reason ci-local does not run it")
     for name in names:
         try:
             data = load_workflow(name)
diff --git a/tools/release-scan.py b/tools/release-scan.py
new file mode 100644
index 00000000..a2d9bba2
--- /dev/null
+++ b/tools/release-scan.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""Refuse to publish an artifact that carries a private path or name (Python 3.9+).
+
+    python3 tools/release-scan.py target/octosense-package/dist/*
+    python3 tools/release-scan.py --extra 'some-private-host' OctoSense.app
+
+Every file is read as bytes, including the files inside the packages a
+release ships: `.app` bundles and directories, `.dmg` images (mounted with
+hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (`--appimage-extract` on
+Linux), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the
+installed files themselves are scanned before packaging on Windows, since
+the release workflow scans the staged payload too). An unreadable container
+fails the scan rather than being skipped.
+
+It fails on:
+
+- a macOS user directory (`/Users/`), a Windows one other than a
+  CI runner's (`C:\\Users\\runneradmin`, which the prebuilt NSIS plugin
+  cargo-packager bundles carries; also UTF-16), and a Linux home other than
+  a CI runner's (`/home/runner`);
+- a `.local` host name (mDNS: a build machine on a private network;
+  one label, as mDNS names are, so words glued together in a binary's
+  string data are not read as a dotted name);
+- a private IPv4 address (10/8, 172.16/12, 192.168/16);
+- the name of the account and host running the scan (skipped for generic CI
+  accounts), and every regular expression in `--extra` or the
+  `RELEASE_SCAN_EXTRA` environment variable (comma-separated; the release
+  workflow feeds it from a secret, so the patterns themselves stay private).
+
+Exit status 1 with one line per finding (the match is shown masked), 0 when
+clean. Findings are about the build, not the code: fix them with neutral
+build paths (see desktop/scripts/package.py). The only exceptions are the
+product's own `.local` constants (PRODUCT_LOCAL_NAMES), exact names.
+"""
+import argparse
+import getpass
+import io
+import os
+from pathlib import Path
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tarfile
+import tempfile
+import zipfile
+
+GENERIC_ACCOUNTS = {"runner", "runneradmin", "root", "admin", "administrator", "user", "builder", "build",
+                    "vagrant", "ubuntu", "ec2-user", "github", "ci", "localhost"}
+
+# `.local` names that are constants in the code, not machines: the Mail
+# service's SMTP EHLO name, and octos' own placeholder addresses (its fleet
+# worker's git identity, the solo profile, a test account).
+PRODUCT_LOCAL_NAMES = ("octosense.local", "octos.local", "solo.local", "test.local")
+
+BASE_PATTERNS = [
+    ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
+    ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"),
+    ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
+    ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
+    ("mDNS .local host name", rb"(?= 3 and user.lower() not in GENERIC_ACCOUNTS:
+        found.append(("the scanning account's name", rb"(?= 4 and host.lower() not in GENERIC_ACCOUNTS and not host.lower().startswith(("fv-az", "runner", "mac-", "ip-")):
+        found.append(("the scanning host's name", rb"(?= 5:
+                findings.append(f"{where}: {label}: (more matches not shown)")
+                break
+
+
+def scan_tree(root, label, patterns, findings):
+    root = Path(root)
+    count = 0
+    for path in sorted(root.rglob("*")):
+        if path.is_file() and not path.is_symlink():
+            count += 1
+            scan_bytes(path.read_bytes(), f"{label}/{path.relative_to(root).as_posix()}", patterns, findings)
+    return count
+
+
+def scan_tar(data, label, patterns, findings):
+    with tarfile.open(fileobj=io.BytesIO(data)) as tar:
+        for member in tar.getmembers():
+            if member.isfile():
+                scan_bytes(tar.extractfile(member).read(), f"{label}!{member.name}", patterns, findings)
+
+
+def ar_members(data):
+    """The members of a Unix ar archive (a .deb)."""
+    if not data.startswith(b"!\n"):
+        raise ValueError("not an ar archive")
+    pos = 8
+    while pos + 60 <= len(data):
+        header = data[pos:pos + 60]
+        name = header[:16].decode().strip().rstrip("/")
+        size = int(header[48:58].decode().strip())
+        yield name, data[pos + 60:pos + 60 + size]
+        pos += 60 + size + (size % 2)
+
+
+def scan_artifact(path, patterns, findings, dmg_bytes_only=False):
+    """Scan one artifact and what it contains; returns how many files.
+    `dmg_bytes_only`: a .dmg's own bytes only, where it cannot be mounted
+    (the release job on Linux; its contents were scanned on macOS)."""
+    path = Path(path)
+    name = path.name
+    if path.is_dir():
+        return scan_tree(path, name, patterns, findings)
+    data = path.read_bytes()
+    scan_bytes(data, name, patterns, findings)
+    lower = name.lower()
+    if lower.endswith(".dmg"):
+        if dmg_bytes_only:
+            return 1
+        if sys.platform != "darwin":
+            raise RuntimeError(f"{name}: a .dmg can only be opened on macOS")
+        with tempfile.TemporaryDirectory() as mount:
+            # An image with a license agreement waits for "Y" on stdin.
+            subprocess.run(["hdiutil", "attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", mount, str(path)],
+                           check=True, capture_output=True, input=b"Y\n", env={**os.environ, "PAGER": "cat"})
+            try:
+                return 1 + scan_tree(mount, name, patterns, findings)
+            finally:
+                subprocess.run(["hdiutil", "detach", mount, "-force"], capture_output=True)
+    if lower.endswith(".deb"):
+        count = 1
+        for member, blob in ar_members(data):
+            if member.startswith(("data.tar", "control.tar")):
+                if member.endswith(".zst"):
+                    raise RuntimeError(f"{name}: {member} is zstd; install dpkg-deb or repackage")
+                scan_tar(blob, f"{name}!{member}", patterns, findings)
+                count += 1
+        return count
+    if lower.endswith(".appimage"):
+        if not sys.platform.startswith("linux"):
+            raise RuntimeError(f"{name}: an AppImage can only be extracted on Linux")
+        with tempfile.TemporaryDirectory() as temp:
+            copy = Path(temp) / name
+            shutil.copy2(path, copy)
+            copy.chmod(0o755)
+            subprocess.run([str(copy), "--appimage-extract"], cwd=temp, check=True, capture_output=True)
+            return 1 + scan_tree(Path(temp) / "squashfs-root", name, patterns, findings)
+    if lower.endswith(".zip"):
+        with zipfile.ZipFile(path) as z:
+            for info in z.infolist():
+                if not info.is_dir():
+                    scan_bytes(z.read(info), f"{name}!{info.filename}", patterns, findings)
+        return 1
+    if re.search(r"\.tar(\.(gz|xz|bz2))?$|\.tgz$", lower):
+        scan_tar(data, name, patterns, findings)
+        return 1
+    if lower.endswith(".exe") and data[:2] == b"MZ" and b"Nullsoft" in data:
+        seven = shutil.which("7z") or shutil.which("7z.exe")
+        if not seven:
+            print(f"release-scan: {name}: no 7-Zip; scanned the installer's bytes only", file=sys.stderr)
+            return 1
+        with tempfile.TemporaryDirectory() as temp:
+            subprocess.run([seven, "x", "-y", f"-o{temp}", str(path)], check=True, capture_output=True)
+            return 1 + scan_tree(temp, name, patterns, findings)
+    return 1
+
+
+def main(argv=None):
+    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("artifacts", nargs="+", type=Path)
+    p.add_argument("--extra", action="append", default=[], help="Another regular expression that must not appear")
+    p.add_argument("--no-identity", action="store_true", help="Do not look for this machine's account and host names")
+    p.add_argument("--dmg-bytes-only", action="store_true",
+                   help="Scan a .dmg's bytes without mounting it (off macOS, after its contents were scanned on macOS)")
+    args = p.parse_args(argv)
+    extra = args.extra + os.environ.get("RELEASE_SCAN_EXTRA", "").split(",")
+    patterns = compile_patterns(extra, identity=not args.no_identity)
+    findings, files = [], 0
+    for artifact in args.artifacts:
+        if not artifact.exists():
+            p.error(f"no such artifact: {artifact}")
+        try:
+            files += scan_artifact(artifact, patterns, findings, args.dmg_bytes_only)
+        except (RuntimeError, ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as e:
+            findings.append(f"{artifact.name}: could not be opened for scanning: {e}")
+    for line in findings:
+        print(f"release-scan: {line}")
+    if findings:
+        print(f"release-scan: FAILED: {len(findings)} finding(s) in {len(args.artifacts)} artifact(s)", file=sys.stderr)
+        return 1
+    print(f"release-scan: clean: {len(args.artifacts)} artifact(s), {files} file(s), {len(patterns)} pattern(s)")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/test_ci_local.py b/tools/test_ci_local.py
index 71d2e314..c50cc2e7 100644
--- a/tools/test_ci_local.py
+++ b/tools/test_ci_local.py
@@ -73,6 +73,21 @@ class Drift(unittest.TestCase):
     def test_the_mapping_fits_the_workflows(self):
         self.assertEqual(ci.check_drift(), [])
 
+    def test_the_release_workflow_is_left_out_on_purpose(self):
+        self.assertIn("release-desktop.yml", ci.NOT_LOCAL)
+        self.assertNotIn("release-desktop.yml", ci.GROUPS["all"])
+        self.assertTrue(ci.NOT_LOCAL["release-desktop.yml"].strip())
+        # It is left out, not invisible: checked on its own it is drift.
+        self.assertTrue(ci.check_drift(["release-desktop.yml"]))
+        with tempfile.TemporaryDirectory() as temp:
+            workflows = Path(temp)
+            for path in (ROOT / ".github/workflows").glob("*.yml"):
+                if path.name != "release-desktop.yml":
+                    shutil.copy(path, workflows / path.name)
+            with patch.object(ci, "WORKFLOWS", workflows):
+                problems = "\n".join(ci.check_drift())
+        self.assertIn("release-desktop.yml: listed in NOT_LOCAL but no such workflow", problems)
+
     def test_a_changed_workflow_is_drift(self):
         with tempfile.TemporaryDirectory() as temp:
             workflows = Path(temp)
diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py
new file mode 100644
index 00000000..abaa781d
--- /dev/null
+++ b/tools/test_release_scan.py
@@ -0,0 +1,120 @@
+"""Tests for tools/release-scan.py (no network; containers built in a temp dir)."""
+import contextlib
+import importlib.util
+import io
+from pathlib import Path
+import tarfile
+import tempfile
+import unittest
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("release_scan", ROOT / "tools/release-scan.py")
+scan = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(scan)
+
+
+def findings(data, extra=()):
+    out = []
+    scan.scan_bytes(data, "x", scan.compile_patterns(extra, identity=False), out)
+    return out
+
+
+class PatternTests(unittest.TestCase):
+    def test_private_paths_and_hosts_are_found(self):
+        for leak in (b"/Users/someone/src/app.rs", b"C:\\Users\\someone\\.cargo", b"c:/Users/someone/x",
+                     "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"my-mac.local", b"http://studio.local/api", 
+                     b"http://192.168.1.20:8080", b"10.0.0.7", b"172.20.1.1"):
+            self.assertTrue(findings(b"at " + leak + b" end"), leak)
+
+    def test_ci_and_system_paths_are_fine(self):
+        for fine in (b"/home/runner/work/OctoSense", b"C:\\Users\\runneradmin\\.cargo\\registry", b"/usr/local/lib", b"/rustc/abc/library/std", b"~/.cargo/registry",
+                     b"./crates/shell/src/lib.rs", b"localhost.localdomain", b"127.0.0.1", b"1.10.0.7",
+                     b"version 10.2.3.4.5", b"/cargo/registry/src", b"EHLO octosense.local\r\n",
+                     b"fleet-worker@octos.local", b"e2e@test.local",
+                     b"forbiddenutf-8.local/bin/ominix-api", b"x.local/share/y",
+                     b"not found.forbiddenutf-8.local/bin/x", b"command not foundnewTab.local/sharekde-open"):
+            self.assertEqual(findings(fine), [], fine)
+
+    def test_findings_are_masked_and_extra_patterns_apply(self):
+        out = findings(b"/Users/someone/x")
+        self.assertEqual(len(out), 1)
+        self.assertNotIn("someone", out[0], "the match itself is not echoed")
+        self.assertTrue(findings(b"the build host is zeta-box", extra=["zeta-box"]))
+        self.assertEqual(findings(b"clean", extra=["", " "]), [], "empty patterns are ignored")
+
+    def test_identity_patterns_skip_generic_accounts(self):
+        self.assertEqual(scan.identity_patterns("runner", "fv-az123"), [])
+        self.assertEqual(scan.identity_patterns("root", "localhost"), [])
+        labels = [label for label, _ in scan.identity_patterns("jdoe-dev", "workbench")]
+        self.assertEqual(len(labels), 2)
+
+
+class ContainerTests(unittest.TestCase):
+    def run_scan(self, *paths):
+        out = io.StringIO()
+        with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+            code = scan.main(["--no-identity", *map(str, paths)])
+        return code, out.getvalue()
+
+    def test_leaks_inside_packages_fail_the_scan(self):
+        with tempfile.TemporaryDirectory() as temp:
+            temp = Path(temp)
+            payload = b"binary /Users/someone/.cargo/registry/src"
+            # A directory (an .app), a zip, a tar.gz and a .deb (ar + data.tar.gz).
+            app = temp / "OctoSense.app/Contents/MacOS"
+            app.mkdir(parents=True)
+            (app / "octosense").write_bytes(payload)
+            with zipfile.ZipFile(temp / "a.zip", "w", zipfile.ZIP_DEFLATED) as z:
+                z.writestr("OctoSense.app/Contents/MacOS/octosense", payload)
+            tar_bytes = io.BytesIO()
+            with tarfile.open(fileobj=tar_bytes, mode="w:gz") as t:
+                info = tarfile.TarInfo("./usr/bin/octosense")
+                info.size = len(payload)
+                t.addfile(info, io.BytesIO(payload))
+            (temp / "a.tar.gz").write_bytes(tar_bytes.getvalue())
+            blob = tar_bytes.getvalue()
+            deb = b"!\n"
+            for name, data in (("debian-binary", b"2.0\n"), ("data.tar.gz", blob)):
+                deb += f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode() + data
+                if len(data) % 2:
+                    deb += b"\n"
+            (temp / "octosense_0.1.0_amd64.deb").write_bytes(deb)
+            for artifact in ("OctoSense.app", "a.zip", "a.tar.gz", "octosense_0.1.0_amd64.deb"):
+                code, out = self.run_scan(temp / artifact)
+                self.assertEqual(code, 1, artifact)
+                self.assertIn("macOS user directory", out)
+
+    def test_a_clean_artifact_passes(self):
+        with tempfile.TemporaryDirectory() as temp:
+            path = Path(temp) / "SHA256SUMS"
+            path.write_text("abc  OctoSense_0.1.0_aarch64.dmg\n")
+            code, out = self.run_scan(path)
+            self.assertEqual(code, 0)
+            self.assertIn("clean", out)
+
+    def test_a_dmg_is_opened_unless_told_its_contents_were_scanned(self):
+        with tempfile.TemporaryDirectory() as temp:
+            path = Path(temp) / "OctoSense_0.1.0_aarch64.dmg"
+            path.write_bytes(b"not really a disk image")
+            code, _ = self.run_scan("--dmg-bytes-only", path)
+            self.assertEqual(code, 0)
+            if scan.sys.platform != "darwin":
+                code, out = self.run_scan(path)
+                self.assertEqual(code, 1, "off macOS a .dmg cannot be opened, so it fails")
+            leaky = Path(temp) / "leaky.dmg"
+            leaky.write_bytes(b"x /Users/someone/y")
+            code, out = self.run_scan("--dmg-bytes-only", leaky)
+            self.assertEqual(code, 1, "its bytes are still scanned")
+
+    def test_an_unopenable_container_fails(self):
+        with tempfile.TemporaryDirectory() as temp:
+            path = Path(temp) / "broken.deb"
+            path.write_bytes(b"not an ar archive")
+            code, out = self.run_scan(path)
+            self.assertEqual(code, 1)
+            self.assertIn("could not be opened", out)
+
+
+if __name__ == "__main__":
+    unittest.main()

From e1bca5aeaa083d39f792430893876509feb6d47c Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:58:00 -0700
Subject: [PATCH 2/2] release-scan: matrix-sdk's send-queue server name is not
 a build host

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 
---
 tools/release-scan.py      | 20 +++++++++++++++++---
 tools/test_release_scan.py | 10 ++++++++++
 2 files changed, 27 insertions(+), 3 deletions(-)

diff --git a/tools/release-scan.py b/tools/release-scan.py
index a2d9bba2..d8fc2b0f 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -29,8 +29,9 @@
 
 Exit status 1 with one line per finding (the match is shown masked), 0 when
 clean. Findings are about the build, not the code: fix them with neutral
-build paths (see desktop/scripts/package.py). The only exceptions are the
-product's own `.local` constants (PRODUCT_LOCAL_NAMES), exact names.
+build paths (see desktop/scripts/package.py). The only exceptions are known
+`.local` constants, exact names: the product's own (PRODUCT_LOCAL_NAMES) and
+its dependencies' (DEPENDENCY_LOCAL_NAMES).
 """
 import argparse
 import getpass
@@ -54,12 +55,25 @@
 # worker's git identity, the solo profile, a test account).
 PRODUCT_LOCAL_NAMES = ("octosense.local", "octos.local", "solo.local", "test.local")
 
+# The same in a dependency, one entry per constant, exact names. Each says
+# whose constant it is and why it shows up as a `.local` name.
+DEPENDENCY_LOCAL_NAMES = (
+    # matrix-sdk (Rinx's Matrix client) names media still in its send queue
+    # `mxc://send-queue.localhost/` (LOCAL_MXC_SERVER_NAME,
+    # crates/matrix-sdk/src/media.rs). x86-64 code compares a server name
+    # with it 16 bytes at a time, so its first 16 bytes are a constant of
+    # their own; when the next constant starts with a non-name byte, they
+    # read as this name (seen in the Windows build).
+    "send-queue.local",
+)
+
 BASE_PATTERNS = [
     ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
     ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"),
     ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
     ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
-    ("mDNS .local host name", rb"(?