diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..b72663b
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,28 @@
+name: Deploy Docs
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "docs/**"
+ - "mkdocs.yml"
+ - ".github/workflows/docs.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: docs-deploy
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: extractions/setup-just@v2
+ - uses: astral-sh/setup-uv@v3
+ - run: just docs-deploy
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
deleted file mode 100644
index ccc1ee6..0000000
--- a/.readthedocs.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-version: 2
-
-build:
- os: "ubuntu-22.04"
- tools:
- python: "3.12"
-
-python:
- install:
- - requirements: docs/requirements.txt
-
-mkdocs:
- configuration: mkdocs.yml
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0bfbb37..8dc80b3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing
The contributing guide is published as part of the project documentation:
-**https://httpware.readthedocs.io/en/latest/dev/contributing/**
+**https://httpware.modern-python.org/dev/contributing/**
Source: [`docs/dev/contributing.md`](docs/dev/contributing.md).
diff --git a/Justfile b/Justfile
index 493f3b3..7d9310e 100644
--- a/Justfile
+++ b/Justfile
@@ -29,3 +29,8 @@ publish:
uv version $GITHUB_REF_NAME
uv build
uv publish --token $PYPI_TOKEN
+
+# Force-pushes built site to gh-pages; CI runs this on push to main.
+# Manual invocation from a stale checkout will roll the live site back.
+docs-deploy:
+ uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..2eef52d
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+httpware.modern-python.org
diff --git a/docs/recipes/modern-di.md b/docs/recipes/modern-di.md
index bac0a30..35eb0bb 100644
--- a/docs/recipes/modern-di.md
+++ b/docs/recipes/modern-di.md
@@ -1,6 +1,6 @@
# Wiring `AsyncClient` into `modern-di`
-If you wire your app's dependencies with [`modern-di`](https://modern-di.readthedocs.io/) and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the [`modern-python`](https://github.com/modern-python) org.
+If you wire your app's dependencies with [`modern-di`](https://modern-di.modern-python.org/) and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the [`modern-python`](https://github.com/modern-python) org.
## The minimal wire-up
@@ -34,7 +34,7 @@ Breaking that down:
A common first instinct here is `finalizer=lambda c: c.aclose()`. **That does not work** — the lambda itself is sync, so `modern-di` calls it synchronously and discards the returned coroutine unawaited. The underlying connection pool leaks. Pass the unbound async method directly, or wrap in `async def`.
-See the [`modern-di` factories docs](https://modern-di.readthedocs.io/providers/factories/) for the broader `CacheSettings` story (scopes, `clear_cache`, sync vs async finalizers).
+See the [`modern-di` factories docs](https://modern-di.modern-python.org/providers/factories/) for the broader `CacheSettings` story (scopes, `clear_cache`, sync vs async finalizers).
## Adding a second backend hits a type collision
@@ -135,4 +135,4 @@ Each cached singleton owns its own `AsyncBulkhead` and `AsyncRetry` state — wh
- **[Quick-Start](../index.md)** — the base `AsyncClient` API.
- **[Middleware guide](../middleware.md)** — what `AsyncBulkhead` and `AsyncRetry` are doing in `kwargs[middleware]`.
- **[Resilience reference](../resilience.md)** — every parameter on `AsyncRetry`, `RetryBudget`, `AsyncBulkhead`.
-- **[`modern-di` factories](https://modern-di.readthedocs.io/providers/factories/)** — `CacheSettings`, scopes, the broader provider story.
+- **[`modern-di` factories](https://modern-di.modern-python.org/providers/factories/)** — `CacheSettings`, scopes, the broader provider story.
diff --git a/mkdocs.yml b/mkdocs.yml
index 5456fdb..24f9cba 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,5 +1,5 @@
site_name: httpware
-site_url: https://httpware.readthedocs.io/
+site_url: https://httpware.modern-python.org
repo_url: https://github.com/modern-python/httpware
docs_dir: docs
edit_uri: edit/main/docs/
diff --git a/planning/engineering.md b/planning/engineering.md
index 2f7c16d..21cb66c 100644
--- a/planning/engineering.md
+++ b/planning/engineering.md
@@ -142,7 +142,7 @@ Post-pivot, the roadmap has three categories. Topic slugs in `planning/specs/` a
- **v0.8.0:** sync `Client` with full feature parity (middleware chain, decoder seam, `Retry`, `Bulkhead`, `stream()`); async surface renamed to `Async*`/`async_*` prefix; `attempt_timeout=` removed from `AsyncRetry`. Breaking release for every public async middleware import.
- **Epic 4 — Streaming: SHIPPED in v0.5.** `AsyncClient.stream()` context manager + Retry refuses streamed-body requests. See [`planning/archive/specs/2026-06-05-streaming-design.md`](archive/specs/2026-06-05-streaming-design.md) and [`planning/archive/plans/2026-06-05-streaming-plan.md`](archive/plans/2026-06-05-streaming-plan.md).
- **Epic 5 — Observability: SHIPPED in v0.6** — re-scoped from the original 4-story plan. `Retry` and `Bulkhead` emit operational events via stdlib `logging` + opt-in OpenTelemetry span events. Stories `5-1` (Layer 1 middleware hooks) and `5-4` (standalone OTel middleware) RETIRED — `opentelemetry-instrumentation-httpx` already covers transport-level tracing; a separate httpware middleware would duplicate it. See [`planning/archive/specs/2026-06-05-observability-design.md`](archive/specs/2026-06-05-observability-design.md) and [`planning/archive/plans/2026-06-05-observability-plan.md`](archive/plans/2026-06-05-observability-plan.md).
-- **Epic 6 — Ship v1.0: SHIPPED.** `6-2` docs site live at (mkdocs-material, hand-written content only, auto-publishing from `main`). Stories `6-3` (benchmarks) and `6-5` (Trusted Publishers + Sigstore release flow) RETIRED — neither carries enough value to maintain. Tag-driven release via the existing `publish.yml` workflow stays as-is.
+- **Epic 6 — Ship v1.0: SHIPPED.** `6-2` docs site live at (mkdocs-material, hand-written content only, auto-publishing from `main`). Stories `6-3` (benchmarks) and `6-5` (Trusted Publishers + Sigstore release flow) RETIRED — neither carries enough value to maintain. Tag-driven release via the existing `publish.yml` workflow stays as-is.
- **Carry-forward decoder:** `1-6` msgspec decoder via extras — second `ResponseDecoder` adapter, already implemented; verified surviving in the pivot.
- **Middleware protocol:** `2-1` and `2-2` already implemented in the pivot (protocol, chain, phase decorators).
diff --git a/planning/plans/2026-06-06-modern-di-recipe-plan.md b/planning/plans/2026-06-06-modern-di-recipe-plan.md
index 2f2a9d3..3c692a9 100644
--- a/planning/plans/2026-06-06-modern-di-recipe-plan.md
+++ b/planning/plans/2026-06-06-modern-di-recipe-plan.md
@@ -170,7 +170,7 @@ Write `docs/recipes/modern-di.md` with the full content below.
````markdown
# Wiring `AsyncClient` into `modern-di`
-If you wire your app's dependencies with [`modern-di`](https://modern-di.readthedocs.io/) and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the [`modern-python`](https://github.com/modern-python) org.
+If you wire your app's dependencies with [`modern-di`](https://modern-di.modern-python.org/) and want connection-pool teardown and middleware composition to flow through the container's lifecycle, this is the bridge. Both libraries ship under the [`modern-python`](https://github.com/modern-python) org.
## The minimal wire-up
@@ -204,7 +204,7 @@ Breaking that down:
A common first instinct here is `finalizer=lambda c: c.aclose()`. **That does not work** — the lambda itself is sync, so `modern-di` calls it synchronously and discards the returned coroutine unawaited. The underlying connection pool leaks. Pass the unbound async method directly, or wrap in `async def`.
-See the [`modern-di` factories docs](https://modern-di.readthedocs.io/providers/factories/) for the broader `CacheSettings` story (scopes, `clear_cache`, sync vs async finalizers).
+See the [`modern-di` factories docs](https://modern-di.modern-python.org/providers/factories/) for the broader `CacheSettings` story (scopes, `clear_cache`, sync vs async finalizers).
## Adding a second backend hits a type collision
@@ -304,7 +304,7 @@ Each cached singleton owns its own `Bulkhead` and `Retry` state — what you wan
- **[Quick-Start](../index.md)** — the base `AsyncClient` API.
- **[Middleware guide](../middleware.md)** — what `Bulkhead` and `Retry` are doing in `kwargs[middleware]`.
- **[Resilience reference](../resilience.md)** — every parameter on `Retry`, `RetryBudget`, `Bulkhead`.
-- **[`modern-di` factories](https://modern-di.readthedocs.io/providers/factories/)** — `CacheSettings`, scopes, the broader provider story.
+- **[`modern-di` factories](https://modern-di.modern-python.org/providers/factories/)** — `CacheSettings`, scopes, the broader provider story.
````
- [ ] **Step 2: Update `mkdocs.yml` to add the `Recipes` nav section**
diff --git a/planning/plans/2026-06-08-mkdocs-gh-pages-migration-plan.md b/planning/plans/2026-06-08-mkdocs-gh-pages-migration-plan.md
new file mode 100644
index 0000000..be92571
--- /dev/null
+++ b/planning/plans/2026-06-08-mkdocs-gh-pages-migration-plan.md
@@ -0,0 +1,620 @@
+# mkdocs GitHub Pages Migration 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:** Move httpware's docs hosting from ReadTheDocs to GitHub Pages at `httpware.modern-python.org`, modeled on the `modern-di` pipeline, as a single structural PR.
+
+**Architecture:** A GitHub Actions workflow (`docs.yml`) runs `just docs-deploy` on every push to `main` that touches docs, mkdocs config, or the workflow itself. The recipe uses `uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force`, which builds the site and force-pushes it to the `gh-pages` branch. A `docs/CNAME` file pins the custom domain across deploys. ReadTheDocs config is deleted as part of the cutover.
+
+**Tech Stack:** GitHub Actions, mkdocs + mkdocs-material, `just`, `uv` / `uvx`, GitHub Pages.
+
+**Spec:** [`planning/specs/2026-06-08-mkdocs-gh-pages-migration-design.md`](../specs/2026-06-08-mkdocs-gh-pages-migration-design.md)
+
+---
+
+## Working assumptions
+
+- You are working on a branch off `main` (not directly on `main`). If unsure, create one: `git checkout -b docs/migrate-to-gh-pages`.
+- `just`, `uv` (which provides `uvx`), and Python ≥3.11 are installed locally.
+- DNS for `httpware.modern-python.org` and the Settings → Pages bootstrap are OPERATIONAL prerequisites and are NOT part of this plan — they're called out in the spec for the PR description.
+- The current working directory throughout the plan is the repo root: `/Users/kevinsmith/src/pypi/httpware` (or your equivalent checkout).
+
+## File map
+
+**Create:**
+- `docs/CNAME` — pins the custom domain.
+- `.github/workflows/docs.yml` — GitHub Actions deploy workflow.
+
+**Modify:**
+- `Justfile` — add `docs-deploy` recipe.
+- `mkdocs.yml` — flip `site_url`.
+- `pyproject.toml` — flip `docs` project URL.
+- `CONTRIBUTING.md` — flip docs link, drop RTD versioning path.
+- `planning/engineering.md` — flip RTD URL.
+- `docs/recipes/modern-di.md` — flip two dead `modern-di.readthedocs.io` URLs.
+- `planning/plans/2026-06-06-modern-di-recipe-plan.md` — flip two dead modern-di URLs.
+- `planning/specs/2026-06-06-modern-di-recipe-design.md` — flip one dead modern-di URL.
+
+**Delete:**
+- `.readthedocs.yaml`.
+
+---
+
+## Task 1: Add the `docs-deploy` recipe to the Justfile
+
+**Files:**
+- Modify: `Justfile`
+
+- [ ] **Step 1: Append the recipe to the Justfile**
+
+Append to the bottom of `Justfile` (after the existing `publish:` recipe):
+
+```
+# Force-pushes built site to gh-pages; CI runs this on push to main.
+# Manual invocation from a stale checkout will roll the live site back.
+docs-deploy:
+ uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force
+```
+
+The comment is intentional — `--force` makes local invocation destructive if `main` is stale.
+
+- [ ] **Step 2: Verify the recipe is registered**
+
+Run: `just --list`
+
+Expected output includes:
+```
+Available recipes:
+ default
+ docs-deploy # Force-pushes built site to gh-pages; CI runs this on push to main.
+ install
+ lint
+ lint-ci
+ publish
+ test *args
+ test-branch
+```
+
+- [ ] **Step 3: Smoke-test that the mkdocs build still succeeds with the current config**
+
+This catches any pre-existing site-build failure before the cutover changes. Run:
+
+```
+uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/httpware-docs-smoke
+```
+
+Expected: `INFO - Documentation built in s` with no warnings (because `--strict`).
+
+If this fails with broken-link warnings unrelated to this PR, STOP and surface them — that's a pre-existing problem, not a migration issue.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add Justfile
+git commit -m "build(just): add docs-deploy recipe for mkdocs gh-deploy"
+```
+
+---
+
+## Task 2: Add `docs/CNAME` to pin the custom domain
+
+**Files:**
+- Create: `docs/CNAME`
+
+- [ ] **Step 1: Create the file**
+
+Content of `docs/CNAME` (single line, no trailing data):
+
+```
+httpware.modern-python.org
+```
+
+- [ ] **Step 2: Verify mkdocs treats it as a static asset**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/httpware-docs-smoke`
+
+Then check the build output includes the CNAME file at the root:
+
+```bash
+test -f /tmp/httpware-docs-smoke/CNAME && cat /tmp/httpware-docs-smoke/CNAME
+```
+
+Expected: prints `httpware.modern-python.org`. If the file is absent, mkdocs is not copying it — abort and investigate before continuing.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/CNAME
+git commit -m "docs(cname): pin httpware.modern-python.org for GitHub Pages"
+```
+
+---
+
+## Task 3: Flip `site_url` in `mkdocs.yml`
+
+**Files:**
+- Modify: `mkdocs.yml` line 2
+
+- [ ] **Step 1: Edit the file**
+
+Change line 2 of `mkdocs.yml` from:
+
+```yaml
+site_url: https://httpware.readthedocs.io/
+```
+
+to:
+
+```yaml
+site_url: https://httpware.modern-python.org
+```
+
+(No trailing slash, matching the modern-di convention.)
+
+- [ ] **Step 2: Verify the build still passes and the new URL is baked in**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/httpware-docs-smoke`
+
+Expected: build succeeds. Then confirm the new URL is embedded:
+
+```bash
+grep -c "httpware.modern-python.org" /tmp/httpware-docs-smoke/index.html
+```
+
+Expected: a positive integer (the URL appears in canonical/og meta tags).
+
+```bash
+grep -c "httpware.readthedocs.io" /tmp/httpware-docs-smoke/index.html
+```
+
+Expected: `0`. If positive, find the leftover reference and resolve it before proceeding.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add mkdocs.yml
+git commit -m "docs(mkdocs): switch site_url to httpware.modern-python.org"
+```
+
+---
+
+## Task 4: Update the `docs` project URL in `pyproject.toml`
+
+**Files:**
+- Modify: `pyproject.toml` line 42
+
+- [ ] **Step 1: Edit the file**
+
+Change line 42 of `pyproject.toml` from:
+
+```toml
+docs = "https://httpware.readthedocs.io"
+```
+
+to:
+
+```toml
+docs = "https://httpware.modern-python.org"
+```
+
+- [ ] **Step 2: Verify the project still resolves**
+
+Run: `uv lock --check`
+
+Expected: exits 0 with no output (or a brief "Resolved N packages in