install macOS .app directory bundles - #200
Merged
Merged
Conversation
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
force-pushed
the
260726.bundle-install-phase-a
branch
from
July 27, 2026 13:00
226b02d to
a3b14f2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the macOS
.appsupport fromspecs/bundle-install.md(spec merged in #198). Closes #145.Setting
bundle_path_in_archive(..)selects bundle mode: the named directory inside the archive replacesbundle_install_path(..)as one unit, instead of a single file replacingbin_install_path. A.appbundle's resources and code signature have to move with its executable, so a file-at-a-time replacement cannot work.bundle_path_in_archive/bundle_install_pathsetters to the shared builder surface, threadedCommonBuilderConfig->CommonConfig->FinishCtxso the sync and async finish tails stay identical.build()rejects bundle mode combined with an explicitbin_install_pathorbin_path_in_archiveasError::ConflictingConfig, and abundle_install_pathwithout the archive path asError::MissingField..appancestor ofcurrent_exe()when unset:Error::NoAppBundlewhen there is none,Error::AppTranslocatedfor a quarantined app running from a read-only translocated mount. Other targets require the setter.self_replacecall is involved.verify_binaryagainst the staged bundle root, and point the opt-incheck_install_path_writablepreflight at the bundle's parent.Error::NoAppBundle,Error::ConflictingConfig,Error::AppTranslocated.Erroris#[non_exhaustive], so these are additive.bin_install_pathmode is untouched: bundle mode is only reachable through the new setter.CI
macOS had no job at all, so the
.apppath resolution had never been compiled, and the windows job's feature list had drifted from the Makefile.macosjob (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, andself_replace. None of that is architecture-dependent, so no x86_64 runner is used.REQWEST_FEATURES/UREQ_FEATURESinto workflow-levelenv, consumed by the windows, macos, and msrv jobs. This restoresgitee,manifest, andcompression-tar-xzcoverage on windows, and makes msrv build the full set its comment already claimed (cargo +1.88.0 buildpasses, so the declared MSRV does not move).make check/workflow-features, wired intocheck, 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 distinctError::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
..inbundle_path_in_archivecan point outside the staging dir. Unchanged from thebin_path_in_archivecontract; the traversal guard covers the templated release version, which is the part callers do not control.codesign --verifyafter a swap, Gatekeeper, a quarantined copy, Finder launch, and relaunch viarestart(). Tracked in the spec's manual matrix.