# bzr v0.2.0 Release — Design

**Date:** 2026-04-28
**Branch:** `release/v0.2.0-prep`
**Previous release:** [v0.1.2](https://github.com/randomparity/bzr/releases/tag/v0.1.2) (2026-04-27)
**Commits since v0.1.2:** 62

## Background

62 commits have landed on `main` since v0.1.2. The headline change is **TLS
TOFU certificate pinning** (PR #99): four new CLI flags
(`--tls-ca-cert`, `--tls-pin-sha256`, `--tls-pin-now`, `--tls-pin-clear`),
three new config fields (`tls_ca_cert`, `tls_pin_sha256`,
`tls_pin_issuer`), two new error variants (`PinMismatch`,
`IssuerChanged`), and integration through the connection setup path.

Bundled alongside: HTTP error-chain fixes and `--from-url` shell-escape
handling (PR #98), and a SonarCloud-driven internal cleanup that splits
`commands/bug.rs` and `xmlrpc/mod.rs` into focused submodules and pushes
per-file coverage to ≥85% (PR #100). All three PRs are merged; main is
clean.

This is a minor release — TLS pinning is a real new feature, not a fix.
Cut as `v0.2.0-rc1` for a 3-day soak with smoke-matrix validation, then
promote to `v0.2.0`.

## Decisions

| Topic | Decision |
|---|---|
| Target version | `v0.2.0`, preceded by `v0.2.0-rc1` |
| Release notes source | `CHANGELOG.md` (Keep-a-Changelog format, already in repo) |
| Notes delivery | `release.yml` extracts the tag's section from `CHANGELOG.md` and passes it via `gh release create --notes-file` |
| Pre-tag validation | CI gates + `make functional-test-all` + manual smoke matrix |
| Soak window | 3 calendar days from rc1 to final |
| Re-spin trigger | Any correctness defect, TLS regression, or new RUSTSEC against an unpatchable dep → cut rc(N+1), restart 3-day clock |
| Smoke matrix | `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, `powerpc64le-unknown-linux-gnu` |
| TLS tamper test method | Edit `~/.config/bzr/config.toml` directly to mangle `tls_pin_sha256`, expect `PinMismatch`, revert |
| Branch strategy | No release branch. rc1 tag points at a release-prep PR's merge commit on `main`; final tag points at a small promotion PR's merge commit on `main`. |
| crates.io for RC | Skipped via prerelease gate in `publish-crates.yml` |
| Post-release version bump on `main` | None. `Cargo.toml` stays at `0.2.0` until the next release-prep PR. |

## Non-goals

- No new in-repo `CHANGELOG` format. Existing Keep-a-Changelog file is the
  source of truth.
- No GPG-signed tags. Existing release flow doesn't sign; not in scope.
- No release branch (`release/v0.2.0`) for soak isolation. Solo project,
  main is quiet, branch would be ceremony.
- No support-matrix changes, MSRV bump, or doc-site work.
- No yank or republish of older versions.
- No automated smoke-matrix runner. Smoke is hands-on.

## Release artifacts

Produced by `release.yml` on tag push (existing behavior, unchanged):

- 7 platform binaries — `x86_64`, `aarch64`, `powerpc64le`, `s390x` Linux;
  `aarch64` macOS; `x86_64`, `aarch64` Windows.
- Each packaged as `bzr-<tag>-<target>.{tar.gz,zip}` containing `bzr`,
  `LICENSE`, `README.md`.
- One GitHub Release per tag. rc1 is marked `--prerelease`; final is not.
  Body comes from the matching `CHANGELOG.md` section.
- crates.io publish: only on stable tags. rc1 is gated off.

## Release-prep PR contents

Branch: `release/v0.2.0-prep`. Single PR; mechanical changes only, no new
code paths.

### 1. Version bump

`Cargo.toml`:

```toml
[package]
name = "bzr"
version = "0.2.0"   # was 0.1.2
```

`Cargo.lock` regenerated by running `cargo build --locked` once after
the `Cargo.toml` change. Only the `bzr` entry should diff.

### 2. CHANGELOG.md entry for `[0.2.0-rc1]`

Insert above the existing `[0.1.2]` heading. Promoted to `[0.2.0]` in the
later promotion PR.

```markdown
## [0.2.0-rc1] - 2026-04-28

### Added

- TLS certificate pinning with trust-on-first-use (TOFU) prompt flow.
  New CLI flags on `bzr config set-server`: `--tls-ca-cert <path>` to pin
  a CA certificate, `--tls-pin-sha256 <hex>` to pin a leaf SPKI
  fingerprint, `--tls-pin-now` to probe the server and prompt before
  storing the observed pin, and `--tls-pin-clear` to remove an existing
  pin.
- Per-server config fields `tls_ca_cert`, `tls_pin_sha256`,
  `tls_pin_issuer` persisted in `~/.config/bzr/config.toml`.
- New error variants `PinMismatch` and `IssuerChanged` with distinct
  exit codes and actionable hints (`--tls-pin-now`, `--tls-ca-cert`).
- `bzr config show` displays configured CA cert path and pin fingerprint
  for each server.

### Changed

- Internal: migrated PEM parsing from `rustls-pemfile` to
  `rustls-pki-types` `PemObject` API. No user-visible change.
- Internal: `commands/bug.rs` split into per-action submodules; `xmlrpc/mod.rs`
  split into `call`, `fault`, `parsing`. No public API change.
- Internal: test modules moved to sibling `_tests.rs` files for
  SonarCloud copy-paste-detection exclusion.

### Fixed

- HTTP error messages now walk the reqwest error source chain, so TLS
  diagnostics surface even when wrapped in transport errors.
- `bzr bug search --from-url` strips shell-escaped backslashes from URL
  arguments pasted from terminals that quote them.
```

The promotion PR later renames `[0.2.0-rc1] - 2026-04-28` to
`[0.2.0] - <promotion-date>`. No body changes unless the rc cycle
introduces fixes (in which case those are appended in **Fixed**).

### 3. README.md TLS pinning section

New subsection under the existing security/configuration content. Not a
rewrite. Approximate outline:

- One paragraph: what TLS pinning is, why bzr supports it (defense
  against compromised CA chains for self-hosted Bugzilla on the open
  internet).
- The four flags with one-line descriptions.
- Quickstart: `bzr config set-server <name> <url> --tls-pin-now` →
  prompt → confirm → pin stored.
- Storage note: pin lives in `~/.config/bzr/config.toml`, not the
  keyring; pinning is per-server.
- Pointer: "see `docs/bzr-cli.md` for full reference."

### 4. `publish-crates.yml` prerelease gate

Add a job-level guard so the entire job is skipped on tags containing
`-`. Current job header:

```yaml
jobs:
  publish:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6
```

Becomes:

```yaml
jobs:
  publish:
    name: Publish to crates.io
    if: ${{ !contains(github.ref_name, '-') }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6
```

A pushed `v0.2.0-rc1` tag still triggers the workflow but the job
short-circuits — no checkout, no publish, status reported as skipped.

### 5. `release.yml` — CHANGELOG-driven release notes

Replace `--generate-notes` with an extraction step. Current final step in
the `release` job:

```yaml
      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          PRERELEASE=""
          if [[ "${{ github.ref_name }}" == *-* ]]; then
            PRERELEASE="--prerelease"
          fi
          gh release create "${{ github.ref_name }}" \
            --title "bzr ${{ github.ref_name }}" \
            --generate-notes \
            $PRERELEASE \
            artifacts/*
```

Becomes:

```yaml
      - name: Extract release notes from CHANGELOG.md
        id: notes
        shell: bash
        run: |
          set -euo pipefail
          TAG="${{ github.ref_name }}"
          VERSION="${TAG#v}"
          NOTES_FILE="$(mktemp)"
          # Extract lines between "## [VERSION]" and the next "## [" heading.
          awk -v v="$VERSION" '
            $0 ~ "^## \\[" v "\\]" { capture = 1; next }
            capture && /^## \[/      { exit }
            capture                  { print }
          ' CHANGELOG.md > "$NOTES_FILE"
          if ! [ -s "$NOTES_FILE" ]; then
            echo "::error::No CHANGELOG.md entry found for [$VERSION]"
            exit 1
          fi
          echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          PRERELEASE=""
          if [[ "${{ github.ref_name }}" == *-* ]]; then
            PRERELEASE="--prerelease"
          fi
          gh release create "${{ github.ref_name }}" \
            --title "bzr ${{ github.ref_name }}" \
            --notes-file "${{ steps.notes.outputs.notes_file }}" \
            $PRERELEASE \
            artifacts/*
```

Failure mode: tag is pushed without a matching CHANGELOG entry → the
extraction step fails, the release is not created, the build artifacts
are left as workflow artifacts. This is desired; missing notes is a real
bug, not something to paper over.

## Pre-tag validation

Run from a clean `main` after the prep PR has merged. All steps must
succeed before pushing the tag.

| # | Step | Command |
|---|---|---|
| 1 | Sync local main | `git checkout main && git pull --ff-only origin main` |
| 2 | Format check | `cargo fmt --check` |
| 3 | Lint | `cargo clippy --all-targets --all-features -- -D warnings` |
| 4 | Test | `cargo test --locked` |
| 5 | Functional tests | `make functional-test-all` |
| 6 | Publish dry-run | `cargo publish --dry-run --locked` |

If any step fails: fix on a branch → PR → merge → restart from step 1.
Do not tag on a tree that hasn't fully validated locally.

## Tag flow — rc1

```bash
git tag -a v0.2.0-rc1 -m "bzr v0.2.0-rc1"
git push origin v0.2.0-rc1
```

CI side effects:

- `release.yml`: 7 builds in parallel, then GitHub Release `v0.2.0-rc1`
  marked prerelease, body sourced from `CHANGELOG.md` `[0.2.0-rc1]`
  section, all 7 archives attached.
- `publish-crates.yml`: `if:` gate evaluates false (tag contains `-`),
  job skipped, no crates.io publish.

If a target build fails, `release.yml`'s `release` job won't run because
of `needs: build`. Tags are immutable on the remote, so there is no
"retag rc1." Instead: fix the build → merge to main → cut `v0.2.0-rc2`.

## Soak window + smoke matrix

**Window:** 3 calendar days from rc1 tag to final tag, assuming clean
results.

**Matrix:** the three target platforms below, exercised the same way on
each. The exotic Linux targets (`s390x`, `aarch64-unknown-linux-gnu`,
`x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`) are validated by the
build succeeding plus `cargo test` on `x86_64-unknown-linux-gnu`. No
platform-specific code in the TLS path that would fail only on those
targets.

| # | Platform | Source |
|---|---|---|
| 1 | `x86_64-unknown-linux-gnu` | downloaded artifact tarball |
| 2 | `aarch64-apple-darwin` | downloaded artifact tarball |
| 3 | `powerpc64le-unknown-linux-gnu` | downloaded artifact tarball |

**Per-platform script:**

```bash
# Install
tar xzf bzr-v0.2.0-rc1-<target>.tar.gz
cd bzr-v0.2.0-rc1-<target>
./bzr --version          # expect: 0.2.0-rc1

# Configure auth against existing test server
./bzr config set-server smoke <url> --api-key <key>
./bzr -s smoke whoami    # expect: success, returns logged-in user

# Read path
./bzr -s smoke bug get <known-bug-id>
./bzr -s smoke bug list --product <known-product> --limit 5

# TLS pinning — TOFU happy path against a real public Bugzilla
./bzr config set-server pinned <real-https-bugzilla> --tls-pin-now
# expect: probe succeeds, prompts for confirmation, pin stored
./bzr -s pinned bug get <id>
# expect: succeeds, pin honored on second connection

# TLS pinning — tamper test (fail-closed)
# Edit ~/.config/bzr/config.toml: change one byte of the pinned server's tls_pin_sha256
./bzr -s pinned bug get <id>
# expect: PinMismatch error, non-zero exit
# Revert the config edit

# TLS pinning — clear
./bzr config set-server pinned --tls-pin-clear
./bzr config show pinned
# expect: tls_pin_sha256 and tls_pin_issuer absent
```

**Recording results:** post a comment on the release-prep PR with
per-platform pass/fail. Keep it lightweight — three lines is enough.
Not part of the GitHub Release body (which is CHANGELOG-derived).

## Promotion to v0.2.0

After the soak window with no defects:

1. **Promotion PR.** Branch off main: `release/v0.2.0-promote`. Single
   change: in `CHANGELOG.md`, rename
   `## [0.2.0-rc1] - 2026-04-28` → `## [0.2.0] - <today>`. PR, merge.
2. **Re-run pre-tag validation** (the table above). Don't skip on the
   grounds that rc1 already passed; the promotion PR's merge commit is
   on top.
3. **Tag and push:**

   ```bash
   git tag -a v0.2.0 -m "bzr v0.2.0"
   git push origin v0.2.0
   ```

4. **CI side effects:** `release.yml` builds 7 targets, creates a stable
   (non-prerelease) GitHub Release with the `[0.2.0]` CHANGELOG
   section as body. `publish-crates.yml` runs (no `-` in tag), passes
   version-match check (Cargo.toml = 0.2.0), publishes to crates.io.
5. **Repeat smoke matrix** on the final artifacts. Identical script
   to rc1; small extra effort, catches the rare "build was reproducible
   between rc and final" failure.

## Post-release verification

- `cargo search bzr` shows `bzr = "0.2.0"` (may take several minutes).
- `cargo install bzr --locked` from a fresh shell succeeds; `bzr --version`
  reports `0.2.0`.
- The GitHub Release page renders the CHANGELOG section without escaping
  artifacts.
- No `Cargo.toml` "dev version" bump on main. Project history shows main
  stays at the released version until the next release-prep PR; keep that
  pattern.

## Defect-driven re-spin (rc(N+1))

Triggered by:

- Smoke matrix failure on any of the 3 platforms.
- TLS pinning correctness regression discovered post-rc.
- New RUSTSEC advisory against a dependency that can't be patched
  in-place.
- Any user-facing bug in the in-scope commit range.

Procedure:

1. Fix on a topic branch → PR → merge to main.
2. Update `CHANGELOG.md`: rename heading `[0.2.0-rc1] - <date>` →
   `[0.2.0-rc2] - <new date>`. Append the fix to the appropriate section
   (`### Fixed` for bugs, `### Changed` for behavior tweaks). Do not
   rewrite or remove the rc1 content — rc2 is rc1 + fix.
3. Tag `v0.2.0-rc2`, push, restart soak window at day 0.
4. Repeat for rc3, rc4, … as needed. No length cap; promote only when
   clean.

## Risks and open questions

- **Real Bugzilla target for TOFU smoke step.** The smoke matrix needs a
  stable HTTPS Bugzilla to probe. Default to whichever public Bugzilla
  the operator already trusts; absent that, `bugzilla.mozilla.org` or
  `bugzilla.redhat.com` are acceptable. No coordination with those
  projects is required — we're a read-only client probing TLS.
- **CHANGELOG extraction edge case.** The `awk` extractor in
  `release.yml` requires the `## [VERSION]` heading to match exactly
  (e.g., `## [0.2.0-rc1]` for tag `v0.2.0-rc1`). Off-by-one in the
  heading text → empty notes file → workflow fails fast. Acceptable; a
  failed release is recoverable, a release with wrong notes is not.
- **Smoke results disappear if PR is squash-merged.** If the release-prep
  PR is squash-merged after smoke results are posted, the comment
  history persists on the PR page but not in git. Acceptable — the PR
  page is the audit trail.
