# Installer scripts (`install.sh` / `install.ps1`)

**Status:** Approved (brainstorming, 2026-05-04)
**Author:** randomparity
**Target release:** post v0.2.0

## Problem

The README's install decision tree already lists Homebrew, `.deb`/`.rpm`,
manual tarball, and `cargo install` paths. The remaining gap is a
one-line install for users who fall outside every package manager —
most acutely **Windows users without `cargo`**, who today have to
download a zip, extract it, and put `bzr.exe` on PATH by hand.

We add the canonical `curl <url> | sh` (Unix) and `irm <url> | iex`
(Windows) flow that automates the existing "Pre-built binaries"
download path, with SHA-256 verification against the already-published
`SHA256SUMS` file.

## What already exists (do not redo)

The release workflow has matured significantly since this work was
proposed. The installer feature **builds on** the following, which
are already on `main`:

- `release.yml` generates `SHA256SUMS` covering every artifact
  (tarballs, zips, `.deb`, `.rpm`) — filename is exactly `SHA256SUMS`
  (no tag prefix).
- Per-target tarballs and zips include `man/man1/*.1` man pages,
  `LICENSE`, and `README.md` alongside the binary.
- Manpages are generated by the `xtask` workspace member
  (`cargo run -p xtask -- man`) — no need to introduce a new tool.
- `update-homebrew.yml` auto-bumps `randomparity/homebrew-tap` on
  every stable (non-prerelease) tag using a custom sed-based render
  of `homebrew/bzr.rb.template`.
- `.deb` (amd64, arm64, ppc64el) and `.rpm` (x86_64, aarch64, ppc64le,
  s390x) packages are produced and uploaded.
- README install section (lines 31–~190) already documents the full
  decision tree, including SHA256SUMS verification for manual
  downloads.
- SLSA build provenance attestation is already produced for every
  release artifact.

The installer feature does **not** touch any of this beyond reading
the `SHA256SUMS` file the workflow already publishes.

## Goals

1. One-line install on Linux, macOS (Apple Silicon), and Windows.
2. SHA-256 verification of the downloaded archive against the
   published `SHA256SUMS` file, so a CDN/MITM attacker can't swap
   binaries silently.
3. No surprise mutations: the installer never edits dotfiles,
   registry, or system PATH. It places the binary and tells the user
   how to make it discoverable.
4. Predictable pinning: a user can lock both the script *and* the
   binary version to a specific release tag.
5. Clear, actionable failure modes for unsupported platforms (Intel
   Mac, FreeBSD, 32-bit Windows, etc.).
6. Slot cleanly into the existing README install decision tree
   without disrupting the package-manager-first guidance.

## Non-goals (explicitly out of scope)

- Adding `x86_64-apple-darwin` to the release matrix. The installer
  surfaces the gap; the matrix change is a separate ticket.
- PATH mutation by default. No `--add-to-path` flag in v1.
- Signing (cosign / minisign / GPG) beyond the existing SLSA
  attestation.
- Manpage installation. The scripts drop the binary; users who want
  `man bzr` follow the existing "Manual pages" section. Adding
  manpage install would require root or a `MANPATH` mutation —
  same surprise-mutation principle as PATH.
- New downstream packaging channels: Scoop, AUR, additional `.deb`
  repos.
- Homebrew tap automation. Already implemented and works on stable
  tags.
- A `bzr self update` subcommand.
- Statically-linked Linux binaries (musl).
- Locale-sensitive output / i18n.

## Architecture

Two scripts at the repo root, one packaging tweak, plus narrowly
scoped doc and CI additions:

```
bzr/
├── install.sh                 # NEW — POSIX sh, Linux + macOS
├── install.ps1                # NEW — PowerShell 5.1+, Windows
├── .github/workflows/
│   ├── ci.yml                 # MOD — add lint + smoke jobs for installers
│   └── release.yml            # MOD — Windows zip layout fix; upload+template
│   │                          #       installer scripts; post-release smoke
├── tests/installer/
│   ├── smoke.sh               # NEW — local-server smoke test for install.sh
│   └── smoke.ps1              # NEW — local-server smoke test for install.ps1
├── README.md                  # MOD — add one-liners inside "Pre-built
│                              #       binaries"; tweak decision-tree bullet
├── RELEASING.md               # MOD — note new installer assets in
│                              #       "GitHub release binaries" section
└── CHANGELOG.md               # MOD — add Unreleased entry
```

There is **no new long-form `docs/installation.md`** — the README
install section already serves that role. We extend it rather than
duplicate it.

Both installers follow the same shape:

1. Detect OS + arch → map to one of the 7 release targets.
2. Resolve version (env override → baked-in default → GitHub API
   "latest").
3. Resolve install dir (env override → platform default).
4. Download archive + `SHA256SUMS` from `releases/download/<tag>/`.
5. Verify the archive's SHA-256 against the sums file; abort on
   mismatch.
6. Extract, move binary into place, set executable bit (Unix only).
7. Run `bzr --version` as a smoke check.
8. Print a PATH hint if the install dir isn't on PATH.

Unsupported platforms abort at step 1 with an error pointing at
`cargo install bzr --locked` and the README's package-manager
options.

### Component boundaries

- **`install.sh`** depends on: `curl` *or* `wget`, `tar`, `sha256sum`
  *or* `shasum -a 256`, `mktemp`, `uname`, POSIX `sh`. No bash-isms.
- **`install.ps1`** depends on: PowerShell 5.1+ built-ins only
  (`Invoke-WebRequest`, `Get-FileHash`, `Expand-Archive`,
  `[Net.ServicePointManager]`). No external tools.
- **`release.yml`** is the only place that knows the version-pin
  templating contract. Both scripts read what the workflow
  publishes; the workflow rewrites a single comment-marked line in
  each script before uploading them as release assets.

## Precursor fix: symmetric Windows zip layout

`release.yml` currently packages Windows artifacts with
`Compress-Archive -Path "$STAGING\*"`. The trailing `\*` archives
the *contents* of the staging directory, so the resulting zip has
`bzr.exe`, `LICENSE`, `README.md`, and `man/man1/` at the **zip
root**, with no wrapper directory. The Unix tarball wraps everything
in `bzr-<tag>-<target>/`.

Drop the `\*`:

```yaml
Compress-Archive -Path $STAGING -DestinationPath "$STAGING.zip"
```

Now both archive types extract to
`bzr-<tag>-<target>/{bzr[.exe], LICENSE, README.md, man/man1/}`.
The installers share the same "look in the wrapper directory"
extraction logic. Pre-existing prerelease zips (`v0.2.0-rc[1-4]`)
keep their old layout but won't be touched by the installer.

This is the only `release.yml` change needed in the build matrix.
The post-build steps (manpages, deb, rpm, attest, upload) are
unchanged.

## `install.sh` — bash/POSIX installer

**Header:** `#!/bin/sh` with `set -eu`. Pipe-safe (`curl ... | sh`).

### Target detection

| `uname -s` | `uname -m`              | Release target                    |
| ---------- | ----------------------- | --------------------------------- |
| Linux      | `x86_64`                | `x86_64-unknown-linux-gnu`        |
| Linux      | `aarch64` or `arm64`    | `aarch64-unknown-linux-gnu`       |
| Linux      | `ppc64le`               | `powerpc64le-unknown-linux-gnu`   |
| Linux      | `s390x`                 | `s390x-unknown-linux-gnu`         |
| Darwin     | `arm64`                 | `aarch64-apple-darwin`            |
| Darwin     | `x86_64`                | unsupported → `cargo` hint, exit 2 |
| anything else            | unsupported → `cargo` hint, exit 2 |

For Linux targets, the unsupported-fallthrough message also points
at `.deb`/`.rpm` packages and Homebrew, since those exist.

### Dependencies

Probed at startup; abort with a precise error naming the missing
tool.

- HTTP: `curl` (preferred) or `wget` (fallback).
- Archive: `tar`.
- Hashing: `sha256sum` (Linux) or `shasum -a 256` (macOS, BSDs).
- Misc: `mktemp`, `uname`.

### Linux `libdbus-1` runtime hint

The default-feature binary dynamically links `libdbus-1.so.3`. After
copying the binary into place, run `bzr --version`; if it exits
non-zero with a `libdbus` error in stderr, print:

```
bzr is installed but cannot start because libdbus-1 is missing.
Install it with one of:
  Debian/Ubuntu:  sudo apt-get install libdbus-1-3
  Fedora/RHEL:    sudo dnf install dbus-libs
  Alpine:         apk add dbus-libs
Or rebuild without the keyring feature:
  cargo install bzr --locked --no-default-features
Or use the .deb/.rpm package, which declares libdbus as a runtime dep.
```

The install is still considered successful (exit 0) since the binary
is on disk.

### Environment variable overrides

- `BZR_VERSION` — release tag like `v0.2.0`.
  - Default for `main`-branch copy: query GitHub API for the latest
    *stable* release tag (skipping prereleases via the
    `releases/latest` redirect, which already filters them).
  - Default for release-asset copy: baked-in tag for that release
    (see "Release workflow → version pin" below).
- `BZR_INSTALL_DIR` — install directory.
  - Default: `$HOME/.local/bin` (created if missing).

### Flow

1. Probe deps → detect target → resolve version → resolve dir.
2. `mktemp -d` workdir; `trap 'rm -rf "$workdir"' EXIT`.
3. Download `bzr-<tag>-<target>.tar.gz` and `SHA256SUMS` into
   workdir.
4. Verify: `grep "  bzr-<tag>-<target>.tar.gz$" SHA256SUMS |
   sha256sum -c -` (or `shasum -a 256 -c -` on macOS). The grep
   filters the multi-artifact `SHA256SUMS` down to the one line for
   our archive.
5. `tar xzf <archive>`; copy `bzr-<tag>-<target>/bzr` to
   `$BZR_INSTALL_DIR/bzr`; `chmod 0755`.
6. Run `"$BZR_INSTALL_DIR/bzr" --version` (with libdbus hint on
   Linux failure).
7. PATH check: if `command -v bzr` doesn't resolve to the installed
   path, print:
   ```
   $BZR_INSTALL_DIR is not on your PATH. Add it to your shell rc:
     export PATH="$BZR_INSTALL_DIR:$PATH"
   ```

### Exit codes

| Code | Meaning                       |
| ---: | ----------------------------- |
|    0 | Success                       |
|    2 | Unsupported platform          |
|    3 | Missing required dependency   |
|    4 | Download failure (HTTP error) |
|    5 | Checksum mismatch             |
|    6 | Extraction failure            |

## `install.ps1` — PowerShell installer

**Header:** `Set-StrictMode -Version Latest`,
`$ErrorActionPreference = 'Stop'`. Pipe-safe (`irm ... | iex`).

### TLS prelude

PS 5.1 defaults to SSL3/TLS1.0 and silently fails against GitHub.
First executable line:

```powershell
[Net.ServicePointManager]::SecurityProtocol = `
  [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
```

### Target detection

| `PROCESSOR_ARCHITECTURE` (with `ARCHITEW6432` fallback) | Release target              |
| ------------------------------------------------------- | --------------------------- |
| `AMD64`                                                 | `x86_64-pc-windows-msvc`    |
| `ARM64`                                                 | `aarch64-pc-windows-msvc`   |
| anything else                                           | unsupported → `cargo` hint  |

The `ARCHITEW6432` check covers 32-bit PowerShell running on a 64-bit
Windows host (including ARM64 + x86 emulation).

### Dependencies

Pure built-ins: `Invoke-WebRequest -UseBasicParsing`,
`Get-FileHash -Algorithm SHA256`, `Expand-Archive`. No external tools.

### Environment variable overrides

- `$env:BZR_VERSION` — release tag.
- `$env:BZR_INSTALL_DIR` — defaults to
  `Join-Path $env:LOCALAPPDATA 'Programs\bzr'`.

### Flow

1. TLS prelude → detect target → resolve version → resolve dir
   (create if missing).
2. Create temp dir; wrap remaining steps in `try`/`finally` to
   remove it.
3. `Invoke-WebRequest` to download
   `bzr-<tag>-<target>.zip` and `SHA256SUMS` into temp dir.
4. Parse `SHA256SUMS` (lines are `<hex>  <filename>`), find the
   line for our archive, compare against
   `(Get-FileHash -Algorithm SHA256 $archive).Hash`. Case-insensitive.
5. `Expand-Archive` the zip into temp dir; copy
   `bzr-<tag>-<target>\bzr.exe` to `$BzrInstallDir\bzr.exe`
   (overwrite if present). This relies on the zip layout fix above.
6. Run `& "$BzrInstallDir\bzr.exe" --version` as smoke test.
7. PATH check via `[Environment]::GetEnvironmentVariable('Path',
   'User')` and the process PATH; if neither contains the install
   dir, print:
   ```
   $BzrInstallDir is not on your PATH. Add it (current user, persistent):
     [Environment]::SetEnvironmentVariable('Path',
       [Environment]::GetEnvironmentVariable('Path','User') + ';$BzrInstallDir',
       'User')
   ```

### Exit codes

Mirror the bash installer except code 3 (missing dep) is unused —
all dependencies are PowerShell built-ins.

### Execution policy

`irm ... | iex` works regardless of execution policy because `iex`
evaluates a string; the script never touches disk in the canonical
invocation. Saving the `.ps1` and running it directly *would* hit
policy — covered in the README troubleshooting note.

## Release workflow changes

### 1. Symmetric zip layout (build matrix)

Drop the `\*` from the Windows packaging step (see "Precursor fix"
above). One-character change.

### 2. Stage installer scripts with version baked in (release job)

Both scripts have a single, comment-marked line that the workflow
rewrites:

```sh
# RELEASE_VERSION_PIN — release.yml rewrites the next line at release time
BZR_VERSION="${BZR_VERSION:-}"
```

```powershell
# RELEASE_VERSION_PIN -- release.yml rewrites the next line at release time
$BzrVersion = $env:BZR_VERSION
```

Add a step in the `release` job **before `Generate SHA256SUMS`**
(ordering matters — see section 3 below):

```yaml
- name: Stage installer scripts (with version baked in)
  working-directory: artifacts
  run: |
    set -euo pipefail
    sed "s|^BZR_VERSION=\"\${BZR_VERSION:-}\"|BZR_VERSION=\"\${BZR_VERSION:-${GITHUB_REF_NAME}}\"|" \
      ../install.sh > install.sh
    sed "s|^\$BzrVersion = \$env:BZR_VERSION|\$BzrVersion = if (\$env:BZR_VERSION) { \$env:BZR_VERSION } else { '${GITHUB_REF_NAME}' }|" \
      ../install.ps1 > install.ps1
    chmod +x install.sh
    # Verify the rewrites hit. If the marker line moves or is renamed,
    # fail the release rather than ship an asset that ignores its
    # own version.
    grep -q "BZR_VERSION:-${GITHUB_REF_NAME}" install.sh
    grep -q "BzrVersion.*${GITHUB_REF_NAME}" install.ps1
```

The verification grep is in the same step (single failure surface).
The existing `gh release create … artifacts/*` step picks the new
files up automatically.

The `installer-smoke` job (below) also validates the templating
end-to-end against the real release assets.

### 3. SHA256SUMS — reorder, no logic change

The existing `Generate SHA256SUMS` step hashes every file in
`artifacts/` matching the find filter. Today that's the tarballs,
zips, `.deb`, and `.rpm`. We need it to also cover the new
`install.sh` and `install.ps1` so:

- A paranoid user can `curl … install.sh | sha256sum` and verify
  against the published sums before piping to `sh`.
- The script — which is itself the trust anchor for archive
  verification — is hashed by the same publish-time pipeline that
  hashes everything it later validates.

**Required change:** move the existing `Generate SHA256SUMS` step
**after** the new "Stage installer scripts" step from section 2.
The step itself does not need editing — its `find . -maxdepth 1
-type f ! -name 'SHA256SUMS' -printf '%f\n'` glob will pick up
`install.sh` and `install.ps1` automatically once they're in
`artifacts/`.

The installer scripts read the existing `SHA256SUMS` file by name;
they do not depend on a tag-prefixed filename.

### 4. `installer-smoke` job (post-release)

New job in `release.yml` depending on `release`. For each of
`ubuntu-latest` and `windows-latest`:

1. Download `install.sh` / `install.ps1` from the just-created
   release.
2. Run it against the real GitHub Releases CDN.
3. Assert `bzr --version` returns the freshly-released tag.

Runs on **all** tags including prereleases — that's exactly when
templating regressions matter most.

The job is non-blocking for the rest of the release: a smoke
failure surfaces in CI without rolling back the release itself
(uploads have already happened by the time it runs).

## Documentation changes

### `README.md` — add the one-liners inside "Pre-built binaries"

The existing `Pre-built binaries` subsection (line 105+) currently
shows only the manual `tar xzf … && sudo install` flow. Add the
installer scripts at the **top** of that subsection as the
recommended approach, with the manual flow kept below as the
"prefer to do it yourself" option:

```markdown
### Pre-built binaries

For a one-line install on Linux, macOS arm64, or Windows:

**Linux / macOS (Apple Silicon):**
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/randomparity/bzr/main/install.sh | sh
\`\`\`

**Windows (x86_64 / ARM64):**
\`\`\`powershell
irm https://raw.githubusercontent.com/randomparity/bzr/main/install.ps1 | iex
\`\`\`

The installer detects your platform, verifies the SHA-256 checksum
against the published `SHA256SUMS`, and drops the binary in
`~/.local/bin` (Unix) or `%LOCALAPPDATA%\Programs\bzr` (Windows).
It never modifies PATH or system files. The script honors
`BZR_VERSION` and `BZR_INSTALL_DIR` env vars.

To pin the script to a specific release (e.g. for reproducible
builds):

\`\`\`bash
curl -fsSL https://github.com/randomparity/bzr/releases/download/vX.Y.Z/install.sh | sh
\`\`\`

#### Manual download

Or download the tarball or zip directly from
[GitHub Releases](https://github.com/randomparity/bzr/releases/latest):

(... existing tar xzf flow ...)
```

Update the `Choosing an install method` decision tree (line 42) to
reflect the new path:

```markdown
- **Windows, or any Linux distro without `apt`/`dnf`** —
  use the [one-line installer](#pre-built-binaries) or download a
  pre-built tarball or zip.
```

The "manpages on these paths need a manual install" caveat stays
— installer scripts do not install manpages.

### `RELEASING.md` — note the new release artifacts

In the "GitHub release binaries" section's bullet list (line 62+),
add:

```markdown
- Stages `install.sh` and `install.ps1` as release assets, with
  the release tag baked into the `BZR_VERSION` default
- Runs an `installer-smoke` job against the real release CDN
  (Ubuntu and Windows runners) that verifies `bzr --version`
  matches the released tag
```

No change needed to the Homebrew or crates.io sections.

### `CHANGELOG.md` — add an Unreleased entry

Per the project convention (CLAUDE.md: "CHANGELOG.md entries are
written **as the work lands**"), add under `[Unreleased] / Added`:

```markdown
- Installer scripts (`install.sh`, `install.ps1`) for one-line
  installation from GitHub Releases, with SHA-256 verification
  against the published `SHA256SUMS` file. Hosted at
  `raw.githubusercontent.com/.../main/install.sh` and as release
  assets pinned to each tag.
```

### `docs/bzr-cli.md` — no change

Installer adds no CLI surface.

## Testing

### CI lint (every PR, path-filtered)

- `shellcheck -s sh install.sh tests/installer/smoke.sh`
- `shfmt -d -ln posix -i 2 install.sh tests/installer/smoke.sh`
- `Invoke-ScriptAnalyzer install.ps1 tests/installer/smoke.ps1
  -Severity Warning` via the
  `microsoft/psscriptanalyzer-action` (pinned to SHA per repo
  convention; warnings treated as failures).

Add as a path-filtered job in `ci.yml` so unrelated PRs don't pay
the cost.

### CI smoke tests (every PR, 3-platform matrix)

New `installer-test` job in `ci.yml` running on `ubuntu-latest`,
`macos-14`, and `windows-latest`. Each platform runs its installer
against a local HTTP server (Python `http.server`) serving fixture
archives + a fixture `SHA256SUMS`. Cases:

- Success path
- Checksum mismatch (corrupt one byte) → exit 5
- Unsupported target (stub `uname` on PATH) → exit 2
- Missing dep (bash only — temporarily move `tar` aside) → exit 3

Tests never touch the real GitHub Releases API and never modify
the host beyond a tempdir.

### Release smoke test (post-tag)

The `installer-smoke` job described in the release workflow
section above. Runs on every tag including prereleases.

### One-time manual validation (pre-merge)

Before merging this PR, manually run each script from a clean host
on a real prerelease tag (`v0.2.1-rc1` or similar). Document this
as a one-time bootstrap in the PR description.

## Security considerations

- HTTPS to GitHub releases provides transport security. SHA-256
  sums protect against post-build tampering of the archive (a CDN
  swap or GH Actions cache poisoning would still need the
  `SHA256SUMS` file to also be wrong, which the
  `installer-smoke` job would catch).
- The installer never runs downloaded code other than the
  binary itself, and only after `--version` succeeds.
- The installer never writes to `$HOME` outside `$BZR_INSTALL_DIR`
  (no rc-file edits) and never writes to system locations.
- The `main`-branch script is mutable; users who want frozen
  install logic can pin via
  `releases/download/<tag>/install.sh`.
- This feature does not change the existing SLSA build provenance
  attestation. Users wanting cryptographic verification beyond
  SHA-256 can still verify provenance via `gh attestation verify`.

## Risks and open questions

- **GitHub API rate limit on `latest`:** unauthenticated requests
  are 60/hr per IP. Shared-NAT environments could hit this.
  Mitigation: clear error pointing at `BZR_VERSION` override.
  Note: the release-asset copy of the script never queries the API
  (version is baked in), so users can pin to dodge this.
- **`libdbus-1` runtime gap on minimal Linux:** documented and
  surfaced at install time. Users who hit this can fall back to
  `.deb`/`.rpm` (which declares the runtime dep) or
  `--no-default-features` source builds. Not blocking.
- **Windows ARM64 untested in CI:** GitHub does not yet offer ARM64
  Windows runners. We trust the build artifact and rely on user
  reports.
- **Pre-existing `v0.2.0-rc[1-4]` zips have the old asymmetric
  layout.** The installer will not work against those tags
  (extraction would look in the wrong place). Acceptable: those
  are prereleases, the installer is post-v0.2.0, and `BZR_VERSION`
  defaults to "latest" stable.

## Implementation order

1. Apply the Windows zip layout fix in `release.yml` (one-character
   change; can ship on its own and is a worthwhile cleanup
   regardless).
2. Add `install.sh` and `install.ps1` with full target-detection,
   download, verify, install, and PATH-hint logic. Local
   smoke-test scaffolding (`tests/installer/`).
3. Wire CI lint + smoke jobs in `ci.yml`.
4. Add the version-pin staging step and `installer-smoke` job to
   `release.yml`.
5. Update `README.md` ("Pre-built binaries" subsection +
   decision-tree bullet), `RELEASING.md` (new bullets),
   `CHANGELOG.md` (Unreleased entry).
6. Cut a prerelease tag (`v0.2.1-rc1` or similar), run the
   one-time manual validation, then merge.
