Skip to content
Merged
29 changes: 24 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:

env:
CARGO_TERM_COLOR: always
# The full per-client feature sets, for the jobs that cannot run `make` (the Makefile is the source
# of truth for the lanes; `make check/workflow-features` fails if these drift from it).
REQWEST_FEATURES: github gitlab gitea gitee manifest s3 archive-tar archive-zip compression-tar-gz compression-tar-xz compression-zip-deflate compression-zip-bzip2 signatures checksums s3-auth
UREQ_FEATURES: ureq native-tls github gitlab gitea gitee manifest s3 archive-tar archive-zip compression-tar-gz compression-tar-xz compression-zip-deflate compression-zip-bzip2 signatures checksums s3-auth

jobs:
ci:
Expand Down Expand Up @@ -48,14 +52,29 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.88.0
# Pin the declared MSRV: the full reqwest feature set must build on 1.88 (zip 8 requires 1.88).
- run: cargo build --features "github gitlab gitea s3 archive-tar archive-zip compression-tar-gz compression-zip-deflate compression-zip-bzip2 signatures s3-auth checksums"
- run: cargo build --features "${{ env.REQWEST_FEATURES }}"

macos:
# macOS is the target platform for directory-bundle (`.app`) installs, so run the suite there.
# `macos-latest` is arm64 (Apple Silicon). 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. Runners are free for public repos.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# `make` is available here, but the fmt/README lanes are already covered on linux, so run the
# test lanes directly on both clients.
- run: cargo test --features "${{ env.REQWEST_FEATURES }}"
- run: cargo test --no-default-features --features "${{ env.UREQ_FEATURES }}"

windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
# Exercise the Windows-sensitive paths (asset-name guard, zip extraction, self-replace) on both
# clients. `make` is not available on the Windows runner, so run the lanes directly.
- run: cargo test --features "github gitlab gitea s3 archive-tar archive-zip compression-tar-gz compression-zip-deflate compression-zip-bzip2 signatures s3-auth checksums"
- run: cargo test --no-default-features --features "ureq native-tls github gitlab gitea s3 archive-tar archive-zip compression-tar-gz compression-zip-deflate compression-zip-bzip2 signatures s3-auth checksums"
# Exercise the Windows-sensitive paths (asset-name guard, zip extraction, self-replace, the
# bundle swap's file-locking caveat) on both clients. `make` is not available on the Windows
# runner, so run the lanes directly.
- run: cargo test --features "${{ env.REQWEST_FEATURES }}"
- run: cargo test --no-default-features --features "${{ env.UREQ_FEATURES }}"
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
## [unreleased]

### Added
- Directory-bundle installs (macOS `.app`): `bundle_path_in_archive(..)` names the bundle directory
inside the release archive and selects bundle mode, where the whole tree replaces
`bundle_install_path(..)` instead of one file replacing `bin_install_path`. The new bundle is
staged in the destination's parent and swapped by rename with the displaced tree stashed, so a
failure restores the original bundle; a running executable inside the bundle is renamed aside
first, so its path holds the new executable afterwards and composes with `restart()`. On macOS
`bundle_install_path` defaults to the nearest `.app` ancestor of the running executable. The
`verify_binary` hook receives the staged bundle root, and the opt-in
`check_install_path_writable` preflight probes the bundle's parent directory. Adds
`Error::NoAppBundle` (no `.app` ancestor to derive the path from), `Error::ConflictingConfig`
(bundle mode combined with an explicit `bin_install_path` / `bin_path_in_archive`), and
`Error::AppTranslocated` (a quarantined app running from a read-only translocated mount). A
symlinked `bundle_install_path` is resolved first, so the tree behind the link is replaced and the
link survives; `bundle_install_path` without `bundle_path_in_archive` is a `MissingField` error
rather than a silently discarded path.
([#145](https://github.com/jaemk/self_update/issues/145))
- `compression-tar-xz` feature: decode `.tar.xz` / `.txz` archives and plain `.xz` single-file
assets (pure-Rust `lzma-rs`, no C `liblzma` dependency, so it cross-compiles like the rest of the
default stack). Opt-in, mirroring `compression-tar-gz`. Adds `Compression::Xz`.
Expand Down
36 changes: 34 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ EXAMPLE_TARGETS = examples $(SELF_UPDATE_EXAMPLE_TARGETS)
TEST_TARGETS = tests tests/default tests/reqwest tests/ureq tests/async
BUILD_TARGETS = build/all-features
DOC_TARGETS = docs docs/readme
CHECK_TARGETS = check check/fmt check/readme check/clippy check/clippy/reqwest check/clippy/ureq check/clippy/async check/help
CHECK_TARGETS = check check/fmt check/readme check/clippy check/clippy/reqwest check/clippy/ureq check/clippy/async check/help check/workflow-features
CLEAN_TARGETS = clean clean/cargo
HELP_TARGETS = help ci $(EXAMPLE_TARGETS) $(TEST_TARGETS) $(BUILD_TARGETS) $(DOC_TARGETS) fmt $(CHECK_TARGETS) $(CLEAN_TARGETS)

# The CI workflow. The windows and macos jobs cannot run `make`, so they carry
# the per-client feature sets above as workflow env vars;
# `check/workflow-features` fails if the two copies drift apart.
WORKFLOW = .github/workflows/build.yml

# Cargo command used to run `build`, `test`, `clippy`... Useful if you keep
# multiple cargo versions installed on your machine.
CARGO_COMMAND = cargo
Expand Down Expand Up @@ -89,6 +94,7 @@ help: ## List all supported Make targets
check/clippy/ureq) desc="Run clippy with the full ureq feature set" ;; \
check/clippy/async) desc="Run clippy with the async API feature set" ;; \
check/help) desc="Verify the help output covers every supported target" ;; \
check/workflow-features) desc="Verify the workflow feature lists match this Makefile" ;; \
clean) desc="Remove all generated artifacts" ;; \
clean/cargo) desc="Run cargo clean" ;; \
*) desc="" ;; \
Expand Down Expand Up @@ -168,7 +174,7 @@ fmt:

################################################################################
# Runs all checks.
check: check/fmt check/readme check/clippy check/help
check: check/fmt check/readme check/clippy check/help check/workflow-features

# Checks that the crate is well formatted.
check/fmt: FMT_CCFLAGS += --check
Expand Down Expand Up @@ -212,6 +218,32 @@ check/help:
exit 1; \
fi

# Verifies the workflow's per-client feature lists match this Makefile, which is
# the source of truth for the lanes. A stale copy silently stops testing whole
# backends on the runners that cannot use `make`.
check/workflow-features:
@echo [$@]: Checking workflow feature lists...
@fail=0; \
for var in REQWEST_FEATURES UREQ_FEATURES; do \
case "$$var" in \
REQWEST_FEATURES) expected="$(REQWEST_FEATURES)" ;; \
UREQ_FEATURES) expected="$(UREQ_FEATURES)" ;; \
esac; \
expected="$$(printf '%s' "$$expected" | tr -s ' ')"; \
actual="$$(grep -E "^ $$var:" $(WORKFLOW) | head -1 | cut -d: -f2- \
| tr -s ' ' | sed -e 's/^ *//' -e 's/ *$$//')"; \
if [ -z "$$actual" ]; then \
echo "$$var is missing from $(WORKFLOW)" >&2; \
fail=1; \
elif [ "$$expected" != "$$actual" ]; then \
echo "$$var differs between the Makefile and $(WORKFLOW):" >&2; \
echo " Makefile: $$expected" >&2; \
echo " workflow: $$actual" >&2; \
fail=1; \
fi; \
done; \
exit $$fail

################################################################################
# Cleans all generated artifacts.
clean: clean/cargo
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,62 @@ fn update() -> Result<(), Box<dyn std::error::Error>> {
}
```

### Bundle installs (macOS `.app`)

A macOS application is a *directory* bundle, so replacing only the executable inside
`MyApp.app/Contents/MacOS/` leaves stale resources behind and breaks the bundle's code signature.
Set `bundle_path_in_archive` to name the bundle directory inside the release archive and the whole
tree is installed as one unit:

```rust
fn update() -> Result<(), Box<dyn std::error::Error>> {
self_update::backends::github::Update::configure()
.repo_owner("me")
.repo_name("myapp")
.bin_name("myapp")
.current_version(self_update::cargo_crate_version!())
// The bundle directory inside the archive; `{{ bin }}` / `{{ target }}` / `{{ version }}`
// substitutions work here exactly as in `bin_path_in_archive`.
.bundle_path_in_archive("MyApp.app")
// Optional on macOS: defaults to the nearest `.app` ancestor of the running executable.
.bundle_install_path("/Applications/MyApp.app")
.build()?
.update()?;
Ok(())
}
```

How the swap works, and what it guarantees:

- The archive is extracted in full into a temporary directory **inside the install path's parent**,
so every rename is on one filesystem (there is no cross-device fallback, and the parent needs
room for one more copy of the bundle). A symlinked `bundle_install_path` is resolved first, so the
tree behind the link is replaced, the link survives, and staging still lands beside the real tree.
- The installed tree is stashed, then the staged tree is renamed into place. A failure at any step
restores the original bundle, and the error names the bundle path. Once the final rename lands the
update is committed.
- When the running executable lives inside the bundle it is renamed aside first, so the old tree
holds no running image. After a successful update the running executable's path holds the new
bundle's executable, and the process can relaunch itself with `restart()` (see
[Restarting after an update](#restarting-after-an-update)).
- Bundle mode replaces a directory, so combining it with an explicit `bin_install_path` or
`bin_path_in_archive` is rejected by `build()` (`Error::ConflictingConfig`), and setting
`bundle_install_path` without `bundle_path_in_archive` is an `Error::MissingField` rather than a
silently discarded path. `bin_name` is still required: it selects the asset and feeds `{{ bin }}`.
- The `verify_binary` hook receives the **staged bundle root**, which is what
`codesign --verify --deep` wants; a rejection aborts before anything is replaced.
- The crate never signs, notarizes, or staples: ship an already-signed (and, for Gatekeeper,
notarized) `.app` and the swap preserves exactly what you shipped. A quarantined app running from
a read-only App Translocation mount cannot update itself in place; that is detected up front as
`Error::AppTranslocated`, and the fix is to move the app (which clears the quarantine) and
relaunch it.

Directory bundles on linux and windows go through the same code path. On windows the swap fails,
and rolls back, if the process holds files inside the bundle open beyond its own executable (a DLL
loaded from the bundle, for example). `.deb` / `.msi` packages are a different shape entirely --
hand the downloaded file to `dpkg -i` / `msiexec /i` yourself; the crate's replace-and-verify
semantics do not apply to a system installer.

### Checksum verification

With the `checksums` feature, the crate verifies the downloaded artifact against a digest
Expand Down
3 changes: 2 additions & 1 deletion specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ design before it can be built). Keep each row's status current with `spec.py set
| Restart After Update | done | [ref-restart.md](ref-restart.md) |
| Update-check Interval Guard | done | [ref-check-interval.md](ref-check-interval.md) |
| Manifest Backend | done | [ref-manifest-backend.md](ref-manifest-backend.md) |
| Bundle Install | research | [bundle-install.md](bundle-install.md) |
| Bundle Install | done | [bundle-install.md](bundle-install.md) |
| Auth Token from Env | pending | [auth-token-from-env.md](auth-token-from-env.md) |

## Conventions

Expand Down
112 changes: 112 additions & 0 deletions specs/auth-token-from-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Auth token from env, and rate-limit errors

Status: pending (decided 2026-07-26; not implemented)

## Problem

An update check behind a shared egress IP (a NAT'd corporate network) fails with
HTTP 403 once the unauthenticated GitHub REST budget, 60 requests/hour counted
per source IP, is exhausted by everyone sharing that IP. The fix is to send a
token, but the crate makes each consumer plumb one in itself, and the resulting
403 is indistinguishable from a real credential failure.

Current behavior:

- `auth_token(impl Into<String>)` on every backend builder is the only way to
supply a token (`src/backends/github.rs:147`). There is no env-var path, so
every consumer writes the same `std::env::var("GITHUB_TOKEN")` plumbing,
including the skip-when-empty case.
- The token is already forwarded safely: `apply_auth` attaches it only to a URL
whose host matches the configured API base or an `allow_auth_host` entry, and
only over https (`src/backends/common.rs:322-356`), with a per-backend scheme
(github/gitea `Token`, gitlab `Bearer`, `common.rs:196`). Nothing about the
env source changes that gate.
- A rate-limited response surfaces as `Error::Unauthorized { status: 403, url }`
(`src/errors.rs:75`), the same variant as a bad token, so a caller cannot tell
"wait for the window to reset, or set a token" from "these credentials are
wrong". README:360 documents the limits and tells the reader to recognize the
rate-limit case by its symptom.

## AUTH-1: token from the environment

AUTH-1-1. `auth_token_from_env()` is added to the backend `UpdateBuilder` and
`ReleaseListBuilder` types that take an `auth_token`. It reads the backend's
conventional env vars in order and uses the first that is present and non-empty
after trimming surrounding whitespace:

- github: `GITHUB_TOKEN`, then `GH_TOKEN` (matching the `gh` CLI).
- gitlab: `GITLAB_TOKEN`, then `CI_JOB_TOKEN`.
- gitea: `GITEA_TOKEN`.
- gitee: `GITEE_TOKEN`.

AUTH-1-2. No variable set (or all empty) leaves `auth_token` unset: the request
goes out unauthenticated exactly as today, no error. This makes the call safe to
place unconditionally in an application that also runs outside CI or a corporate
network.

AUTH-1-3. Reading env is opt-in, never automatic. A library that harvests
credentials from the environment without being asked is surprising, and the
configured API base can be a self-hosted host, so an implicit read would decide
on its own to send a user's token somewhere. The explicit call keeps the
decision with the embedding application. `auth_token(..)` and
`auth_token_from_env()` are last-setter-wins.

AUTH-1-4. The env read happens in the setter (not at request time), so the
resolved value is visible in the builder's `Debug` output (redacted as
`<token>`, `common.rs:273`) and the behavior does not depend on env changes made
later in the process.

AUTH-1-5. Tests: the env-var precedence is exercised through a pure helper
taking the candidate `(name, value)` pairs, so no test mutates process env
(which is racy under the parallel test harness). Cover first-wins, empty-skip,
whitespace-trim, and none-set.

## AUTH-2: distinguishable rate-limit error

AUTH-2-1. New variant `Error::RateLimited { status, url, reset_at, retry_after }`
(`Error` is `#[non_exhaustive]`, `src/errors.rs:21`, so this is a minor-version
addition). `reset_at` is the parsed reset instant when the response carries one,
`retry_after` the `Retry-After` delay when present; both `Option`.

AUTH-2-2. A 403 (or 429) response is classified as `RateLimited` instead of
`Unauthorized` when it carries a zero remaining-quota header:
`x-ratelimit-remaining: 0` with `x-ratelimit-reset` (github, gitea, gitee), or
`RateLimit-Remaining: 0` (gitlab). Absent those headers the classification is
unchanged.

AUTH-2-3. `Error::http_status()` returns the status for `RateLimited` as it does
for the other HTTP variants, and `Error::url()` returns its URL
(`src/errors.rs:267`, `:279`).

AUTH-2-4. The `Display` string names rate limiting, the reset time when known,
and the token remedy, rather than reading as an auth failure.

AUTH-2-5. Tests: classification from synthetic response headers (403 with
remaining 0 -> `RateLimited`; 403 without the headers -> `Unauthorized`; 429
with headers -> `RateLimited`), plus `http_status()` / `url()` accessor
coverage.

## AUTH-3: docs

AUTH-3-1. The README / lib.rs rate-limit section gains the shared-IP mechanism:
the 60/hour budget is per source IP, so on a NAT'd network it is pooled across
everyone behind that IP and can be exhausted by other people entirely, which is
why a lightly-used application still sees 403s there.

AUTH-3-2. It also documents `auth_token_from_env()` as the one-line remedy, and
`Error::RateLimited` as the variant to match for backoff.

## Non-goals

- No automatic env fallback (AUTH-1-3).
- No credential-helper, keychain, netrc, or `gh auth token` shell-out lookups.
- No automatic wait-and-retry on `RateLimited`. Retrying a rate-limited request
only consumes more quota; backing off is the caller's policy decision, and
`UpdateCheckGuard` (`ref-check-interval.md`) is the throttle the crate offers.

## Related

- `ref-github-backend.md`, `ref-common-config.md` (auth token threading and the
host gate)
- `ref-errors.md` (variant inventory)
- `ref-check-interval.md` (reducing check frequency)
Loading
Loading