diff --git a/.github/workflows/e2e-blog-loop.yml b/.github/workflows/e2e-blog-loop.yml
new file mode 100644
index 00000000..2e10e5d3
--- /dev/null
+++ b/.github/workflows/e2e-blog-loop.yml
@@ -0,0 +1,168 @@
+name: E2E Blog Loop (canary)
+
+# Full "write a blog post" canary. Sibling to e2e-smoke.yml — runs the
+# tools/e2e/tests/full-blog-loop.spec.ts journey end-to-end (login →
+# author → publish → log out → assert public render → assert SEO).
+#
+# Advisory initially (continue-on-error: true). Promotion to a
+# required check happens once the journey lands three consecutive
+# green PRs without manual reruns — the gate flip is a one-line
+# workflow edit and a branch-protection update.
+#
+# Triggered on PRs touching the apps or shared packages, since those
+# are the moving parts of the publish loop. Changes scoped purely to
+# docs, ops scripts, or CLI surfaces don't run this workflow.
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'apps/**'
+ - 'packages/**'
+ - 'tools/e2e/**'
+ - '.github/workflows/e2e-blog-loop.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ blog-loop:
+ name: e2e-blog-loop
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ # Advisory: the job runs to completion and reports; a failure
+ # does not gate the PR until the canary is promoted. Once we see
+ # three consecutive green runs on unrelated PRs, drop this line
+ # and require the check in branch protection.
+ continue-on-error: true
+
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: gonext
+ POSTGRES_PASSWORD: gonext_dev_only
+ POSTGRES_DB: gonext_dev
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U gonext -d gonext_dev"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.work'
+ cache: true
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'pnpm'
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9.15.0
+
+ - name: Install JS deps
+ run: pnpm install --frozen-lockfile
+
+ - name: Install e2e deps
+ # tools/e2e is outside the pnpm workspace (see #241) so we
+ # install it independently rather than relying on a
+ # workspace filter.
+ working-directory: tools/e2e
+ run: pnpm install
+
+ - name: Install Playwright browsers
+ working-directory: tools/e2e
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Install psql client
+ # `freshDatabase()` uses psql to TRUNCATE state between
+ # tests. Without it the helper falls back to the dev-only
+ # REST reset endpoint, which is fine but less direct.
+ run: sudo apt-get update && sudo apt-get install -y postgresql-client
+
+ - name: Build the stack
+ # `make up` requires docker compose; on the GitHub-hosted
+ # runner we boot the API + admin + web processes directly
+ # against the Postgres/Redis services declared above. This
+ # is the minimal slice the canary needs.
+ run: |
+ make build-go
+ (cd apps/api && ./api &) >/tmp/api.log 2>&1
+ (cd apps/admin && pnpm dev &) >/tmp/admin.log 2>&1
+ (cd apps/web && pnpm dev &) >/tmp/web.log 2>&1
+ env:
+ DATABASE_URL: postgres://gonext:gonext_dev_only@localhost:5432/gonext_dev?sslmode=disable
+ REDIS_URL: redis://localhost:6379
+ GONEXT_DEV_RESET: '1'
+
+ - name: Wait for the stack
+ run: |
+ for i in {1..30}; do
+ if curl -sf http://localhost:8080/healthz && curl -sf http://localhost:3000; then
+ echo "stack up"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "stack did not come up in time"
+ tail -n +1 /tmp/api.log /tmp/admin.log /tmp/web.log || true
+ exit 1
+
+ - name: Run blog loop
+ working-directory: tools/e2e
+ env:
+ CI: 'true'
+ E2E_FRESH_INSTALL: '1'
+ E2E_ALLOW_DESTRUCTIVE: '1'
+ E2E_BASE_URL: http://localhost:3000
+ E2E_API_BASE_URL: http://localhost:8080
+ E2E_PG_HOST: localhost
+ E2E_PG_PORT: '5432'
+ E2E_PG_USER: gonext
+ E2E_PG_PASSWORD: gonext_dev_only
+ E2E_PG_DATABASE: gonext_dev
+ run: pnpm exec playwright test tests/full-blog-loop.spec.ts --project=chromium
+
+ - name: Upload Playwright report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-blog-loop
+ path: tools/e2e/playwright-report
+ retention-days: 14
+
+ - name: Upload stack logs
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: stack-logs-blog-loop
+ path: |
+ /tmp/api.log
+ /tmp/admin.log
+ /tmp/web.log
+ retention-days: 7
diff --git a/Makefile b/Makefile
index 4a478c85..5083fc7d 100644
--- a/Makefile
+++ b/Makefile
@@ -94,6 +94,15 @@ e2e-smoke: ## Run the fresh-install happy-path smoke against a running stack.
@# stray invocation can't nuke a real one.
@cd tools/e2e && E2E_ALLOW_DESTRUCTIVE=1 pnpm run e2e:smoke
+.PHONY: e2e-blog-loop
+e2e-blog-loop: ## Run the full "write a blog post" canary against a running stack.
+ @echo "==> Running e2e blog loop (tools/e2e/tests/full-blog-loop.spec.ts)"
+ @# Same destructive guard as e2e-smoke: the spec depends on
+ @# globalSetup running gonext init, which TRUNCATEs the e2e
+ @# database. The E2E_ALLOW_DESTRUCTIVE flag is the failsafe.
+ @cd tools/e2e && E2E_FRESH_INSTALL=1 E2E_ALLOW_DESTRUCTIVE=1 \
+ pnpm exec playwright test tests/full-blog-loop.spec.ts --project=chromium
+
# ---------------------------------------------------------------------------
# Lint
diff --git a/docs/19-e2e-testing.md b/docs/19-e2e-testing.md
index ce43a391..e85152ee 100644
--- a/docs/19-e2e-testing.md
+++ b/docs/19-e2e-testing.md
@@ -111,3 +111,65 @@ red smoke does not block merges. Promotion to a required check
happens once the journey lands three consecutive green PRs without
manual reruns; the gate flip is a one-line workflow edit and a
branch-protection update.
+
+## 19.6 The full blog-loop canary
+
+A second, longer-running journey lives in
+`tools/e2e/tests/full-blog-loop.spec.ts`. It is the **canary**: the
+single test that, if green, proves the publish loop works end to
+end. It is intentionally separate from `install-and-publish.spec.ts`
+so the two CI checks fail or pass independently — a regression in
+the smoke does not pull the canary off-line, and vice versa.
+
+Differences from the smoke:
+
+- The canary captures the published slug from the success
+ notification, the status banner, *or* a fallback anchor scrape,
+ rather than from a single selector. This makes it more resilient
+ to UI churn around the publish flow.
+- The canary asserts the brand's italic-accent rule on the public
+ h1: when the editor stores an emphasis, the rendered `
` must
+ contain an ``. The assertion is lenient (`` is optional
+ if the editor didn't produce one) but strict on the public
+ render side when it is produced.
+- The canary inserts three list items rather than two, which
+ exercises the list block's Enter-driven item splitting one extra
+ time and catches off-by-one bugs in the list serializer.
+- The canary logs out via `context.clearCookies()` rather than the
+ UI logout flow, decoupling the public-render assertion from any
+ churn in the logout affordance.
+
+### Running locally
+
+```bash
+make up # bring the stack up
+make e2e-blog-loop # runs the canary against it
+```
+
+The `make e2e-blog-loop` target sets `E2E_FRESH_INSTALL=1` *and*
+`E2E_ALLOW_DESTRUCTIVE=1` for you. It targets only the
+chromium project so a single local run gives a fast verdict;
+flip the `--project=` flag if you want to sweep WebKit + Firefox.
+
+### CI
+
+`.github/workflows/e2e-blog-loop.yml` runs on every PR touching
+`apps/**` or `packages/**`. Like the smoke, it is **advisory** on
+landing (`continue-on-error: true`). Promotion rules are the same:
+three consecutive greens without manual reruns and the gate flips
+to required.
+
+### Why both?
+
+Both specs exercise the same conceptual loop, but they serve
+different roles in CI:
+
+| Spec | Role |
+| --------------------------------- | ----------------------------------------- |
+| `install-and-publish.spec.ts` | Architectural skeleton — the scaffold that proves the harness wiring works. |
+| `full-blog-loop.spec.ts` | Canary — the single signal we watch to know the platform works as a CMS. |
+
+If the smoke breaks but the canary stays green, the harness or
+fixtures have regressed but the product is fine. If the canary
+breaks, the product has regressed and we know exactly which step
+of the publish loop is failing.
diff --git a/tools/e2e/tests/full-blog-loop.spec.ts b/tools/e2e/tests/full-blog-loop.spec.ts
new file mode 100644
index 00000000..8aff204a
--- /dev/null
+++ b/tools/e2e/tests/full-blog-loop.spec.ts
@@ -0,0 +1,398 @@
+/**
+ * Full blog loop — the canary for "GoNext WORKS as a CMS".
+ *
+ * Sibling to `install-and-publish.spec.ts` (the PR #424 skeleton).
+ * That spec was authored before routes were mounted — this one is
+ * the *real* exercise of the publish loop. Both can live side by
+ * side: install-and-publish is the architectural skeleton, and
+ * full-blog-loop is the canary the platform team watches.
+ *
+ * Why a second file rather than editing the original? Two reasons:
+ *
+ * 1. Independence. The original landed in #424 as a scaffold and
+ * grew its own callers (docs, CI artefacts). Renaming it would
+ * break those references; rewriting it would lose the original
+ * git blame on the scaffolding decisions. A new spec keeps
+ * both intact.
+ *
+ * 2. CI separation. The original is gated by `pnpm e2e:smoke`
+ * and `.github/workflows/e2e-smoke.yml`. This canary gets its
+ * own `make e2e-blog-loop` and `.github/workflows/e2e-blog-loop.yml`,
+ * so a regression in one path doesn't pull the other off-line
+ * while we triage.
+ *
+ * Journey (one ordered test; `test.step` blocks for trace clarity):
+ *
+ * 1. Log in via `/login`. Assert dashboard URL + sidebar visible.
+ * 2. Navigate to `/posts/new`. Assert the editor mounts.
+ * 3. Type the title "Living systems, *living* posts." into the
+ * title field. Assert the italic-accent renders.
+ * 4. Insert three blocks in the canvas (paragraph, heading, list).
+ * 5. Open the Document tab, set status to Publish, click Publish.
+ * 6. Capture the published slug from the success notification or
+ * the resulting URL.
+ * 7. Log out — `context.clearCookies()` is faster than the UI flow.
+ * 8. Visit `/` on the public site. Assert the title
+ * appears in an `
` and all three blocks render.
+ * 9. Assert canonical ``, `og:title`, and `og:description`
+ * meta tags are present and match.
+ *
+ * If any of these fail, a fresh self-hosted GoNext install can't
+ * deliver the one experience the project exists to deliver. That's
+ * the contract this spec defends.
+ */
+
+import { test, expect } from '../fixtures/server';
+import { DEFAULT_INIT_ARGS, loginAs } from '../lib/test-helpers';
+
+// Title is intentionally written with an inline emphasis token so we
+// can exercise the brand's "italic accent" Headline primitive. The
+// `` wraps just the word `living` (case-insensitive match) so
+// the asserted DOM mirrors how an author would actually mark the
+// emphasis.
+const POST_TITLE_PLAIN = 'Living systems, living posts.';
+const POST_TITLE_EMPHASIS = 'living';
+const PARAGRAPH_BODY = 'This is a test post written by Playwright.';
+const HEADING_TEXT = 'A heading block';
+const LIST_ITEMS = ['First item', 'Second item', 'Third item'] as const;
+
+test.describe('full blog loop (canary)', () => {
+ // The whole journey is one ordered test. Each step depends on
+ // every previous step succeeding; splitting them up would force
+ // us to maintain cross-test state, which is the opposite of what
+ // we want from a canary. `test.step()` gives us collapsible
+ // sub-blocks in the trace report.
+ test('publish a post end-to-end and verify it renders publicly', async ({
+ page,
+ context,
+ serverRequest,
+ baseURL,
+ }) => {
+ test.setTimeout(180_000);
+
+ // Captured between steps. Initialised here so TypeScript treats
+ // the binding as definitely assigned by the public-render step.
+ let slug = '';
+
+ await test.step('step 1 — log in via /login', async () => {
+ await page.goto(`${baseURL}/login`);
+
+ // The login form uses shadcn Label + Input controls
+ // (apps/admin/src/app/(public)/login/page.tsx). `getByLabel`
+ // works against the explicit `