self_update 1.3.0

Self updates for standalone executables
Documentation
# GitHub backend (reference)

Status: implemented

## Scope

The GitHub release backend, `crate::backends::github`. It provides two
builder-driven entry points: `ReleaseList` (list a repo's releases) and `Update`
(download and install a release). Both target GitHub's REST releases API, with
support for a custom base URL (GitHub Enterprise) and optional token auth. This
document describes the existing behavior verified against `src/backends/github.rs`,
with shared HTTP helpers in `src/backends/common.rs` and `src/backends/mod.rs`.

## Behavior

### Builders

`ReleaseList::configure()` returns a `ReleaseListBuilder` with all fields `None`
and a default `RequestConfig`. Setters: `repo_owner` (required),
`repo_name` (required), `filter_target` (optional asset-target filter),
`api_base_url` (custom API base URL, `src/backends/github.rs:api_base_url`), `auth_token`,
plus the shared transport setters from
`request_config_setters!(request)`. The builder-setter
doc-aliases were dropped, so `filter_target`, `api_base_url`, and the others carry no
`#[doc(alias)]`. The string setters (`repo_owner`, `repo_name`, `api_base_url`,
`filter_target`, `auth_token`, and the `Update` builder's common setters) take
`impl Into<String>`. `build()` (`src/backends/github.rs:build`) calls `self.request.check()` first (surfacing a
deferred `request_header` conversion error as `Error::InvalidHeader`), then requires
`repo_owner` and `repo_name`, bailing `Error::MissingField { field }` if either is missing.
`ReleaseList` has `fetch` and, under the `async` feature, `fetch_async`
(`src/backends/github.rs:fetch_async`), both returning `Result<Releases>`.

`Update::configure()` returns an `UpdateBuilder` (`Default`-constructed).
Setters: `repo_owner` (required), `repo_name` (required),
`api_base_url` (custom API base URL, no doc-alias, `src/backends/github.rs:api_base_url`), plus the full shared
common surface from `impl_common_builder_setters!()`: target,
bin name, current version, auth token, timeout, retries, request headers,
progress, confirm, asset matcher, verify hook, checksum, injected HTTP clients,
etc. `build_update()` requires `repo_owner`/`repo_name` (else
`Error::MissingField { field }`) and calls `self.common.build()` (which validates and
requires `current_version`/`bin_name`/`bin_path_in_archive`).
`build()` returns the concrete `Update` (`src/backends/github.rs:build`), as does
`build_async()` (feature `async`, `src/backends/github.rs:build_async`). `Update` is `Send` and exposes
the update verbs as inherent methods (`update`, `update_extended`,
`get_latest_release`, `get_newer_releases`, `get_release_version`,
`is_update_available`), so `.build()?.update()?` needs no trait import.
`is_update_available()` returns `Result<Option<Release>>`: the newest
strictly-newer release, or `None` when up to date.

### Route shapes and base URL

The base URL is `self.custom_url`, defaulting to `https://api.github.com` when
unset. `ReleaseList` inlines this default; `Update` centralizes
it in `api_base()` so the sync and async paths cannot drift.
The `api_base_url(...)` setter takes the full API base including any path prefix, with no
trailing slash, e.g. `https://api.github.com` or `https://github.mycorp.com/api/v3`.

Routes built (all GET), each via a shared URL helper (`releases_url`, `latest_url`,
`tag_url`) so the sync and async paths cannot drift:
- List releases: `{base}/repos/{owner}/{name}/releases`. Used by `ReleaseList::fetch` /
  `fetch_async`, `get_newer_releases`, and `get_newer_releases_async`.
- Latest release: `{base}/repos/{owner}/{name}/releases/latest`. Used by
  `get_latest_release` / `get_latest_release_async`. This route returns a single release
  object (not an array).
- Fetch by tag: `{base}/repos/{owner}/{name}/releases/tags/{tag}` where `tag` is
  `urlencoding::encode(ver)`. Used by `get_release_version` and `get_release_version_async`.

The list routes go through `first_page_url` (`src/backends/mod.rs:first_page_url`), which appends
`?per_page=100` only when the base URL carries no `?` query.

### Auth

`auth_token` is optional and is carried as `Option<String>` on both `ReleaseList`
(its own field) and `Update` (via `CommonConfig::auth_token`). The free `api_headers()`
(`src/backends/github.rs:api_headers`) only sets the shared `self_update/<version>` User-Agent (github rejects
requests with no User-Agent); the `Authorization` header is no longer built there. Auth is
applied centrally by `RequestConfig::apply_auth` (`common.rs`) on both the listing and download
paths, rendering `Authorization: token {token}` (the GitHub legacy "token" scheme, not "Bearer")
and honoring a user `request_header(AUTHORIZATION, ..)` override.
A token that fails to parse as a header value is surfaced as `Error::InvalidAuthToken`.
The token is supplied explicitly via `auth_token(...)`, or read from the environment on request via
`auth_token_from_env()` (`GH_TOKEN`, then `GITHUB_TOKEN`; the crate-internal `AUTH_TOKEN_ENV_VARS`
list, `src/macros.rs:AUTH_TOKEN_ENV_VARS`) on both the `Update` and `ReleaseList` builders. This order was flipped
from the reverse (`GITHUB_TOKEN` then `GH_TOKEN`) to match what `gh help environment` actually
documents ("GH_TOKEN, GITHUB_TOKEN, in order of precedence"): inside GitHub Actions `GITHUB_TOKEN`
is auto-populated, so a deliberately-exported `GH_TOKEN` should win over it, not be silently
shadowed. An explicit `auth_token(...)` always wins over `auth_token_from_env()`, whatever the call
order -- the environment only fills an empty token slot (see `ref-common-config.md`). `has_auth_token()`
reports whether a token is currently set from either source, without exposing it. The environment
is never read implicitly: without calling `auth_token_from_env()` the backend does not look at
either variable. The `impl_update_config_accessors!` override arm wires github's `api_headers` into
the download path so the same User-Agent and token scheme are used there too.

An env-sourced token is bound to whatever host `api_base_url` is configured with (or the default
`api.github.com`), which the request-time host gate cannot flag on its own (the configured host
*is* `auth_base_host`). `build()` calls `env_token_host_decision` (`src/backends/common.rs:env_token_host_decision`
`ReleaseListBuilder`, `src/backends/common.rs:env_token_host_decision` `Update`) after resolving the host. It returns
`EnvTokenDecision::WarnedAndSent` (logging a warning, but still attaching the token) when the
current token is env-sourced and the resolved host is neither github's canonical
`api.github.com` (`CANONICAL_AUTH_HOST`, `src/backends/github.rs:CANONICAL_AUTH_HOST`) nor an acknowledged `allow_auth_host`
entry -- this is unchanged from before; the only addition is that a host passed to
`allow_auth_host(..)` now counts as acknowledged (`host_is_acknowledged`, `backends/common.rs`) and
yields `EnvTokenDecision::Sent` with no warning. An explicitly-set token is never warned about
either (`EnvTokenDecision::Sent`, since it is not env-sourced). Both builders' `Debug` impls redact
the token to `"<token>"` (`RequestConfig::fmt` / `CommonBuilderConfig::fmt`, `backends/common.rs`)
rather than printing it.

The token is host-gated: it is applied to the release-listing and binary-download requests, but
only to requests whose host matches the configured API host (or an `allow_auth_host` entry),
over https. A server-supplied asset `download_url` or pagination `Link` pointing at a different
host does not receive the token; `dangerously_allow_non_https_auth_forwarding()` relaxes the
https requirement for a host-matched request. A user-set `Authorization` via `request_header`
overrides the crate's token header.

### Rate limits

GitHub rate limits its REST API: 60 requests/hour/IP unauthenticated, 5000/hour with a token (no
scopes needed for a public repo). This crate does not track the limit; it is documented in the
crate-level "GitHub rate limits" section (`src/lib.rs`) and pointed at from the `auth_token` setter
rustdoc. An update check costs one API request (a `/latest` or `/tags/{tag}`
lookup, or one per page of a listing); the asset download is a CDN redirect and does not count
against the core limit. The 60/hour budget is counted per source IP, so behind a shared egress
IP (a NAT'd corporate network, a CI runner pool, a VPN exit) it is pooled across everyone on that IP
and can be spent by other people entirely.

Two GitHub rate-limit mechanisms are distinguished from an `Error::Unauthorized` (a genuine
credential failure) by `status_to_error_with_headers`, which classifies both as
`Error::RateLimited { status, reset_at, retry_after }`:
- The *primary* per-hour budget above: HTTP 403 with `x-ratelimit-remaining: 0`.
- GitHub's *secondary* rate limit (abuse-detection / burst throttling): HTTP 403 with a usable
  `Retry-After` header, which can be present while `x-ratelimit-remaining` is still nonzero. A
  narrower earlier reading required the spent-quota header on every 403, which misclassified this
  case as `Unauthorized`; see `ref-errors.md` / `auth-token-from-env.md` AUTH-2-2 for the broadened
  rule (429 is also always `RateLimited`, with or without headers).

A bare 403 with neither signal stays `Error::Unauthorized`. Mitigation is a token
(`auth_token` / `auth_token_from_env`) and checking less often (the
`check_interval::UpdateCheckGuard` throttle); the retry/backoff setters do not help -- and, as of
the review fix, `retries`/`retry_backoff` cannot even try: `retry` and `retry_async`
(`backends/mod.rs`) return `Error::RateLimited` on the first attempt, before consuming any of the
retry budget, since retrying a rate-limited request only consumes more quota.

### Pagination

The listing is described transport-free as a `PageRequest<Release>` via `releases_plan(base,
auth, stop_at)`: its parser maps the JSON array via `release_array_page` (calling
`ReleaseDto::into_release` per element) and follows GitHub's
`Link: rel="next"` (`next_link`) into the next `PageRequest`. The sync `run_paginated` and async
`run_paginated_async` drivers (`backends/mod.rs`) walk the chain, reusing the shared
`send`/`send_async` + `retry` machinery. Pagination is bounded by `MAX_RELEASE_PAGES = 100`; when
more pages are still advertised at the cap, a warning is logged and the walk stops with what was
collected. Per-page size defaults to 100 (`per_page=100`), but a base/next URL that already has
query params is used verbatim, so an existing `page`/`per_page` is not clobbered.
`get_newer_releases` passes `stop_at = Some(current_version)`, which filters per-item: each
release not strictly newer than the current version is dropped, but pagination continues through
all pages (an out-of-order listing cannot hide newer releases behind an old one).
`ReleaseList::fetch` passes `stop_at = None` and walks all pages unfiltered. The single-object
routes (`/latest`, `/tags/{tag}`) use `single_plan`, whose parser yields `next: None`.

### JSON to model

Each page is parsed by `release_array_page`, which calls `ReleaseDto::into_release` on
each element:
- `tag_name` (required, else `Error::MissingAssetField { field: "tag_name" }`).
- `created_at` (required) into `date`.
- `name` (optional, falls back to `tag_name`).
- `body` (optional `String`).
- `assets` (required array, else `Error::MissingAssetField { field: "assets" }`), each parsed
  via asset DTO parsing.
- `version` is derived from `tag_name` via `backends::common::strip_tag_prefix`. With no
  configured `tag_prefix` this strips a single leading `v` (the default). With a `tag_prefix` set
  (via the builder's `tag_prefix(..)`), the tag must start with that prefix, which is stripped
  (plus any leading `v` after it), so a monorepo tag `myapp-1.2.3` yields `1.2.3`; a tag lacking
  the prefix is skipped from the listing (a `tag_prefix_mismatch_error`, an `Error::SemVer`).
- `html_url` (optional) maps onto `Release::release_notes_url()`.

Asset DTO parsing requires `url` (download URL, else `Error::MissingAssetField { field: "url" }`)
and `name` (else `Error::MissingAssetField { field: "name" }`). The optional `digest`
(github's per-asset `algorithm:hex` content digest, published since mid-2025) maps onto
`ReleaseAsset::digest()` when present, `None` otherwise; under `checksums` the update
pipeline verifies the download against it by default (see `ref-signatures-and-checksums.md`).

### Ordering

Releases are returned in the order GitHub's API returns them, which is newest
first; no client-side re-sort is applied. `ReleaseList::fetch` returns them as-is
(after the optional target filter) (`src/backends/github.rs:fetch`). `Update`'s list paths
filter to strictly-newer-than-current via `bump_is_greater(current, release.version())`
(`src/version.rs:bump_is_greater`, inside `releases_plan`'s page parser) but preserve order.

### Errors

A completed non-2xx response is mapped to a structured variant by status,
identically for both clients: 404 -> `Error::NotFound`, 401/403 ->
`Error::Unauthorized`, any other non-2xx -> `Error::HttpStatus`
(`status_to_error`); a request that cannot complete
(connection/TLS/timeout) is `Error::Transport` (see
`fetch_all_releases_errors_on_http_error_status`). A 200 body
that is not a JSON array on a list route yields `Error::InvalidResponse { source }` (the
serde_json parse error is chained via `source()`); an empty listing is the clean
`Error::NoReleaseFound { target: None }`. Missing required JSON fields yield
`Error::MissingAssetField { field }`
(see JSON-to-model). Transport timeouts and retries are governed by `RequestConfig`
through the shared `send` / `send_async` helpers.

## Public surface

- `ReleaseList`, `ReleaseListBuilder` (public structs; builder is `#[must_use]`).
- `ReleaseList::configure`, `ReleaseList::fetch`, `ReleaseList::fetch_async` (feature `async`).
- `ReleaseListBuilder` setters: `repo_owner`, `repo_name`, `filter_target`, `api_base_url`,
  `auth_token`, the `request_config_setters!` transport setters, `build`.
- `Update`, `UpdateBuilder` (`Update` is `#[non_exhaustive]`; `UpdateBuilder` is
  `#[non_exhaustive]`-free but `#[must_use]`).
- `Update::configure`; `UpdateBuilder::new`, `repo_owner`, `repo_name`, `api_base_url`, the
  `impl_common_builder_setters!` surface, `build`, `build_async` (feature `async`). Both `build`
  and `build_async` return the concrete `Update`.
- `Update` is `Send`, exposes the inherent verbs (`update`, `update_extended`,
  `get_latest_release`, `get_newer_releases`, `get_release_version`, `is_update_available`), and
  implements `ReleaseUpdate` plus, under feature `async`, the public sealed `AsyncReleaseUpdate`
  (`get_latest_release_async`, `get_newer_releases_async`, `get_release_version_async`, plus the
  default `update_async` / `update_extended_async`).

The concrete `Update` carries `#[non_exhaustive]`. The `api_base_url`
setters carry no `#[doc(alias)]` (all builder-setter doc-aliases were dropped).
The setter was renamed `url` -> `api_base_url` (and earlier `with_url` / `instance_url` ->
`url`), but no alias remains.

## Invariants and regression checklist

- The fetch-by-tag route percent-encodes the caller-supplied tag: `tag_url`
  (`src/backends/github.rs:tag_url`) applies `urlencoding::encode(ver)`, shared by both
  `get_release_version` (`src/backends/github.rs:get_release_version`) and `get_release_version_async`
  (`src/backends/github.rs:get_release_version_async`). A tag with a URL-special `+` must appear as `%2B` on the wire, never raw.
- Releases are returned newest-first (GitHub API order), with no client-side
  re-sort in this backend.
- Route shapes are exactly `/repos/{owner}/{name}/releases`,
  `/repos/{owner}/{name}/releases/latest`, and
  `/repos/{owner}/{name}/releases/tags/{tag}` against the resolved base URL.
- Base URL defaults to `https://api.github.com`; the `Update` sync and async paths
  share `api_base()` so they cannot diverge.
- Auth header is `Authorization: token {token}` (legacy scheme), User-Agent is
  always `rust/self-update`.
- The token is only sent to the configured API host (or an `allow_auth_host` entry) over https;
  a cross-host asset `download_url` or pagination link does not receive it.
- List per-page size defaults to 100 and pagination follows `Link: rel="next"`,
  bounded at 100 pages.
- `version` strips a single leading `v` from `tag_name` by default; with a configured
  `tag_prefix`, only tags carrying that prefix are kept (the prefix, plus any inner `v`, is
  stripped), and non-matching tags are skipped.
- `auth_token_from_env()` reads `GH_TOKEN` before `GITHUB_TOKEN` (`AUTH_TOKEN_ENV_VARS`); an
  explicit `auth_token(..)` with a non-blank value always wins over it, whatever the call order (a
  blank explicit token is the documented exception: `auth_token("").auth_token_from_env()` still
  picks up the env token, but `auth_token_from_env().auth_token("")` discards it -- see
  `ref-common-config.md`). `has_auth_token()` reports whether either source set a token. An
  env-sourced token bound to a non-`api.github.com`
  host (from a custom `api_base_url`) logs a warning at `build()` and is still sent, unless that
  host was also passed to `allow_auth_host(..)`, in which case it is acknowledged and no warning
  fires; an explicitly-set token never warns either way.
- A 429 is always `Error::RateLimited`; a 403 is `RateLimited` when it carries a spent quota
  (`x-ratelimit-remaining: 0`) *or* a usable `Retry-After` (the secondary rate limit); a bare 403
  with neither stays `Unauthorized`. `retry`/`retry_async` never spend budget retrying a
  `RateLimited` response.

## Tests

In `src/backends/github.rs` (`#[cfg(test)] mod tests`), driven by a loopback TCP
stub (no external network):
- `get_release_version_percent_encodes_the_tag_in_the_url`:
  asserts `/releases/tags/v1.0.0%2Bbuild.5` on the wire and no raw `+`.
- `fetch_all_releases_follows_link_pagination` and the async
  `fetch_all_releases_async_follows_pagination`: two-page accumulation.
- `fetch_all_releases_errors_on_http_error_status`: a non-2xx is
  the structured status variant (`NotFound`/`Unauthorized`/`HttpStatus`).
- `fetch_all_releases_errors_when_body_is_not_an_array`:
  `Error::InvalidResponse` with a chained `source()`.
- `get_latest_release_sync_wraps_single_object_into_one_element_releases`
  and `..._reports_not_available_when_newest_equals_current`: the `/latest` single-object path.
- `get_newer_releases_sync_returns_releases_and_precheck` (`src/backends/github.rs:get_newer_releases_sync_returns_releases_and_precheck`):
  strictly-newer filtering and the returned `Releases` pre-check.
- `get_newer_releases_continues_past_non_newer_releases_and_fetches_page_two`
  (`src/backends/github.rs:get_newer_releases_continues_past_non_newer_releases_and_fetches_page_two`): per-item filtering keeps paginating.
- `release_list_fetch_async_returns_releases_and_into_vec_recovers_them`
  (`src/backends/github.rs:release_list_fetch_async_returns_releases_and_into_vec_recovers_them`): the async listing path.
- `api_headers_override_uses_github_user_agent_and_token_scheme`:
  User-Agent `rust/self-update` and `Authorization: token secret`.
- `release_list_applies_its_request_config`: `ReleaseList`
  transport setters (retries) flow through `fetch`.
- Transport/builder tests: timeout, retries, custom request header on the wire,
  injected reqwest/ureq/async clients, progress/verify/checksum/asset-matcher storage.
- `auth_token_env_vars_are_gh_token_then_github_token` (`src/backends/github.rs:auth_token_env_vars_are_gh_token_then_github_token`): pins
  `AUTH_TOKEN_ENV_VARS` to `["GH_TOKEN", "GITHUB_TOKEN"]` on both builders. The wire-level
  "`GH_TOKEN` beats `GITHUB_TOKEN` when both are set" check lives in the integration binary
  `tests/auth_token_env_github.rs`, not here.

## Related

- `release-tag-url-encoding.md` (tag percent-encoding at fetch-by-tag sites)
- `release-scan-pagination.md` (Link-header pagination, per-page sizing, page cap)
- `transport-control.md` (timeout, retries, custom headers, injected clients)
- `ref-release-model.md` (the `Release` / `ReleaseAsset` model and version
  normalization)
- `error-network-vs-http-semantics.md` (non-2xx -> structured status variant; transport failure -> `Error::Transport`)
- `choose-latest-release-sort.md` (ordering and newest-release selection)