Skip to content

install macOS .app directory bundles - #200

Merged
jaemk merged 8 commits into
masterfrom
260726.bundle-install-phase-a
Jul 28, 2026
Merged

install macOS .app directory bundles#200
jaemk merged 8 commits into
masterfrom
260726.bundle-install-phase-a

Conversation

@jaemk

@jaemk jaemk commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Implements the macOS .app support from specs/bundle-install.md (spec merged in #198). Closes #145.

Setting bundle_path_in_archive(..) selects bundle mode: the named directory inside the archive replaces bundle_install_path(..) as one unit, instead of a single file replacing bin_install_path. A .app bundle's resources and code signature have to move with its executable, so a file-at-a-time replacement cannot work.

self_update::backends::github::Update::configure()
    .repo_owner("me")
    .repo_name("MyApp")
    .bin_name("MyApp")
    .bundle_path_in_archive("MyApp.app")     // bundle_install_path defaults to the
    .current_version(cargo_crate_version!()) // running exe's enclosing .app on macOS
    .build()?
    .update()?;
  • Add the bundle_path_in_archive / bundle_install_path setters to the shared builder surface, threaded CommonBuilderConfig -> CommonConfig -> FinishCtx so the sync and async finish tails stay identical. build() rejects bundle mode combined with an explicit bin_install_path or bin_path_in_archive as Error::ConflictingConfig, and a bundle_install_path without the archive path as Error::MissingField.
  • Resolve the install path on macOS from the nearest .app ancestor of current_exe() when unset: Error::NoAppBundle when there is none, Error::AppTranslocated for a quarantined app running from a read-only translocated mount. Other targets require the setter.
  • Swap by rename: extract into a temp dir inside the destination's parent (so every rename is same-filesystem, no copy fallback), stash the displaced tree, rename the staged tree into place. A failure at any step reverses the applied renames and returns the original error. A running executable inside the bundle is renamed aside first, so its path holds the new executable afterwards and no self_replace call is involved.
  • Run verify_binary against the staged bundle root, and point the opt-in check_install_path_writable preflight at the bundle's parent.
  • Add Error::NoAppBundle, Error::ConflictingConfig, Error::AppTranslocated. Error is #[non_exhaustive], so these are additive.

bin_install_path mode is untouched: bundle mode is only reachable through the new setter.

CI

macOS had no job at all, so the .app path resolution had never been compiled, and the windows job's feature list had drifted from the Makefile.

  • Add a macos job (macos-latest, arm64) running the reqwest and ureq test lanes. Beyond the bundle swap this covers APFS's case-insensitive filenames, macOS symlink and rename semantics, and self_replace. None of that is architecture-dependent, so no x86_64 runner is used.
  • Hoist REQWEST_FEATURES / UREQ_FEATURES into workflow-level env, consumed by the windows, macos, and msrv jobs. This restores gitee, manifest, and compression-tar-xz coverage on windows, and makes msrv build the full set its comment already claimed (cargo +1.88.0 build passes, so the declared MSRV does not move).
  • Add make check/workflow-features, wired into check, so a future drift between those env values and the Makefile fails CI.

Also carries the design for the shared-egress-IP case in specs/auth-token-from-env.md (auth_token_from_env() plus a distinct Error::RateLimited), unimplemented here. Behind one NAT'd public IP the 60/hour unauthenticated GitHub REST budget is pooled across every client, so an update check can 403 through no traffic of its own.

Notes

  • The whole-tree swap is not coordinated between concurrent updaters (BNDL-5-5), same as the existing single-file install.
  • A caller-supplied .. in bundle_path_in_archive can point outside the staging dir. Unchanged from the bin_path_in_archive contract; the traversal guard covers the templated release version, which is the part callers do not control.
  • Windows in-bundle swaps are allowed but can fail on a locked file. Documented, not worked around.
  • Still needs a human on a signed build: codesign --verify after a swap, Gatekeeper, a quarantined copy, Finder launch, and relaunch via restart(). Tracked in the spec's manual matrix.

jaemk added 8 commits July 26, 2026 17:15
Replace the open-questions section of the bundle-install spec with the
decisions, and flip the status to pending:

- rename the archive-side setter to `bundle_path_in_archive`, symmetric with
  `bin_path_in_archive`
- hard `build()` error when bundle mode is combined with an explicit
  `bin_path_in_archive` / `bin_install_path`
- macOS resolves the install path from the nearest `.app` ancestor of
  `current_exe()` when unset; no ancestor is an error
- non-macOS in-bundle swaps allowed, with the file-locking caveat documented
- no cross-device fallback: staging lives in the destination parent, so every
  rename is same-filesystem
- `verify_binary` receives the staged bundle root
- add `Error::NoAppBundle`, `Error::ConflictingConfig`, and
  `Error::AppTranslocated`; `Error` is `#[non_exhaustive]`, so these are not
  breaking
- detect an `AppTranslocation` path component and fail early rather than
  surfacing a read-only-filesystem error mid-swap (BNDL-5-3)
Records the design for the shared-egress-IP case, where the 60/hour
unauthenticated GitHub REST budget is pooled across every client behind one
public IP and a check returns 403 through no traffic of its own:

- `auth_token_from_env()` on the backend builders, reading the conventional
  per-forge variables (`GITHUB_TOKEN` then `GH_TOKEN`, `GITLAB_TOKEN` then
  `CI_JOB_TOKEN`, `GITEA_TOKEN`, `GITEE_TOKEN`) and leaving the token unset when
  none is present. Opt-in, never an automatic env read.
- `Error::RateLimited { status, url, reset_at, retry_after }`, classified from a
  zero remaining-quota header on a 403 or 429 so it is distinguishable from a
  credential failure.
- Documents that the unauthenticated budget is counted per source IP.
Setting `bundle_path_in_archive(..)` selects bundle mode, where the named
directory inside the archive replaces `bundle_install_path(..)` as one unit
instead of a single file replacing `bin_install_path`. Aimed at macOS `.app`
bundles, whose resources and code signature have to move with the executable.

- Add the `bundle_path_in_archive` / `bundle_install_path` setters to the shared
  builder surface, threaded through `CommonBuilderConfig` -> `CommonConfig` ->
  `FinishCtx`, so the sync and async finish tails stay identical. `build()`
  rejects bundle mode combined with an explicit `bin_install_path` or
  `bin_path_in_archive` as `Error::ConflictingConfig`.
- Resolve the install path on macOS from the nearest `.app` ancestor of
  `current_exe()` when unset, with `Error::NoAppBundle` when there is none and
  `Error::AppTranslocated` for a quarantined app running from a read-only
  translocated mount. Other targets require the setter.
- Swap by rename: extract into a temp dir inside the destination's parent (so
  every rename is same-filesystem, with no copy fallback), stash the displaced
  tree, then rename the staged tree into place. A failure at any step reverses
  the applied renames and returns the original error. A running executable
  inside the bundle is renamed aside first, so its path holds the new
  executable afterwards and no `self_replace` call is involved.
- Run `verify_binary` against the staged bundle root, and point the opt-in
  `check_install_path_writable` preflight at the bundle's parent directory.
- Document the flow in the crate docs and record it in the bundle-install,
  update-pipeline, errors, and common-config specs.
… bundles

Adds the paths the first cut left untested:

- the finish tail in bundle mode end to end, including a nested path with all
  three `{{ .. }}` templates, and the `is_safe_asset_name` traversal guard
  against a malicious release version substituted into the bundle path
- `swap_bundle` failures at the exe-aside and stash-old steps, and a hook error
  that is not already a `VerificationRejected`
- `install_bundle` over a `.tar.gz` bundle (exec bit and symlink target), an
  unwritable install parent, and an archive carrying no bundle directory,
  asserting no staging or stash residue on the error paths
- `install_parent` for bare, nested, and absolute names; `probe_dir_writable`
  for a missing directory; `exe_inside_bundle`'s lexical fallback and its
  component-wise containment (`MyApp.app.bak` is outside `MyApp.app`)
- `FinishCtx` carrying the bundle fields on both the sync and async paths
- the setters and accessors on a real backend builder, `bin_name` still being
  required in bundle mode, and the conflict being reported before the install
  path is resolved
…he link

`rename` does not follow a path's final component, so a symlinked install path
was stashed as a link and replaced by a real directory, leaving the installed
bundle orphaned on disk with no error. `resolve_bundle_target` now maps a live
symlink to the tree it designates before the swap, so that tree is what gets
replaced, the link survives, and staging is still created beside the real tree
(keeping every rename same-filesystem).

Also in bundle mode:

- Test for an existing destination with `symlink_metadata` instead of
  `exists()`, so a dangling symlink is stashed and replaced rather than being
  renamed onto (which fails with ENOTDIR).
- Match the `.app` extension case-insensitively, since macOS's default
  filesystem preserves case without distinguishing it.
- Reject `bundle_install_path` set without `bundle_path_in_archive` as
  `Error::MissingField` instead of silently discarding the install path.
- Name the bundle path in the confirmation block ("Current bundle:") and say the
  bundle directory will be replaced, since `bin_install_path` is never written.
- Drop `bin_install_path` from the `InstallPathNotWritable` docs and message: the
  same variant now also carries a bundle path, or its parent from the preflight.
…ost-agnostic

macOS is the target platform for directory-bundle installs but had no CI job, so
the `.app` default-path resolution was never compiled, let alone run. Two
changes:

- Add a `macos` job (`macos-latest`, arm64) running the reqwest and ureq test
  lanes. Beyond the bundle swap this covers APFS's case-insensitive filenames,
  macOS symlink and rename semantics, and `self_replace`. None of that is
  architecture-dependent, so arm64 alone is enough.
- Split `resolve_default_bundle_path(exe, has_default)` out of
  `default_bundle_install_path`, with the macOS policy carried by a `cfg!` value
  instead of a `#[cfg]` branch. Every arm now compiles and is tested on every
  host: an exe inside a bundle resolves to it, one outside is `NoAppBundle`, a
  translocated exe is rejected ahead of the `.app` lookup, and a target with no
  `.app` convention requires the setter. This also drops the `allow(dead_code)`
  the two path helpers needed off macOS.

The manual matrix in the spec now covers only what needs a signed build or a
real download: `codesign --verify`, Gatekeeper, a quarantined copy, Finder
launch, and relaunch via `restart()`.
…ard the feature lists

The windows job's feature list had drifted from the Makefile's `REQWEST_FEATURES`
/ `UREQ_FEATURES`, so the `gitee` and `manifest` backends and `.tar.xz` decoding
had never been tested on windows, and the msrv job was building a narrower set
than the "full reqwest feature set" its comment claimed.

- Hoist both lists into workflow-level `env`, consumed by the windows, macos, and
  msrv jobs (the first two cannot run `make`).
- Add `make check/workflow-features`, wired into `check`, comparing those env
  values against the Makefile and failing with both sides printed on a mismatch.
  Verified by injecting a drift.
- Point msrv at the same list. `cargo +1.88.0 build` with the full set passes, so
  the added backends do not move the declared MSRV.
… suffix

`bin_name("app")` appends `EXE_SUFFIX`, so the derived `bin_path_in_archive` is
`app.exe` on windows, but `app_tar_gz` hardcoded an entry named `app`. Both
manifest end-to-end tests failed there with "Could not find the required path in
the archive". Build the entry name the same way the custom, gitea, and gitlab
fixtures already do.

Latent since the backend landed: the windows job's feature list did not include
`manifest`, so these tests had never run on windows.
@jaemk
jaemk force-pushed the 260726.bundle-install-phase-a branch from 226b02d to a3b14f2 Compare July 27, 2026 13:00
@jaemk
jaemk merged commit 18d3e02 into master Jul 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for Bundles

1 participant