# bzr v0.2.0 Release Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Cut `v0.2.0-rc1`, soak for 3 days against a 3-platform smoke matrix, then promote to `v0.2.0` (GitHub Release + crates.io publish).

**Architecture:** Two PRs into `main` (release-prep + promotion), two annotated tags (`v0.2.0-rc1`, `v0.2.0`). Existing `release.yml` and `publish-crates.yml` workflows do all the heavy lifting; this plan modifies them once to source release notes from `CHANGELOG.md` and skip crates.io on prereleases. All tag-side work is procedural — push tag, verify CI, run smoke.

**Tech Stack:** Cargo 1.85+, GitHub Actions (`release.yml`, `publish-crates.yml`), `gh` CLI, `awk`, `actionlint`, Keep-a-Changelog format.

**Spec:** `docs/specs/2026-04-28-v0.2.0-release-design.md`

---

## Working conventions for every task

- **Branches:**
  - Tasks 1–8 happen on `release/v0.2.0-prep` (branched from `main`). Verify with `git branch --show-current`.
  - Tasks 11–13 (smoke matrix) happen anywhere — they exercise downloaded artifacts, no working tree state.
  - Tasks 14–16 happen on `release/v0.2.0-promote` (branched from `main` *after* the rc1 PR merges). Verify with `git branch --show-current`.
  - Tag pushes (Tasks 9, 16) happen from a clean `main`. Verify with `git status` clean and `git log -1 --oneline` showing the expected merge commit.
- **Never tag from a dirty tree or from a branch that isn't main.** Re-checkout `main` and `git pull --ff-only origin main` before any tag step.
- **Pre-commit / pre-push hooks** run `cargo fmt --check + cargo clippy` and `cargo test` respectively. Don't bypass; if a hook fails, fix the cause.
- **Commit messages end with `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`** (project convention from recent commits).
- **Date placeholders** — wherever a step says `<TODAY>`, substitute the actual UTC date the operator runs the step (format `YYYY-MM-DD`). The rc1 entry uses `2026-04-28` since the prep work is happening that day; the promotion entry's date is whatever day Task 14 runs.
- **Real Bugzilla for TOFU smoke** — operator picks one. Recommended defaults: `https://bugzilla.mozilla.org` or `https://bugzilla.redhat.com`. Both are public, both terminate TLS with real chains, neither requires authentication for the probe + read paths the smoke matrix exercises.
- **`gh` CLI must be authenticated** (`gh auth status`) before Tasks 7, 9, 13, 14, 16, 17.

---

## Phase 1 — Release-prep PR (`release/v0.2.0-prep`)

## Task 1: Confirm starting state

Verify the working tree, branch, and main parity before making changes.

**Files:** none

- [ ] **Step 1: Confirm branch and clean tree**

  ```bash
  git branch --show-current
  git status
  ```

  Expected: branch `release/v0.2.0-prep`, working tree clean (other than the spec doc already committed in `docs/specs/2026-04-28-v0.2.0-release-design.md`).

- [ ] **Step 2: Confirm main parity**

  ```bash
  git fetch origin
  git log --oneline release/v0.2.0-prep..origin/main
  ```

  Expected: empty output (the prep branch is up to date with main except for the spec commit).

- [ ] **Step 3: Confirm current Cargo.toml version**

  ```bash
  grep -E '^version' Cargo.toml | head -1
  ```

  Expected: `version = "0.1.2"`.

---

## Task 2: Bump `Cargo.toml` to `0.2.0` and regenerate `Cargo.lock`

**Files:**
- Modify: `Cargo.toml` (the package `version` line near the top)
- Modify: `Cargo.lock` (auto-regenerated)

- [ ] **Step 1: Bump the version**

  Edit `Cargo.toml`: change `version = "0.1.2"` to `version = "0.2.0"`. Leave every other field untouched.

- [ ] **Step 2: Regenerate `Cargo.lock`**

  ```bash
  cargo build --locked
  ```

  Expected: build succeeds. The `--locked` flag means cargo will fail rather than silently update unrelated dependencies — if it fails because `Cargo.lock` doesn't match, run `cargo build` (without `--locked`) once, confirm only the `bzr` package version line changed, then re-run with `--locked`.

- [ ] **Step 3: Confirm only `bzr` changed in the lockfile**

  ```bash
  git diff Cargo.lock
  ```

  Expected: a single hunk updating `name = "bzr"` from `version = "0.1.2"` to `version = "0.2.0"`. If anything else changed, abort and investigate (most likely a stale lockfile from before this branch — restore with `git checkout origin/main -- Cargo.lock` and retry).

- [ ] **Step 4: Commit**

  ```bash
  git add Cargo.toml Cargo.lock
  git commit -m "$(cat <<'EOF'
  release: bump version to 0.2.0

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

---

## Task 3: Add `[0.2.0-rc1]` entry to `CHANGELOG.md`

**Files:**
- Modify: `CHANGELOG.md` (insert new heading above existing `## [0.1.2]`)

- [ ] **Step 1: Insert the new entry**

  Open `CHANGELOG.md`. Find the line `## [0.1.2] - 2026-04-27`. Insert the following block immediately above it (with a blank line separating the new block from the `[0.1.2]` heading):

  ```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.
  ```

- [ ] **Step 2: Verify markdown structure**

  ```bash
  head -50 CHANGELOG.md
  ```

  Expected: lines 1–7 unchanged (file preamble); line 8 (or thereabouts) is `## [0.2.0-rc1] - 2026-04-28`; the existing `## [0.1.2] - 2026-04-27` entry follows beneath, intact.

- [ ] **Step 3: Commit**

  ```bash
  git add CHANGELOG.md
  git commit -m "$(cat <<'EOF'
  release: add CHANGELOG entry for 0.2.0-rc1

  Covers TLS TOFU certificate pinning, HTTP error chain fixes, --from-url
  shell escape handling, and the SonarCloud-driven internal cleanup since
  v0.1.2.

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

---

## Task 4: Add TLS pinning section to `README.md`

**Files:**
- Modify: `README.md` (new top-level section)

- [ ] **Step 1: Locate insertion point**

  ```bash
  grep -nE '^##' README.md
  ```

  Identify the existing top-level sections. Pick an insertion point: the new section should land *after* the existing config / authentication / keyring section and *before* the JSON output / examples section. If structure differs, place it after the section that documents `bzr config set-server`. The exact location is operator's judgement; the section content below is what matters.

- [ ] **Step 2: Insert the TLS pinning section**

  Insert the following block at the chosen point (preserving surrounding blank lines so the headings render correctly):

  ```markdown
  ## TLS certificate pinning

  By default, `bzr` validates server TLS using the operating system's CA
  trust store. For self-hosted Bugzilla servers — especially those exposed
  on the open internet — you may want stronger guarantees that the
  connection is reaching the same server you initially trusted, even if a
  CA in the trust store is later compromised.

  `bzr` supports two pinning models on `bzr config set-server`:

  - `--tls-ca-cert <path>`: pin a custom CA certificate (PEM file). The
    server must present a chain that verifies against this CA.
  - `--tls-pin-sha256 <hex>`: pin the SHA-256 fingerprint of the server's
    leaf certificate Subject Public Key Info (SPKI). The server must
    present a leaf certificate whose SPKI matches this fingerprint.

  ### Trust on first use

  If you don't already know the pin, use `--tls-pin-now`. `bzr` connects
  once, captures the leaf certificate's SPKI fingerprint, prints it, and
  prompts before storing it:

  ```sh
  bzr config set-server my-bz https://bugzilla.example.com --tls-pin-now
  ```

  Subsequent connections to `my-bz` verify the pin. If the server
  presents a different leaf certificate (rotation, reissue, MITM) bzr
  exits with `PinMismatch` and a hint suggesting `--tls-pin-now` to
  re-pin or `--tls-pin-clear` to remove the pin.

  ### Clearing a pin

  ```sh
  bzr config set-server my-bz --tls-pin-clear
  ```

  Removes both `tls_ca_cert` and `tls_pin_sha256` for the server.

  ### Storage

  Pins live in `~/.config/bzr/config.toml`, per-server, alongside other
  server config. They are not stored in the OS keyring (which is
  reserved for credentials). The full reference for these flags is in
  [`docs/bzr-cli.md`](docs/bzr-cli.md).
  ```

- [ ] **Step 3: Verify rendering by inspection**

  ```bash
  grep -nA2 '^## TLS' README.md
  ```

  Expected: new heading `## TLS certificate pinning` followed by the intro paragraph.

- [ ] **Step 4: Commit**

  ```bash
  git add README.md
  git commit -m "$(cat <<'EOF'
  docs: add TLS certificate pinning section to README

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

---

## Task 5: Gate `publish-crates.yml` on stable tags only

**Files:**
- Modify: `.github/workflows/publish-crates.yml` (add `if:` to the `publish` job)

- [ ] **Step 1: Apply the gate**

  Edit `.github/workflows/publish-crates.yml`. Locate:

  ```yaml
  jobs:
    publish:
      name: Publish to crates.io
      runs-on: ubuntu-latest
      permissions:
        contents: read
      steps:
  ```

  Insert one new line so it becomes:

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

- [ ] **Step 2: Lint the workflow with `actionlint`**

  ```bash
  actionlint .github/workflows/publish-crates.yml
  ```

  Expected: no output (success). If `actionlint` is not installed, install with `brew install actionlint` (macOS) or `go install github.com/rhysd/actionlint/cmd/actionlint@latest`.

- [ ] **Step 3: Manually verify the expression evaluates correctly**

  Trace through both cases mentally:
  - Tag `v0.2.0-rc1` → `github.ref_name == "v0.2.0-rc1"` → `contains("v0.2.0-rc1", "-")` → `true` → `!true` → `false` → job skipped.
  - Tag `v0.2.0` → `github.ref_name == "v0.2.0"` → `contains("v0.2.0", "-")` → `false` → `!false` → `true` → job runs.

- [ ] **Step 4: Commit**

  ```bash
  git add .github/workflows/publish-crates.yml
  git commit -m "$(cat <<'EOF'
  ci: skip crates.io publish on prerelease tags

  Adds a job-level `if:` so the entire publish job is skipped when the
  pushed tag contains a hyphen (e.g., v0.2.0-rc1). Stable tags publish
  as before.

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

---

## Task 6: Source release notes from `CHANGELOG.md` in `release.yml`

**Files:**
- Modify: `.github/workflows/release.yml` (replace `--generate-notes` with a CHANGELOG-extraction step)

- [ ] **Step 1: Sanity-check the extraction script locally first (TDD)**

  Before editing the workflow, prove the awk extraction produces correct output against the current `CHANGELOG.md`. From the repo root:

  ```bash
  awk -v v="0.2.0-rc1" '
    $0 ~ "^## \\[" v "\\]" { capture = 1; next }
    capture && /^## \[/      { exit }
    capture                  { print }
  ' CHANGELOG.md
  ```

  Expected output: the body of the `[0.2.0-rc1]` entry — `### Added`, `### Changed`, `### Fixed` blocks — with no `## [0.2.0-rc1]` heading and no content from `[0.1.2]`. The first line should be blank (the blank line that follows the heading) or `### Added`.

- [ ] **Step 2: Sanity-check the failure path**

  ```bash
  awk -v v="9.9.9" '
    $0 ~ "^## \\[" v "\\]" { capture = 1; next }
    capture && /^## \[/      { exit }
    capture                  { print }
  ' CHANGELOG.md | wc -c
  ```

  Expected: `0`. The workflow's caller will then exit non-zero on the empty-file check.

- [ ] **Step 3: Edit `release.yml`**

  Locate the final step in the `release` job (currently line 136 onward):

  ```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/*
  ```

  Replace it with these two steps:

  ```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)"
            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/*
  ```

- [ ] **Step 4: Lint the workflow**

  ```bash
  actionlint .github/workflows/release.yml
  ```

  Expected: no output. If actionlint complains about the multi-line awk inside `run:`, double-check that the `awk` block is exactly as shown (the single-quoted awk script must not contain literal tabs).

- [ ] **Step 5: Commit**

  ```bash
  git add .github/workflows/release.yml
  git commit -m "$(cat <<'EOF'
  ci: source release notes from CHANGELOG.md

  Replaces --generate-notes (which dumped every PR title including
  dependabot bumps) with an awk extraction of the matching ## [VERSION]
  section from CHANGELOG.md. Fails fast if no entry exists for the tag.

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

---

## Task 7: Local validation of the prep PR

**Files:** none

Re-run the full local validation suite against the prep branch before pushing.

- [ ] **Step 1: Format check**

  ```bash
  cargo fmt --check
  ```

  Expected: no output, exit 0.

- [ ] **Step 2: Clippy**

  ```bash
  cargo clippy --all-targets --all-features -- -D warnings
  ```

  Expected: no warnings, exit 0.

- [ ] **Step 3: Unit + integration tests**

  ```bash
  cargo test --locked
  ```

  Expected: all tests pass.

- [ ] **Step 4: Functional tests against real Bugzilla containers**

  ```bash
  make functional-test-all
  ```

  Expected: all functional tests pass. This step requires Docker; if Docker is not running, start it before this step. If a transient container failure happens, rerun once before treating it as a real failure.

- [ ] **Step 5: Publish dry-run**

  ```bash
  cargo publish --dry-run --locked
  ```

  Expected: succeeds, with `Packaging bzr v0.2.0` in the output. If it warns about uncommitted files, commit or stash them first.

- [ ] **Step 6: Workflow lint sweep**

  ```bash
  actionlint .github/workflows/
  ```

  Expected: no output.

---

## Task 8: Push prep branch and open the PR

**Files:** none

- [ ] **Step 1: Push the branch**

  ```bash
  git push -u origin release/v0.2.0-prep
  ```

- [ ] **Step 2: Open the PR**

  ```bash
  gh pr create --title "Release v0.2.0-rc1 prep" --body "$(cat <<'EOF'
  ## Summary
  - Bumps `Cargo.toml` to `0.2.0` and regenerates `Cargo.lock`
  - Adds `[0.2.0-rc1]` entry to `CHANGELOG.md`
  - Adds TLS certificate pinning section to `README.md`
  - Gates `publish-crates.yml` on stable tags only (skips prereleases)
  - Replaces `--generate-notes` with `CHANGELOG.md` extraction in `release.yml`

  Spec: `docs/specs/2026-04-28-v0.2.0-release-design.md`
  Plan: `docs/plans/2026-04-28-v0.2.0-release.md`

  ## Test plan
  - [ ] CI green (fmt, clippy, test, deny, scorecard, fuzz smoke)
  - [ ] After merge: tag `v0.2.0-rc1` from main and run smoke matrix on x86_64-linux, aarch64-darwin, ppc64le-linux

  🤖 Generated with [Claude Code](https://claude.com/claude-code)
  EOF
  )"
  ```

- [ ] **Step 3: Wait for CI**

  ```bash
  gh pr checks --watch
  ```

  Expected: all required checks pass. If any fail, fix on the same branch (do not merge yet), push, and re-watch.

- [ ] **Step 4: Merge the PR**

  Merge via the GitHub UI or:

  ```bash
  gh pr merge --merge
  ```

  Use a merge commit (not squash, not rebase) — the spec/plan/changelog/version commits should remain individually navigable in `git log`. If the project's PR-merge convention is squash, follow that; cross-check `git log v0.1.2..main` after the previous release to see what was used.

- [ ] **Step 5: Sync local main**

  ```bash
  git checkout main
  git pull --ff-only origin main
  ```

  Expected: local main now matches origin/main and contains the prep PR's changes.

---

## Phase 2 — Tag rc1

## Task 9: Pre-tag validation and tag push for `v0.2.0-rc1`

**Files:** none

Re-run validation from main (the merge commit may have introduced surprises) and push the rc1 tag.

- [ ] **Step 1: Confirm clean main**

  ```bash
  git branch --show-current
  git status
  git log -1 --oneline
  ```

  Expected: branch `main`, clean tree, latest commit is the prep PR's merge.

- [ ] **Step 2: Run the full pre-tag checklist**

  ```bash
  cargo fmt --check
  cargo clippy --all-targets --all-features -- -D warnings
  cargo test --locked
  make functional-test-all
  cargo publish --dry-run --locked
  ```

  All must succeed before tagging. Stop and fix on a branch if any step fails.

- [ ] **Step 3: Verify the version is wired through**

  ```bash
  cargo run -- --version
  ```

  Expected: `bzr 0.2.0`.

- [ ] **Step 4: Verify CHANGELOG extraction works on this exact commit**

  ```bash
  awk -v v="0.2.0-rc1" '
    $0 ~ "^## \\[" v "\\]" { capture = 1; next }
    capture && /^## \[/      { exit }
    capture                  { print }
  ' CHANGELOG.md
  ```

  Expected: the `[0.2.0-rc1]` body. If empty, the prep PR's CHANGELOG edit didn't merge correctly — investigate before tagging.

- [ ] **Step 5: Create and push the annotated tag**

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

- [ ] **Step 6: Watch the workflows**

  ```bash
  gh run list --workflow=release.yml --limit=1
  gh run watch
  gh run list --workflow=publish-crates.yml --limit=1
  ```

  Expected: `release.yml` runs to completion, all 7 builds succeed, the `release` job creates the GitHub Release marked prerelease. `publish-crates.yml` shows the `publish` job as skipped (the `if:` gate evaluated false).

- [ ] **Step 7: Verify the GitHub Release**

  ```bash
  gh release view v0.2.0-rc1 --json isPrerelease,name,body,assets
  ```

  Expected: `isPrerelease: true`, name `bzr v0.2.0-rc1`, body containing the `[0.2.0-rc1]` CHANGELOG section (with `### Added` / `### Changed` / `### Fixed` headings), 7 assets attached.

- [ ] **Step 8: Confirm crates.io was not touched**

  ```bash
  cargo search bzr | grep '^bzr '
  ```

  Expected: still shows `bzr = "0.1.2"` (the previous stable). No `0.2.0-rc1` on crates.io.

---

## Phase 3 — Soak window + smoke matrix

### Smoke matrix script (referenced by Tasks 10, 11, 12)

The same 7-step verification script runs on every platform. Each platform task downloads its artifact, then runs this script verbatim. Substitute `<TARGET>` with the platform triple and `<TAG>` with `v0.2.0-rc1` (or `v0.2.0` when re-running for Task 17 step 2).

1. **Version check.**
   ```bash
   ./bzr --version
   ```
   Expected: `bzr 0.2.0`. The binary's `--version` reflects `Cargo.toml` only; the `-rc1` suffix lives in the tag/archive name, not the binary version string.

2. **Configure auth against the operator's existing test server.**
   ```bash
   ./bzr config set-server smoke <YOUR-TEST-BUGZILLA-URL> --api-key <YOUR-API-KEY>
   ./bzr -s smoke whoami
   ```
   Expected: prints the logged-in user's email/login. Auth failures here are a smoke-environment issue, not a release defect — fix server config before proceeding.

3. **Read-path smoke.**
   ```bash
   ./bzr -s smoke bug get <KNOWN-BUG-ID>
   ./bzr -s smoke bug list --product <KNOWN-PRODUCT> --limit 5
   ```
   Expected: each command prints a populated table; non-zero exit on either is a defect.

4. **TLS pinning — TOFU happy path** against a public Bugzilla.
   ```bash
   ./bzr config set-server pinned <REAL-HTTPS-BUGZILLA-URL> --tls-pin-now
   ```
   Expected: probe → printed SHA-256 fingerprint → confirmation prompt → on `y`, pin stored in config. If no prompt appears, the TOFU integration (commit `9e6b92b`) is regressed — escalate.

   Then exercise the pin:
   ```bash
   ./bzr -s pinned whoami
   ```
   Expected: TLS handshake completes (the response body may be unauthenticated for a public server with no API key — that's fine; the goal is verifying the pin is honored, not authentication).

5. **TLS pinning — tamper test (fail-closed).**
   ```bash
   PIN=$(./bzr config show pinned | grep tls_pin_sha256 | awk '{print $NF}')
   echo "Original pin: $PIN"
   ```
   Edit `~/.config/bzr/config.toml`. In the `[servers.pinned]` block, change one hex character of `tls_pin_sha256` (keep it a valid hex digit so TOML still parses). Save.
   ```bash
   ./bzr -s pinned whoami
   ```
   Expected: non-zero exit with a `PinMismatch` error and a hint mentioning `--tls-pin-now`. If it succeeds, the pin verifier is broken — escalate.

   Restore the original pin (paste `$PIN` back into the config file). Re-run `whoami`; should succeed.

6. **TLS pinning — clear.**
   ```bash
   ./bzr config set-server pinned --tls-pin-clear
   ./bzr config show pinned
   ```
   Expected: the `pinned` server's config no longer shows `tls_pin_sha256` or `tls_pin_issuer`.

7. **Cleanup.** Edit `~/.config/bzr/config.toml` and remove the `[servers.smoke]` and `[servers.pinned]` blocks. Cleanup is courtesy; failure here doesn't block the release.

**Recording the result.** Note `<TARGET> ✓ <DATE>` (or `<TARGET> ✗ <details>` on failure) for use in Task 13.

---

## Task 10: Smoke matrix — `x86_64-unknown-linux-gnu`

**Files:** none. All work in a scratch directory on a Linux x86_64 host.

- [ ] **Step 1: Download the artifact**

  ```bash
  mkdir -p /tmp/bzr-smoke-x86_64 && cd /tmp/bzr-smoke-x86_64
  gh release download v0.2.0-rc1 -p 'bzr-v0.2.0-rc1-x86_64-unknown-linux-gnu.tar.gz'
  tar xzf bzr-v0.2.0-rc1-x86_64-unknown-linux-gnu.tar.gz
  cd bzr-v0.2.0-rc1-x86_64-unknown-linux-gnu
  ```

- [ ] **Step 2: Run the smoke matrix script**

  Run all 7 steps of the "Smoke matrix script" defined above. This is the reference platform: every step should pass cleanly. Failures here block rc1 promotion entirely.

- [ ] **Step 3: Record the result**

  For Task 13: `linux-x86_64 ✓ <DATE>` (or `linux-x86_64 ✗ <details>` if any step failed).

---

## Task 11: Smoke matrix — `aarch64-apple-darwin`

**Files:** none. All work on the operator's macOS arm64 host.

- [ ] **Step 1: Download the artifact**

  ```bash
  mkdir -p /tmp/bzr-smoke-darwin && cd /tmp/bzr-smoke-darwin
  gh release download v0.2.0-rc1 -p 'bzr-v0.2.0-rc1-aarch64-apple-darwin.tar.gz'
  tar xzf bzr-v0.2.0-rc1-aarch64-apple-darwin.tar.gz
  cd bzr-v0.2.0-rc1-aarch64-apple-darwin
  ```

- [ ] **Step 2: Run the smoke matrix script**

  Run all 7 steps of the "Smoke matrix script" defined above. Pay attention to the TLS steps (4 and 5) — macOS uses a different TLS stack from Linux, and any platform-specific issue in cert handling shows up here.

- [ ] **Step 3: Record the result**

  For Task 13: `darwin-aarch64 ✓ <DATE>` (or `darwin-aarch64 ✗ <details>` if any step failed).

---

## Task 12: Smoke matrix — `powerpc64le-unknown-linux-gnu`

**Files:** none. All work on a ppc64le Linux host (operator's hardware).

- [ ] **Step 1: Download the artifact onto the ppc64le host**

  ```bash
  mkdir -p /tmp/bzr-smoke-ppc && cd /tmp/bzr-smoke-ppc
  gh release download v0.2.0-rc1 -p 'bzr-v0.2.0-rc1-powerpc64le-unknown-linux-gnu.tar.gz'
  tar xzf bzr-v0.2.0-rc1-powerpc64le-unknown-linux-gnu.tar.gz
  cd bzr-v0.2.0-rc1-powerpc64le-unknown-linux-gnu
  ```

- [ ] **Step 2: Run the smoke matrix script**

  Run all 7 steps of the "Smoke matrix script" defined above. Pay attention to the TLS steps (4 and 5) — the ppc64le binary is built under QEMU in CI, and any endianness or pointer-width assumption in the TLS paths would surface here first.

- [ ] **Step 3: Record the result**

  For Task 13: `linux-ppc64le ✓ <DATE>` (or `linux-ppc64le ✗ <details>` if any step failed).

---

## Task 13: Post smoke results and start the soak clock

**Files:** none

- [ ] **Step 1: Comment the smoke results on the prep PR**

  ```bash
  gh pr comment <PREP-PR-NUMBER> --body "$(cat <<'EOF'
  ## v0.2.0-rc1 smoke matrix

  - linux-x86_64 ✓ <DATE>
  - darwin-aarch64 ✓ <DATE>
  - linux-ppc64le ✓ <DATE>

  Soak clock starts now. If clean at +3 days, promote to v0.2.0.
  EOF
  )"
  ```

  Substitute `<PREP-PR-NUMBER>` (find with `gh pr list --state merged --limit 5`) and the actual dates.

  If any platform failed: do **not** post a "✓" line for that row. Instead, capture the failure in the comment, open a tracking issue or fix branch, and proceed to the re-spin procedure (see "Defect-driven re-spin" at the end of this document).

- [ ] **Step 2: Note the soak window**

  rc1 tag date + 3 calendar days = earliest-possible promotion date. Set a reminder. No work needed during the soak window itself unless a defect surfaces.

---

## Phase 4 — Promote to v0.2.0

## Task 14: Open the promotion PR

**Files:**
- Modify: `CHANGELOG.md` (rename heading)

Run this on or after rc1+3 days, assuming clean soak.

- [ ] **Step 1: Branch off latest main**

  ```bash
  git checkout main
  git pull --ff-only origin main
  git checkout -b release/v0.2.0-promote
  ```

- [ ] **Step 2: Rename the CHANGELOG heading**

  Edit `CHANGELOG.md`. Find:

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

  Replace with:

  ```
  ## [0.2.0] - <TODAY>
  ```

  (Substitute `<TODAY>` with today's UTC date in `YYYY-MM-DD` format.) Body unchanged.

- [ ] **Step 3: Verify CHANGELOG extraction works for the new heading**

  ```bash
  awk -v v="0.2.0" '
    $0 ~ "^## \\[" v "\\]" { capture = 1; next }
    capture && /^## \[/      { exit }
    capture                  { print }
  ' CHANGELOG.md
  ```

  Expected: same body as the rc1 extraction — `### Added`, `### Changed`, `### Fixed` blocks.

- [ ] **Step 4: Commit**

  ```bash
  git add CHANGELOG.md
  git commit -m "$(cat <<'EOF'
  release: promote 0.2.0-rc1 to 0.2.0

  Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  EOF
  )"
  ```

- [ ] **Step 5: Push and open PR**

  ```bash
  git push -u origin release/v0.2.0-promote
  gh pr create --title "Release v0.2.0 promotion" --body "$(cat <<'EOF'
  ## Summary
  - Renames `CHANGELOG.md` heading from `[0.2.0-rc1]` to `[0.2.0]` with today's date.

  Soak record on the rc1 prep PR.

  ## Test plan
  - [ ] CI green
  - [ ] After merge: tag v0.2.0 from main, verify release + crates.io publish

  🤖 Generated with [Claude Code](https://claude.com/claude-code)
  EOF
  )"
  ```

- [ ] **Step 6: Wait for CI and merge**

  ```bash
  gh pr checks --watch
  gh pr merge --merge
  git checkout main
  git pull --ff-only origin main
  ```

---

## Task 15: Pre-tag validation for v0.2.0

**Files:** none

Same checklist as Task 9 — main has a new merge commit on top of rc1's commits and must validate cleanly before tagging.

- [ ] **Step 1: Confirm state**

  ```bash
  git branch --show-current
  git status
  git log -1 --oneline
  ```

  Expected: branch `main`, clean tree, latest commit is the promotion PR's merge.

- [ ] **Step 2: Run the full validation suite**

  ```bash
  cargo fmt --check
  cargo clippy --all-targets --all-features -- -D warnings
  cargo test --locked
  make functional-test-all
  cargo publish --dry-run --locked
  ```

  All must pass.

- [ ] **Step 3: Verify CHANGELOG extraction for `0.2.0`**

  ```bash
  awk -v v="0.2.0" '
    $0 ~ "^## \\[" v "\\]" { capture = 1; next }
    capture && /^## \[/      { exit }
    capture                  { print }
  ' CHANGELOG.md
  ```

  Expected: non-empty body. If empty, the promotion commit didn't land on main — investigate before tagging.

---

## Task 16: Tag and push v0.2.0

**Files:** none

- [ ] **Step 1: Create and push the annotated tag**

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

- [ ] **Step 2: Watch the workflows**

  ```bash
  gh run list --workflow=release.yml --limit=1
  gh run watch
  gh run list --workflow=publish-crates.yml --limit=1
  ```

  Expected: `release.yml` runs to completion, all 7 builds succeed, `release` job creates a stable (non-prerelease) GitHub Release. `publish-crates.yml` runs (no skip this time), passes the `Verify tag matches package version` step (tag `v0.2.0` matches `Cargo.toml` `0.2.0`), passes `cargo publish --dry-run`, and runs `cargo publish`.

- [ ] **Step 3: Verify the GitHub Release**

  ```bash
  gh release view v0.2.0 --json isPrerelease,name,body,assets
  ```

  Expected: `isPrerelease: false`, name `bzr v0.2.0`, body containing the `[0.2.0]` CHANGELOG section, 7 assets attached.

- [ ] **Step 4: Verify crates.io publication**

  Wait ~2 minutes for the index to update, then:

  ```bash
  cargo search bzr | head -3
  ```

  Expected: `bzr = "0.2.0"` appears at the top.

  Cross-check the crate page:

  ```bash
  gh api -H "Accept: application/json" 'https://crates.io/api/v1/crates/bzr/0.2.0' | jq '.version.num, .version.created_at'
  ```

  Or open `https://crates.io/crates/bzr` in a browser. Expected: 0.2.0 listed as the latest version.

---

## Task 17: Post-release verification

**Files:** none

- [ ] **Step 1: Fresh-shell install from crates.io**

  Install into an isolated location to avoid clobbering the operator's existing `bzr` install:

  ```bash
  cargo install bzr --locked --root /tmp/bzr-install-verify
  /tmp/bzr-install-verify/bin/bzr --version
  ```

  Expected: install completes, version output is `bzr 0.2.0`. May take a minute (full release build from source).

- [ ] **Step 2: Re-run smoke matrix on final artifacts**

  Re-run Tasks 10, 11, 12 with one substitution: in each task's Step 1, replace `v0.2.0-rc1` with `v0.2.0` in the `gh release download` argument and the `tar xzf` / `cd` paths. Step 2 (the smoke matrix script) is unchanged; the script's `<TAG>` placeholder now resolves to `v0.2.0`. This catches the rare case where the rc1 build was non-reproducible.

- [ ] **Step 3: Verify rendering of the GitHub Release page**

  Open `https://github.com/randomparity/bzr/releases/tag/v0.2.0` in a browser. Confirm:
  - Heading: `bzr v0.2.0`
  - Not marked as pre-release
  - Body shows `### Added`, `### Changed`, `### Fixed` sections rendered as h3 headings (not as raw text or escaped markdown)
  - All 7 archive files are listed under "Assets"

- [ ] **Step 4: No follow-up version bump on main**

  Confirm `Cargo.toml` on main still reads `version = "0.2.0"`. Do **not** bump to a "dev" version. Project convention (per past releases) is to leave main at the released version until the next release-prep PR.

- [ ] **Step 5: Optional: announce**

  Out of scope for this plan — anything beyond the release artifacts (blog post, social, mailing list) is operator's discretion. Document the announce in the operator's own notes if applicable.

---

## Defect-driven re-spin (rc(N+1)) — used only if soak finds a defect

**Trigger:** any of:
- Smoke matrix failure on any of the 3 platforms during Task 10–12 or Task 17 step 2.
- TLS pinning correctness regression discovered during soak.
- New RUSTSEC advisory against a dependency that can't be patched in-place.
- User-facing bug in the in-scope commit range surfaces.

**Procedure:**

1. **Fix on a topic branch.**
   ```bash
   git checkout main
   git pull --ff-only origin main
   git checkout -b fix/<short-description>
   # ... make the fix, write the failing test first if it's a code change
   git push -u origin fix/<short-description>
   gh pr create --title "<title>" --body "<body>"
   ```
   CI must be green; merge.

2. **Update CHANGELOG.md to reflect rc(N+1).** On a new branch off main, edit the heading from `## [0.2.0-rc1] - <date>` to `## [0.2.0-rc2] - <NEW-DATE>`. Append a one-line entry to the appropriate section (`### Fixed` for bugs, `### Changed` for behavior changes) describing the fix. Do **not** rewrite or remove the rc1 content — rc2 is rc1 plus the fix. Commit, PR, merge.

3. **Tag v0.2.0-rc2 from main.**
   ```bash
   git checkout main
   git pull --ff-only origin main
   # Re-run the full Task 9 pre-tag validation
   git tag -a v0.2.0-rc2 -m "bzr v0.2.0-rc2"
   git push origin v0.2.0-rc2
   gh run watch
   ```

4. **Restart the smoke matrix and soak clock.** Re-run Tasks 10–13 against rc2 artifacts. The 3-day soak clock restarts at rc2 tag time.

5. **Repeat for rc3, rc4, … as needed.** No length cap. Promote (Tasks 14–17) only when an rc completes the full 3-day window with a clean smoke matrix.

6. **At promotion time** (Task 14), the heading rename targets whichever rc is the "last" rc — e.g., `## [0.2.0-rc3] - <date>` becomes `## [0.2.0] - <today>`. The body (which now contains both rc1's content and the appended fix lines) becomes the v0.2.0 release notes verbatim.
