diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f222f9..9c226fc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,9 +14,9 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: extractions/setup-just@v2
- - uses: astral-sh/setup-uv@v3
+ - uses: actions/checkout@v6
+ - uses: extractions/setup-just@v4
+ - uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
@@ -46,8 +46,8 @@ jobs:
--health-timeout 5s
--health-retries 5
steps:
- - uses: actions/checkout@v4
- - uses: astral-sh/setup-uv@v3
+ - uses: actions/checkout@v6
+ - uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..1a84455
--- /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@v6
+ with:
+ fetch-depth: 0
+ - uses: extractions/setup-just@v4
+ - uses: astral-sh/setup-uv@v8.2.0
+ - run: just docs-deploy
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index b637272..916fc36 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -9,9 +9,9 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: extractions/setup-just@v2
- - uses: astral-sh/setup-uv@v3
+ - uses: actions/checkout@v6
+ - uses: extractions/setup-just@v4
+ - uses: astral-sh/setup-uv@v8.2.0
- run: just publish
env:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
deleted file mode 100644
index 571df87..0000000
--- a/.readthedocs.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-version: 2
-
-build:
- os: "ubuntu-22.04"
- tools:
- python: "3.10"
-
-python:
- install:
- - requirements: docs/requirements.txt
-
-mkdocs:
- configuration: mkdocs.yml
diff --git a/Justfile b/Justfile
index 307bcee..e209b3b 100644
--- a/Justfile
+++ b/Justfile
@@ -33,3 +33,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/README.md b/README.md
index aebe4d3..1c41d46 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ async with session_factory() as session, session.begin():
)
```
-The same one-decorator pattern works for RabbitMQ, NATS, Redis, and Confluent. See the [relay tutorial](https://faststream-outbox.readthedocs.io/en/latest/usage/relay/) for the FastAPI lifecycle, header propagation, router shapes, and the at-least-once contract.
+The same one-decorator pattern works for RabbitMQ, NATS, Redis, and Confluent. See the [relay tutorial](https://faststream-outbox.modern-python.org/usage/relay/) for the FastAPI lifecycle, header propagation, router shapes, and the at-least-once contract.
## Quickstart — standalone outbox queue
@@ -81,13 +81,13 @@ async with session_factory() as session, session.begin():
## How it works
-A subscriber owns two async loops: a **fetch** loop claims available rows via a single CTE (`SELECT … FOR UPDATE SKIP LOCKED → UPDATE acquired_token=:uuid, acquired_at=now() RETURNING *`), and `max_workers` **worker** loops dispatch to the handler. On success, `DELETE WHERE id=:id AND acquired_token=:token`; on failure, the retry strategy schedules another attempt or terminally drops the row. Terminal failures `DELETE` by default; pass `dlq_table=make_dlq_table(metadata)` to atomically archive them into a sibling audit table instead — see [Dead-letter queue](https://faststream-outbox.readthedocs.io/en/latest/usage/dlq/).
+A subscriber owns two async loops: a **fetch** loop claims available rows via a single CTE (`SELECT … FOR UPDATE SKIP LOCKED → UPDATE acquired_token=:uuid, acquired_at=now() RETURNING *`), and `max_workers` **worker** loops dispatch to the handler. On success, `DELETE WHERE id=:id AND acquired_token=:token`; on failure, the retry strategy schedules another attempt or terminally drops the row. Terminal failures `DELETE` by default; pass `dlq_table=make_dlq_table(metadata)` to atomically archive them into a sibling audit table instead — see [Dead-letter queue](https://faststream-outbox.modern-python.org/usage/dlq/).
The `acquired_token` is the load-bearing invariant: a slow handler whose lease expired and was re-claimed by another worker finds its terminal `DELETE` to be a no-op (the token no longer matches), preventing it from clobbering the new lease holder.
With the `asyncpg` driver, the fetch loop also `LISTEN`s on `outbox_
` and `publish` emits `pg_notify(...)`, so idle dispatch latency is sub-100ms instead of up to `max_fetch_interval`.
-See [How it works](https://faststream-outbox.readthedocs.io/en/latest/introduction/how-it-works/) for the full architecture.
+See [How it works](https://faststream-outbox.modern-python.org/introduction/how-it-works/) for the full architecture.
## Optional extras
@@ -107,7 +107,7 @@ Browse the full list of templates and libraries in
[`modern-python`](https://github.com/modern-python) — see the org profile for the
categorized index.
-## 📚 [Documentation](https://faststream-outbox.readthedocs.io)
+## 📚 [Documentation](https://faststream-outbox.modern-python.org)
## 📦 [PyPi](https://pypi.org/project/faststream-outbox)
diff --git a/docs/CNAME b/docs/CNAME
new file mode 100644
index 0000000..2f00d83
--- /dev/null
+++ b/docs/CNAME
@@ -0,0 +1 @@
+faststream-outbox.modern-python.org
diff --git a/mkdocs.yml b/mkdocs.yml
index 6fbafa5..e8523ef 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,4 +1,5 @@
site_name: faststream-outbox
+site_url: https://faststream-outbox.modern-python.org/
repo_url: https://github.com/modern-python/faststream-outbox
docs_dir: docs
edit_uri: edit/main/docs/
diff --git a/planning/plans/2026-06-09-mkdocs-github-pages-plan.md b/planning/plans/2026-06-09-mkdocs-github-pages-plan.md
new file mode 100644
index 0000000..7ebd128
--- /dev/null
+++ b/planning/plans/2026-06-09-mkdocs-github-pages-plan.md
@@ -0,0 +1,604 @@
+# Migrate Docs Hosting from Read the Docs to GitHub Pages — 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:** Replace the Read the Docs deploy pipeline with a GitHub Actions + GitHub Pages deploy mirroring `modern-di`, served at `faststream-outbox.modern-python.org`. Bump CI action pins to the same baseline along the way.
+
+**Architecture:** Eight in-repo file changes (one new workflow, one new docs/CNAME, one Justfile target, one mkdocs.yml field, two file deletions/edits for RTD removal, two action-pin bumps). Out-of-repo DNS + GH Pages settings happen after merge (documented in the spec, not in this plan). All commits land on a single feature branch.
+
+**Tech Stack:** mkdocs + mkdocs-material (existing), `mkdocs gh-deploy` (force-push to `gh-pages`), GitHub Actions, `just`, `uvx`.
+
+**Spec:** `planning/specs/2026-06-09-mkdocs-github-pages-design.md`
+
+**Branch:** Work on a new branch `feat/mkdocs-github-pages` off `main`. Do not commit to `main` directly.
+
+---
+
+## Pre-flight
+
+- [ ] **Step 1: Create the feature branch**
+
+```bash
+git checkout -b feat/mkdocs-github-pages
+git status
+```
+
+Expected: `On branch feat/mkdocs-github-pages` with clean working tree.
+
+- [ ] **Step 2: Confirm assumptions about current state**
+
+```bash
+ls .readthedocs.yaml docs/requirements.txt docs/CNAME 2>&1
+grep -c "site_url" mkdocs.yml
+grep "docs-deploy" Justfile
+```
+
+Expected:
+- `.readthedocs.yaml` and `docs/requirements.txt` exist.
+- `docs/CNAME` does NOT exist (`ls` reports no such file).
+- `site_url` grep returns `0` (currently absent).
+- `docs-deploy` grep returns no match (Justfile has no docs target yet).
+
+If any expectation fails, STOP and re-read the spec — assumptions changed.
+
+---
+
+## Task 1: Add the `just docs-deploy` target
+
+**Files:**
+- Modify: `Justfile` (append at end)
+
+- [ ] **Step 1: Read the current Justfile end**
+
+```bash
+tail -5 Justfile
+```
+
+Expected: last target is `publish:` with `uv publish --token $PYPI_TOKEN`. Confirms there's no trailing blank-line surprise.
+
+- [ ] **Step 2: Append the `docs-deploy` target**
+
+Append these lines to the end of `Justfile` (preserve a single blank line before the new target, single trailing newline at EOF):
+
+```just
+
+# 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
+```
+
+- [ ] **Step 3: Verify the target is discoverable**
+
+Run: `just --list | grep docs-deploy`
+
+Expected: one line — ` docs-deploy # Force-pushes built site to gh-pages; CI runs this on push to main.`
+
+- [ ] **Step 4: Smoke-test mkdocs without deploying**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/mkdocs-smoke`
+
+Expected: build completes with no warnings (`--strict` turns warnings into errors). Output ends with `INFO - Documentation built in N.NNs`. The `/tmp/mkdocs-smoke` directory now contains `index.html`. Clean up with `rm -rf /tmp/mkdocs-smoke`.
+
+If the build fails, the current `mkdocs.yml` has a latent issue unrelated to this plan — STOP and report.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Justfile
+git commit -m "chore: add just docs-deploy target for mkdocs gh-deploy
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 2: Add `docs/CNAME`
+
+**Files:**
+- Create: `docs/CNAME`
+
+- [ ] **Step 1: Create the file with the custom domain**
+
+Create `docs/CNAME` containing exactly one line with a trailing newline:
+
+```
+faststream-outbox.modern-python.org
+```
+
+- [ ] **Step 2: Verify contents**
+
+Run: `cat docs/CNAME && wc -l docs/CNAME`
+
+Expected:
+```
+faststream-outbox.modern-python.org
+ 1 docs/CNAME
+```
+
+(One line, one newline at EOF.)
+
+- [ ] **Step 3: Confirm mkdocs picks it up**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/mkdocs-smoke && cat /tmp/mkdocs-smoke/CNAME && rm -rf /tmp/mkdocs-smoke`
+
+Expected: build succeeds, `CNAME` is copied into the built site with the same single-line contents.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/CNAME
+git commit -m "docs: add CNAME for faststream-outbox.modern-python.org
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 3: Add `site_url` to `mkdocs.yml`
+
+**Files:**
+- Modify: `mkdocs.yml` (insert one line after `site_name:`)
+
+- [ ] **Step 1: Read the first 6 lines of mkdocs.yml**
+
+```bash
+head -6 mkdocs.yml
+```
+
+Expected current top:
+```yaml
+site_name: faststream-outbox
+repo_url: https://github.com/modern-python/faststream-outbox
+docs_dir: docs
+edit_uri: edit/main/docs/
+nav:
+ - Introduction:
+```
+
+- [ ] **Step 2: Insert `site_url` after `site_name`**
+
+After line 1 (`site_name: faststream-outbox`), insert:
+
+```yaml
+site_url: https://faststream-outbox.modern-python.org/
+```
+
+Result: lines 1–2 are `site_name:` followed by `site_url:`; the rest of the file is unchanged.
+
+- [ ] **Step 3: Verify the edit**
+
+```bash
+head -3 mkdocs.yml
+```
+
+Expected:
+```yaml
+site_name: faststream-outbox
+site_url: https://faststream-outbox.modern-python.org/
+repo_url: https://github.com/modern-python/faststream-outbox
+```
+
+- [ ] **Step 4: Verify mkdocs still builds**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/mkdocs-smoke && grep -c 'canonical' /tmp/mkdocs-smoke/index.html && rm -rf /tmp/mkdocs-smoke`
+
+Expected: build succeeds, grep returns `1` (the canonical link tag is now emitted in `index.html`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mkdocs.yml
+git commit -m "docs: set mkdocs site_url for canonical links and sitemap
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 4: Add `.github/workflows/docs.yml`
+
+**Files:**
+- Create: `.github/workflows/docs.yml`
+
+- [ ] **Step 1: Create the workflow**
+
+Create `.github/workflows/docs.yml` with exactly this content (single trailing newline at EOF):
+
+```yaml
+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@v6
+ with:
+ fetch-depth: 0
+ - uses: extractions/setup-just@v4
+ - uses: astral-sh/setup-uv@v8.2.0
+ - run: just docs-deploy
+```
+
+- [ ] **Step 2: Verify YAML parses**
+
+Run: `python3 -c "import yaml, sys; yaml.safe_load(open('.github/workflows/docs.yml')); print('OK')"`
+
+Expected: prints `OK`. (Any YAML error throws and exits non-zero.)
+
+- [ ] **Step 3: Verify load-bearing fields are present**
+
+Run:
+```bash
+python3 -c "
+import yaml
+d = yaml.safe_load(open('.github/workflows/docs.yml'))
+assert d['name'] == 'Deploy Docs'
+assert d['concurrency']['group'] == 'docs-deploy'
+assert d['concurrency']['cancel-in-progress'] is True
+assert d['permissions']['contents'] == 'write'
+assert d['jobs']['deploy']['steps'][0]['with']['fetch-depth'] == 0
+assert d['jobs']['deploy']['steps'][-1]['run'] == 'just docs-deploy'
+print('OK')
+"
+```
+
+Expected: prints `OK`. If any assert fails, the YAML was mistyped — fix and re-run.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .github/workflows/docs.yml
+git commit -m "ci: add mkdocs gh-deploy workflow
+
+Triggers on push to main when docs/, mkdocs.yml, or this workflow
+change. Concurrency group serializes deploys; contents:write needed
+for gh-pages branch push; fetch-depth: 0 needed for gh-deploy history.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 5: Delete `.readthedocs.yaml`
+
+**Files:**
+- Delete: `.readthedocs.yaml`
+
+- [ ] **Step 1: Confirm the file exists and matches expectations**
+
+```bash
+cat .readthedocs.yaml
+```
+
+Expected: the 14-line file (version 2, ubuntu-22.04, python 3.10, mkdocs config). If contents have drifted from the spec snapshot, pause and re-read the spec.
+
+- [ ] **Step 2: Delete the file**
+
+```bash
+git rm .readthedocs.yaml
+```
+
+Expected: `rm '.readthedocs.yaml'`, working tree shows the deletion staged.
+
+- [ ] **Step 3: Verify**
+
+```bash
+ls .readthedocs.yaml 2>&1; git status --short
+```
+
+Expected: `ls` reports the file does not exist; `git status --short` shows `D .readthedocs.yaml`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git commit -m "ci: remove Read the Docs config
+
+Docs now deploy from .github/workflows/docs.yml. RTD project will be
+archived in the RTD UI after the new URL is live (out-of-repo step).
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 6: Update `README.md` URLs
+
+**Files:**
+- Modify: `README.md` (4 line edits — lines 52, 84, 90, 110)
+
+- [ ] **Step 1: Replace the three `/en/latest/` deep links**
+
+Three substitutions in `README.md`. Each replaces a substring; do them in order. Use Edit (string replacement), not regex.
+
+Edit 1 — line 52, relay tutorial:
+- Old: `https://faststream-outbox.readthedocs.io/en/latest/usage/relay/`
+- New: `https://faststream-outbox.modern-python.org/usage/relay/`
+
+Edit 2 — line 84, dead-letter queue:
+- Old: `https://faststream-outbox.readthedocs.io/en/latest/usage/dlq/`
+- New: `https://faststream-outbox.modern-python.org/usage/dlq/`
+
+Edit 3 — line 90, "How it works":
+- Old: `https://faststream-outbox.readthedocs.io/en/latest/introduction/how-it-works/`
+- New: `https://faststream-outbox.modern-python.org/introduction/how-it-works/`
+
+- [ ] **Step 2: Replace the bare-domain link**
+
+Edit 4 — line 110, top-level Documentation header:
+- Old: `https://faststream-outbox.readthedocs.io`
+- New: `https://faststream-outbox.modern-python.org`
+
+(Order matters: do this AFTER the three `/en/latest/` edits. Otherwise the bare-domain string also matches inside the deeper URLs and you'd over-replace.)
+
+- [ ] **Step 3: Verify no `readthedocs` references remain**
+
+Run: `grep -n readthedocs README.md`
+
+Expected: no output (exit code 1 from grep, which is fine).
+
+- [ ] **Step 4: Verify the four new URLs are in place**
+
+Run: `grep -nc "modern-python.org" README.md`
+
+Expected: `4`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: point README links at modern-python.org
+
+Replaces readthedocs.io/en/latest// with
+modern-python.org// for the three deep links (relay, dlq,
+how-it-works) and the bare-domain Documentation header link.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 7: Bump action pins in `.github/workflows/ci.yml`
+
+**Files:**
+- Modify: `.github/workflows/ci.yml` (lines 17, 18, 19, 49, 50)
+
+- [ ] **Step 1: Confirm current pin lines**
+
+Run: `grep -n "@v[0-9]" .github/workflows/ci.yml`
+
+Expected:
+```
+17: - uses: actions/checkout@v4
+18: - uses: extractions/setup-just@v2
+19: - uses: astral-sh/setup-uv@v3
+49: - uses: actions/checkout@v4
+50: - uses: astral-sh/setup-uv@v3
+```
+
+- [ ] **Step 2: Bump `actions/checkout@v4` → `@v6`**
+
+Two occurrences (lines 17 and 49). Use a `replace_all` for the exact string `actions/checkout@v4` → `actions/checkout@v6`.
+
+- [ ] **Step 3: Bump `extractions/setup-just@v2` → `@v4`**
+
+One occurrence (line 18 — pytest job doesn't use just). Replace exact string `extractions/setup-just@v2` → `extractions/setup-just@v4`.
+
+- [ ] **Step 4: Bump `astral-sh/setup-uv@v3` → `@v8.2.0`**
+
+Two occurrences (lines 19 and 50). Use `replace_all` for `astral-sh/setup-uv@v3` → `astral-sh/setup-uv@v8.2.0`.
+
+- [ ] **Step 5: Verify all five pins updated**
+
+Run: `grep -n "@v[0-9]" .github/workflows/ci.yml`
+
+Expected:
+```
+17: - uses: actions/checkout@v6
+18: - uses: extractions/setup-just@v4
+19: - uses: astral-sh/setup-uv@v8.2.0
+49: - uses: actions/checkout@v6
+50: - uses: astral-sh/setup-uv@v8.2.0
+```
+
+Also verify no stale pins linger:
+
+```bash
+grep -E "@v[234]\b" .github/workflows/ci.yml
+```
+
+Expected: no output (exit 1).
+
+- [ ] **Step 6: Verify YAML still parses**
+
+Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('OK')"`
+
+Expected: `OK`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add .github/workflows/ci.yml
+git commit -m "ci: bump action pins in ci.yml to drop Node.js 20
+
+actions/checkout v4 -> v6
+extractions/setup-just v2 -> v4
+astral-sh/setup-uv v3 -> v8.2.0
+
+Matches the baseline modern-di already runs.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 8: Bump action pins in `.github/workflows/publish.yml`
+
+**Files:**
+- Modify: `.github/workflows/publish.yml` (lines 12, 13, 14)
+
+- [ ] **Step 1: Confirm current pin lines**
+
+Run: `grep -n "@v[0-9]" .github/workflows/publish.yml`
+
+Expected:
+```
+12: - uses: actions/checkout@v4
+13: - uses: extractions/setup-just@v2
+14: - uses: astral-sh/setup-uv@v3
+```
+
+- [ ] **Step 2: Apply the three bumps**
+
+Three single-occurrence string replacements:
+
+- `actions/checkout@v4` → `actions/checkout@v6`
+- `extractions/setup-just@v2` → `extractions/setup-just@v4`
+- `astral-sh/setup-uv@v3` → `astral-sh/setup-uv@v8.2.0`
+
+- [ ] **Step 3: Verify**
+
+Run: `grep -n "@v[0-9]" .github/workflows/publish.yml`
+
+Expected:
+```
+12: - uses: actions/checkout@v6
+13: - uses: extractions/setup-just@v4
+14: - uses: astral-sh/setup-uv@v8.2.0
+```
+
+And: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/publish.yml')); print('OK')"` → `OK`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add .github/workflows/publish.yml
+git commit -m "ci: bump action pins in publish.yml to drop Node.js 20
+
+Matches the pins applied to ci.yml and docs.yml in this branch.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Final verification
+
+- [ ] **Step 1: Run `just lint-ci` to catch formatting drift**
+
+Run: `just lint-ci`
+
+Expected: all four sub-steps pass (`eof-fixer`, `ruff format --check`, `ruff check --no-fix`, `ty check`). If `eof-fixer` flags any of the new files, run `just lint` once, then `git add` the fix into the appropriate prior commit via `git commit --amend` — or, if multiple files are touched, fold them into a single `chore: eof-fixer cleanup` commit at the end.
+
+- [ ] **Step 2: Verify the full diff against `main`**
+
+Run: `git log --oneline main..HEAD && git diff --stat main..HEAD`
+
+Expected: 8 commits (Tasks 1–8). The `--stat` summary should touch exactly these paths:
+
+```
+.github/workflows/ci.yml
+.github/workflows/docs.yml
+.github/workflows/publish.yml
+.readthedocs.yaml
+Justfile
+README.md
+docs/CNAME
+mkdocs.yml
+```
+
+(8 entries; `.readthedocs.yaml` shows as a deletion.)
+
+- [ ] **Step 3: One final mkdocs strict build**
+
+Run: `uvx --with-requirements docs/requirements.txt mkdocs build --strict --site-dir /tmp/mkdocs-final && ls /tmp/mkdocs-final/CNAME && grep -q 'modern-python.org' /tmp/mkdocs-final/index.html && echo OK && rm -rf /tmp/mkdocs-final`
+
+Expected: prints `OK`. (Verifies: build is strict-clean, `CNAME` lands in the output, and the canonical URL points at the new domain.)
+
+- [ ] **Step 4: Push the branch**
+
+```bash
+git push -u origin feat/mkdocs-github-pages
+```
+
+Expected: branch tracks `origin/feat/mkdocs-github-pages`.
+
+- [ ] **Step 5: Open the PR**
+
+```bash
+gh pr create --title "Migrate docs hosting from Read the Docs to GitHub Pages" --body "$(cat <<'EOF'
+## Summary
+
+- Adds `.github/workflows/docs.yml` mirroring `modern-di`'s pattern: `mkdocs gh-deploy --force` on push to `main` (paths-filtered), concurrency-serialized, `contents: write`.
+- Adds `just docs-deploy` target driving the deploy via `uvx --with-requirements docs/requirements.txt mkdocs gh-deploy --force`.
+- Adds `docs/CNAME` and `mkdocs.yml` `site_url` for the custom domain `faststream-outbox.modern-python.org`.
+- Deletes `.readthedocs.yaml`; updates the four `readthedocs.io` links in `README.md` to `modern-python.org` equivalents.
+- Bumps action pins in `ci.yml` and `publish.yml` to the same baseline the new `docs.yml` uses (`checkout@v6`, `setup-just@v4`, `setup-uv@v8.2.0`).
+
+Spec: `planning/specs/2026-06-09-mkdocs-github-pages-design.md`
+
+## Out-of-repo steps after merge
+
+1. DNS: CNAME `faststream-outbox.modern-python.org` → `modern-python.github.io`.
+2. After the first workflow run creates `gh-pages`, set Settings → Pages → Source = `gh-pages` branch, `/` root.
+3. Tick "Enforce HTTPS" once the cert provisions (~5 min).
+4. Archive the `faststream-outbox` project on readthedocs.io.
+
+## Test plan
+
+- [ ] PR CI is green (`ci.yml` lint + pytest jobs run with bumped pins).
+- [ ] After merge, the `Deploy Docs` workflow runs and creates the `gh-pages` branch.
+- [ ] `https://faststream-outbox.modern-python.org/` returns the docs site once DNS + Pages settings are in place.
+- [ ] All four `README.md` links resolve to working pages on the new domain.
+- [ ] A subsequent push to `main` touching `docs/` re-deploys.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+Expected: prints the PR URL.
+
+---
+
+## Self-review
+
+**Spec coverage:**
+
+| Spec section | Plan task |
+| --- | --- |
+| 1. `docs.yml` workflow | Task 4 |
+| 2. `just docs-deploy` | Task 1 |
+| 3. `docs/CNAME` | Task 2 |
+| 4. `mkdocs.yml` `site_url` | Task 3 |
+| 5. Delete `.readthedocs.yaml` | Task 5 |
+| 6. README URL updates | Task 6 |
+| 7. `ci.yml` pin bumps | Task 7 |
+| 8. `publish.yml` pin bumps | Task 8 |
+| Operations (out of repo) | PR description (Step 5 of Final verification) |
+| Sequencing / rollback | Implicit — all 8 land as one PR; Tasks 1–4 add the new path, Task 5 removes RTD config, Task 6 swaps README links, Tasks 7–8 update pins. Reverting the PR restores RTD + old pins. |
+
+All eight spec changes have a dedicated task; out-of-repo ops are surfaced in the PR body so the merging maintainer sees them.
+
+**Placeholder check:** No TBDs, no "implement later", every code/YAML block is complete, every command has expected output.
+
+**Consistency check:**
+- `docs-deploy` Justfile target name matches across Task 1, Task 4 (the workflow's `run: just docs-deploy` line), and the spec.
+- Action pin versions match across Tasks 4, 7, 8 (`checkout@v6`, `setup-just@v4`, `setup-uv@v8.2.0`).
+- The domain string `faststream-outbox.modern-python.org` matches across Tasks 2, 3, 6, and the PR body.
diff --git a/planning/specs/2026-06-09-mkdocs-github-pages-design.md b/planning/specs/2026-06-09-mkdocs-github-pages-design.md
new file mode 100644
index 0000000..d32652b
--- /dev/null
+++ b/planning/specs/2026-06-09-mkdocs-github-pages-design.md
@@ -0,0 +1,269 @@
+# Design: Migrate docs hosting from Read the Docs to GitHub Pages
+
+**Date:** 2026-06-09
+**Status:** Approved
+**Slug:** `mkdocs-github-pages`
+
+## Summary
+
+Move `faststream-outbox` docs hosting off Read the Docs and onto GitHub
+Pages, mirroring the migration the sister `modern-di` project completed in
+commit `61b9377`. Docs build via a new dedicated GH Actions workflow that
+runs `mkdocs gh-deploy` on push to `main`, served at the custom domain
+`faststream-outbox.modern-python.org`. Pin bumps for the two existing
+workflows (`ci.yml`, `publish.yml`) come along for the ride so all three
+workflows share the same action versions on landing.
+
+No runtime code, test code, or public API touched.
+
+## Motivation
+
+- **Operational simplicity.** Read the Docs requires a separate account
+ with its own settings, webhooks, and quirks (Python 3.10 pin in
+ `.readthedocs.yaml`, separate build infrastructure). GH Pages keeps the
+ docs pipeline in the same repo as code review.
+- **Symmetry with `modern-di`.** The two repos already share Justfile
+ layout, lint pipeline, mkdocs theme, and PyPI publish flow. Sharing
+ docs hosting closes the last operational gap.
+- **Action-version drift.** `ci.yml` and `publish.yml` pin `checkout@v4`,
+ `setup-just@v2`, `setup-uv@v3`. `modern-di` already bumped to `@v6`,
+ `@v4`, `@v8.2.0` (per its commit `4f1945c` — "drop Node.js 20"). Doing
+ the bump alongside the docs workflow keeps all three workflows on the
+ same baseline; landing it later would require a one-line follow-up PR.
+
+## Design
+
+### 1. New workflow `.github/workflows/docs.yml`
+
+Near-verbatim copy of `modern-di`'s `docs.yml`:
+
+```yaml
+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@v6
+ with:
+ fetch-depth: 0
+ - uses: extractions/setup-just@v4
+ - uses: astral-sh/setup-uv@v8.2.0
+ - run: just docs-deploy
+```
+
+Load-bearing pieces:
+
+- **Paths filter.** Workflow fires only when docs sources, mkdocs config,
+ or the workflow itself change. CI runs that only touch code don't
+ trigger an unnecessary deploy.
+- **`workflow_dispatch`.** Lets a maintainer force a redeploy from the
+ Actions UI if the live site falls out of sync (e.g. after a manual
+ `gh-pages` branch touch).
+- **`concurrency: docs-deploy`, `cancel-in-progress: true`.** Serializes
+ deploys. A rapid sequence of merges to `main` doesn't race
+ force-pushes against the `gh-pages` branch.
+- **`permissions: contents: write`.** Default `GITHUB_TOKEN` scope is
+ read-only; `mkdocs gh-deploy` needs write to push the `gh-pages`
+ branch.
+- **`fetch-depth: 0`.** `mkdocs gh-deploy` reads git history to construct
+ the deploy commit; a shallow checkout breaks the push.
+
+### 2. Add `just docs-deploy` target
+
+Append to `Justfile`:
+
+```just
+# 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
+```
+
+Notes:
+
+- `uvx --with-requirements docs/requirements.txt` runs mkdocs in an
+ ephemeral environment derived from `docs/requirements.txt`
+ (`mkdocs`, `mkdocs-material`). The project's `.venv` (Docker-managed)
+ is not involved.
+- `--force` matches `modern-di`. `gh-deploy` builds the site, commits it
+ to a synthetic `gh-pages` branch, and force-pushes. The comment in
+ the target body documents the "stale checkout rolls live back" gotcha
+ so a contributor reading the file sees it before invoking locally.
+
+### 3. New file `docs/CNAME`
+
+One line:
+
+```
+faststream-outbox.modern-python.org
+```
+
+mkdocs material copies any file under `docs/` into the build output, so
+the `CNAME` survives every `gh-deploy --force` push. Without this file,
+the force-push would wipe the custom domain on every deploy and GH Pages
+would revert to the project-page URL.
+
+### 4. Update `mkdocs.yml`
+
+Add a `site_url:` line near the top, alongside `site_name:` / `repo_url:`:
+
+```yaml
+site_url: https://faststream-outbox.modern-python.org/
+```
+
+Used by mkdocs for:
+
+- Canonical link tags in every HTML page (SEO).
+- The generated `sitemap.xml`.
+- Material theme social cards (if enabled later).
+
+Currently absent from `mkdocs.yml` — generated pages have no canonical
+URL. This is independently worth doing; bundling it with the migration
+saves a follow-up.
+
+### 5. Delete `.readthedocs.yaml`
+
+Removes the RTD build configuration. After this lands and the new URL is
+live, the RTD project at `faststream-outbox.readthedocs.io` should be
+archived or deleted in the RTD UI (out-of-repo step; see
+"Operations" below).
+
+### 6. Update `README.md` URLs
+
+Four RTD links replaced with their `modern-python.org` equivalents:
+
+- Three deep links of the form
+ `https://faststream-outbox.readthedocs.io/en/latest//` →
+ `https://faststream-outbox.modern-python.org//`. The `/en/latest/`
+ segment is RTD's version/locale routing; GH Pages doesn't use it.
+ Targets: relay tutorial, dead-letter queue, "How it works".
+- One bare-domain link
+ `https://faststream-outbox.readthedocs.io` →
+ `https://faststream-outbox.modern-python.org` (the top-level
+ `## 📚 Documentation` header link).
+
+### 7. Bump action pins in `.github/workflows/ci.yml`
+
+Three pin upgrades, no structural changes:
+
+| Old | New | Occurrences |
+| --- | --- | --- |
+| `actions/checkout@v4` | `actions/checkout@v6` | 2 (one per job) |
+| `extractions/setup-just@v2` | `extractions/setup-just@v4` | 1 (lint job only) |
+| `astral-sh/setup-uv@v3` | `astral-sh/setup-uv@v8.2.0` | 2 (one per job) |
+
+`enable-cache: true` and `cache-dependency-glob: "**/pyproject.toml"`
+both remain valid in `setup-uv@v8` — no `with:` block changes.
+
+Explicitly **not** in scope: restructuring into a reusable
+`_checks.yml` workflow (modern-di's pattern). That's a separate refactor.
+
+### 8. Bump action pins in `.github/workflows/publish.yml`
+
+Same three upgrades, one occurrence each (single job).
+
+## Operations (out of repo)
+
+These steps cannot live in the spec/commit; they need a maintainer with
+admin access:
+
+1. **DNS.** Add a CNAME record:
+ - Host: `faststream-outbox`
+ - Target: `modern-python.github.io`
+ - TTL: 1 hour (GitHub validates within minutes; short TTL is harmless).
+2. **Trigger first build.** Either merge the PR (push to `main` runs the
+ workflow) or run `workflow_dispatch` from the Actions UI. The first
+ run creates the `gh-pages` branch.
+3. **GH Pages source.** After the `gh-pages` branch exists:
+ Settings → Pages → Source = "Deploy from a branch", branch
+ `gh-pages`, folder `/` (root). The custom-domain field
+ auto-populates from `docs/CNAME`.
+4. **HTTPS.** Wait ~5 minutes for GitHub to provision the Let's Encrypt
+ cert, then tick "Enforce HTTPS".
+5. **Verify.** Hit
+ `https://faststream-outbox.modern-python.org/` and confirm the
+ relay/DLQ/how-it-works pages load.
+6. **RTD teardown.** In the Read the Docs UI, archive or delete the
+ `faststream-outbox` project. The old `readthedocs.io` URL will
+ start returning 404; README links no longer point there, so user
+ breakage is bounded to external links / search-engine results
+ (which migrate over time).
+
+## Sequencing and rollback
+
+The workflow can land and run before DNS resolves. GH Pages serves at
+`modern-python.github.io/faststream-outbox/` in the interim (with a
+"custom domain not configured" notice in the Pages settings, harmless).
+RTD continues to serve the old URL until step 6 above, so the docs are
+never simultaneously down.
+
+Rollback path if something breaks mid-migration:
+
+- Revert the PR. RTD is still building (`.readthedocs.yaml` removal is
+ part of the same PR), so reverting re-enables the RTD pipeline. The
+ README points back at `readthedocs.io` after revert.
+- The `gh-pages` branch can be left in place; deleting it requires no
+ follow-up. GH Pages settings can be reset to "Source = None".
+
+## Out of scope (deliberate)
+
+- **PR-time docs build check.** `modern-di` doesn't have one and the
+ symmetry is the whole point. Broken mkdocs config still fails the
+ deploy workflow on `main` — visible quickly.
+- **Reusable `_checks.yml` workflow split.** Modern-di's structural
+ pattern; worth doing separately, not load-bearing for the docs move.
+- **Theme / plugin additions.** No mkdocs plugins added (no
+ `mike` for versioning, no social cards, no privacy plugin). The
+ current setup is intentionally minimal; expansion is a separate
+ conversation.
+- **Old-URL redirects.** GH Pages can't gracefully redirect from
+ `readthedocs.io`; RTD's URL behavior after archival is on RTD's side.
+ Accepted as a one-time bookmark/search-engine adjustment cost.
+
+## Testing
+
+The spec changes are configuration; correctness is observable on the
+live site:
+
+- The first deploy workflow run completes green and creates the
+ `gh-pages` branch (visible in Actions UI + branch list).
+- After DNS resolves and Pages config flips, the live URL returns the
+ expected docs (nav identical to current RTD site, theme intact, search
+ works).
+- A second push to `main` that touches a doc page (or
+ `workflow_dispatch`) re-runs the workflow and updates the live site.
+
+No new pytest / lint hooks are added. The existing `just lint-ci` run on
+PRs already validates YAML formatting via ruff's `EOF` fixer and
+formatters.
+
+## Risk
+
+- **DNS misconfiguration** → custom domain doesn't resolve. Mitigated
+ by sequencing: workflow lands first and serves at the GH project URL;
+ DNS is fixed before README links are pointed at the new domain (or
+ the PR is split if needed).
+- **Action pin upgrade regressions** → CI breaks for unrelated reasons.
+ Low risk: modern-di runs the same pins green; if `setup-uv@v8` does
+ drift, the fix is a one-line pin and rollback is trivial.
+- **`docs-deploy` force-push from a stale clone** → site rolls back.
+ Mitigated by the comment on the `just docs-deploy` target plus the
+ fact that the canonical deploy is from CI; manual invocation is
+ documented as a debugging escape hatch, not a workflow.