krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

bbe31403db53cf5311232c64e95ce0b98b8713dd

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T20:48:13Z

0.18: make the version number worth reading

Everything here is about what happens *after* the code is right, which until now
was undefined: there was a `license = "MIT"` with no LICENSE file, no changelog,
no statement of what a version promises, and no build anywhere but this laptop.

- **A compatibility promise**, in the README and in the guide. Config keys,
  template variables, CLI flags and URLs are the stable surface — URLs
  deliberately, because a generator that moves your pages breaks every link
  anyone has to you. The incremental cache, rendered HTML details and the Rust
  API are explicitly not, so that the rest can hold still. HTML changes because
  it tracks Emacs; that is the product, and it gets called out in the changelog
  each time.

- **CI on Linux and macOS**: build, test, clippy as an error, and the docs site
  built with `--strict`. Emacs is installed on both runners so the oracle suite
  runs for real. Deliberately not gated on `cargo fmt` — the source is formatted
  by hand and rustfmt disagrees with most of it.

- **A checked MSRV.** Set to 1.88. I first wrote 1.82, from the newest std API
  in this crate, and the dependency floors said otherwise: `plist` and `time`,
  by way of syntect, both declare 1.88. That is the whole argument for checking
  it in CI rather than reasoning about it.

- **Release binaries** for macOS (arm64, x86_64) and Linux (gnu, musl), built on
  tag into a *draft* release — a release that publishes itself before anyone has
  read it cannot be edited quietly. The tag is checked against Cargo.toml first.

- A LICENSE file, crates.io metadata, and a release profile that ships 5.0 MB
  instead of 6.5 MB. `cargo package` verifies clean.

`repository` is deliberately left commented out in Cargo.toml: a wrong URL on a
crates.io page is worse than none, and this repository has no remote yet.
 .github/workflows/ci.yml      | 104 ++++++++++++++++++++++++++++
 .github/workflows/release.yml | 113 ++++++++++++++++++++++++++++++
 CHANGELOG.md                  | 156 ++++++++++++++++++++++++++++++++++++++++++
 Cargo.lock                    |   2 +-
 Cargo.toml                    |  26 ++++++-
 LICENSE                       |  21 ++++++
 README.md                     |  30 +++++++-
 RELEASING.md                  |  78 +++++++++++++++++++++
 docs/guide/10-deploying.org   |   7 ++
 docs/guide/11-versioning.org  |  72 +++++++++++++++++++
 10 files changed, 605 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a6e3e7a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,104 @@
+name: CI
+
+# Build, test and lint on both platforms org-ssg is used from, plus a compiler-floor job.
+#
+# WHAT THIS CATCHES THAT LOCAL WORK DOES NOT:
+#
+#   1. Linux. Development happens on macOS, and the two differ where this project is most
+#      likely to break: filesystem event paths (the watcher had a real `/var` vs
+#      `/private/var` bug on macOS), case-insensitive filenames, and path separators.
+#   2. A clean checkout. The oracle tests skip when Emacs is absent and the cache is
+#      gitignored, so a machine that has been building all afternoon is not a fair test of
+#      what a fresh clone does.
+#   3. The MSRV. A stabilised API used without noticing is invisible on a current
+#      toolchain and is a build failure for anyone on a distribution compiler.
+#
+# NOT GATED ON `cargo fmt`. The source is formatted by hand — comment tables, aligned
+# match arms, and prose wrapped to fit the argument being made — and rustfmt disagrees
+# with most of it. Clippy is the lint that catches defects; fmt would only catch taste.
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+  workflow_dispatch:
+
+env:
+  CARGO_TERM_COLOR: always
+  # A failing build should print the error, not a backtrace-shaped wall.
+  RUST_BACKTRACE: 1
+
+jobs:
+  test:
+    name: test (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      # Both platforms report, so a macOS-only failure is distinguishable from a real one.
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+    steps:
+      - name: Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+      - name: Install Rust
+        uses: dtolnay/rust-toolchain@1ff72ee08e3cb84d84adba594e0a297990fc1ed3 # stable
+        with:
+          toolchain: stable
+          components: clippy
+
+      - name: Cache cargo
+        uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+
+      # Emacs makes the oracle suite run for real instead of skipping. It is the only
+      # reason to trust that output still matches org's own exporter, so it is worth the
+      # install minute.
+      - name: Install Emacs (Linux)
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends emacs-nox
+
+      - name: Install Emacs (macOS)
+        if: runner.os == 'macOS'
+        run: brew install emacs
+
+      - name: Build
+        run: cargo build --all-targets --locked
+
+      - name: Test
+        run: cargo test --locked
+
+      - name: Clippy
+        run: cargo clippy --all-targets --locked -- -D warnings
+
+      # The documentation site is built by the tool it documents, so a docs page that no
+      # longer builds is a product defect. `--strict` fails on broken internal links,
+      # which is the failure mode a docs site actually has.
+      - name: Build the documentation site
+        run: cargo run --locked -- build docs -o docs/_site --strict
+
+  msrv:
+    name: minimum supported Rust (1.88)
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+      # Pinned to the version in Cargo.toml's `rust-version`. When that moves, this moves
+      # with it in the same commit — a floor nobody checks is a floor nobody has.
+      - name: Install Rust 1.88
+        uses: dtolnay/rust-toolchain@1ff72ee08e3cb84d84adba594e0a297990fc1ed3 # stable
+        with:
+          toolchain: "1.88"
+
+      - name: Cache cargo
+        uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
+
+      # Build only. The tests pull in dev-dependencies whose own floors move
+      # independently, and chasing those would make this job about someone else's MSRV.
+      #
+      # The floor is set by dependencies rather than by org-ssg — its own code compiles on
+      # 1.82 — which is precisely why it is checked here instead of reasoned about: a
+      # dependency raising its floor is invisible until someone on an older compiler
+      # tries to build.
+      - name: Build
+        run: cargo build --locked
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..71c006b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,113 @@
+name: Release
+
+# Build binaries for a tag and attach them to a GitHub release.
+#
+# WHY BINARIES AT ALL, given `cargo install org-ssg` exists: installing from source needs
+# a Rust toolchain and about a minute of compiling syntect. Someone evaluating a site
+# generator should be able to download one file and point it at their notes.
+#
+# The tag is the source of truth for the version. The build checks it against Cargo.toml
+# rather than trusting them to match, because a release tagged v0.18.0 containing a binary
+# that reports 0.17.0 is the kind of thing nobody notices for months.
+
+on:
+  push:
+    tags: ["v*"]
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: "Tag to build (for a re-run of a failed release)"
+        required: true
+
+env:
+  CARGO_TERM_COLOR: always
+
+jobs:
+  build:
+    name: ${{ matrix.target }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          # Apple Silicon and Intel Macs, built natively on their own runners so neither
+          # is a cross-compile nobody has run.
+          - { os: macos-latest, target: aarch64-apple-darwin }
+          - { os: macos-13, target: x86_64-apple-darwin }
+          # glibc for ordinary distributions, musl for containers and anything older than
+          # the runner's glibc — a dynamically linked binary is the usual reason a
+          # download does not run.
+          - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
+          - { os: ubuntu-latest, target: x86_64-unknown-linux-musl }
+    steps:
+      - name: Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          ref: ${{ github.event.inputs.tag || github.ref }}
+
+      - name: Install Rust
+        uses: dtolnay/rust-toolchain@1ff72ee08e3cb84d84adba594e0a297990fc1ed3 # stable
+        with:
+          toolchain: stable
+          targets: ${{ matrix.target }}
+
+      - name: Install musl tools
+        if: endsWith(matrix.target, '-musl')
+        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
+
+      - name: Check the tag against Cargo.toml
+        shell: bash
+        run: |
+          tag="${{ github.event.inputs.tag || github.ref_name }}"
+          crate=$(cargo metadata --no-deps --format-version 1 \
+            | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])')
+          if [ "$tag" != "v$crate" ]; then
+            echo "tag $tag does not match Cargo.toml version $crate" >&2
+            exit 1
+          fi
+
+      - name: Build
+        run: cargo build --release --locked --target ${{ matrix.target }}
+
+      # A tarball rather than a bare binary: it keeps the executable bit through GitHub's
+      # download path, and carries the licence with the thing it licenses.
+      - name: Package
+        shell: bash
+        run: |
+          staging="org-ssg-${{ github.event.inputs.tag || github.ref_name }}-${{ matrix.target }}"
+          mkdir "$staging"
+          cp "target/${{ matrix.target }}/release/org-ssg" "$staging/"
+          cp README.md LICENSE CHANGELOG.md "$staging/"
+          tar czf "$staging.tar.gz" "$staging"
+          shasum -a 256 "$staging.tar.gz" > "$staging.tar.gz.sha256"
+
+      - name: Upload
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+        with:
+          name: ${{ matrix.target }}
+          path: |
+            *.tar.gz
+            *.tar.gz.sha256
+
+  release:
+    name: publish the release
+    needs: build
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - name: Download every build
+        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+        with:
+          merge-multiple: true
+
+      # A draft, deliberately. The changelog entry is written by a person, and a release
+      # that publishes itself before anyone has read it cannot be edited quietly.
+      - name: Create the draft release
+        uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
+        with:
+          draft: true
+          tag_name: ${{ github.event.inputs.tag || github.ref_name }}
+          files: |
+            *.tar.gz
+            *.tar.gz.sha256
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..dca9f6f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,156 @@
+# Changelog
+
+What changed and why, newest first. Entries name the *behaviour* that moved, since that is
+what a rebuild will show you.
+
+Two conventions worth knowing before reading:
+
+- **A cache-format bump is not a change you need to act on.** The incremental cache is
+  versioned and discards itself; a bump means the next build re-renders everything once.
+- **Output changes are called out.** org-ssg aims at what Emacs exports from the same
+  file, so an entry that says "now renders X" means your pages will change. That is the
+  product, not a regression — but it belongs in a changelog rather than a diff you find
+  later.
+
+Versions follow the compatibility promise in the README: config keys, template variables,
+CLI flags and URLs are the stable surface.
+
+## 0.18.0
+
+Release engineering, so that a version number is worth reading.
+
+- **A written compatibility promise.** Config keys, template variables, CLI flags and URLs
+  are the stable surface; the incremental cache, HTML details and the Rust API are not.
+  In the README, and in the guide under *Versioning and upgrades*.
+- **CI** on Linux and macOS: build, test, clippy as an error, and the documentation site
+  built with `--strict`. Emacs is installed on both, so the oracle suite runs for real
+  instead of skipping.
+- **A checked MSRV**, 1.88 — which is how it came to be 1.88 rather than the 1.82
+  org-ssg's own code needs. The floor comes from dependencies, and nobody finds that out
+  by reasoning about it.
+- **Release binaries** for macOS (arm64, x86_64) and Linux (gnu, musl), built on tag into
+  a draft release. The tag is checked against `Cargo.toml` before anything is built.
+- A `LICENSE` file to go with the MIT declaration, crates.io metadata, and a release
+  profile that produces a 5.0 MB binary rather than 6.5 MB.
+- This changelog, and `RELEASING.md`.
+
+## 0.17.0
+
+- **Asset directories outside the source.** `[build] assets = ["../theme/static"]` copies
+  a directory's contents to the site root. A site's static files do not always live where
+  its writing does, and copying them next to the writing is how a repository ends up with
+  two of every stylesheet. `watch` and `serve` watch these directories too. Two files
+  claiming one URL is a build error naming both.
+- **Template hashing is per template.** A page's render key covered every template, so
+  editing a feed template re-rendered the whole site. It now covers the layout the page
+  uses plus what that layout extends, includes or imports. On a 196-page site, editing the
+  feed template renders one page instead of 196.
+- Cache format 7.
+
+## 0.16.0
+
+- **Org's entity table.** `\alpha`, `\rarr`, `20\deg` and the other 412 names, generated
+  from Emacs' own `org-entities`. An unknown name stays literal; `#+OPTIONS: e:nil` turns
+  the table off. *Output changes* for any page using entities.
+- **Table captions.** `#+CAPTION:` above a table becomes a numbered `<caption>`.
+- **`#+INCLUDE:` reports itself.** It was inert and silent, which publishes a page with
+  content missing and nobody told. Now a diagnostic, and `--strict` makes it a failure.
+- The Emacs oracle separates deliberate divergence from defects. Every difference from
+  org's exporter is named and justified, and a test asserts there are no others.
+
+## 0.15.0
+
+Export parity, from a page-by-page diff of a 179-file corpus against the site Emacs
+publishes from the same sources. **All of these change output.**
+
+- Heading levels are relative to a document's shallowest heading, as org exports them.
+- Org's text conversions: `--`, `---`, `...`, and `x^2` / `a_{b}`. Never inside verbatim,
+  code, source blocks or LaTeX. `#+OPTIONS: -:nil`, `^:nil` and `^:{}` all work.
+- Captioned figures are numbered `Figure N:`.
+- A caption attaches to the element *directly* below it; a blank line between attaches to
+  nothing.
+- Checkboxes render as org writes them, which keeps the `[-]` partly-done state a disabled
+  `<input>` could not express. `[@4]` sets a list item's number.
+- A table's special marker column and its marker rows stay out of the output.
+- `#+BEGIN_NOTE` and any other unrecognised name is a special block: a div holding parsed
+  org rather than a `<pre>` of literal text. Verse keeps its line breaks.
+- Emphasis borders forbid whitespace and nothing else, so `="proxied":false=` is verbatim
+  and `~~/.config/doom/config.el~` is a path that starts with a tilde.
+- Listings sort on the time of day when a timestamp carries one.
+- Cache format 6.
+
+## 0.14.0
+
+- **Per-page layouts.** `[[pages]]` rules map a source path to a template, and
+  `#+TEMPLATE:` on a page overrides any rule. A missing template fails the build naming
+  the page, the template, and what does exist.
+- `page.year`, for grouping a listing by year with minijinja's `groupby`.
+- An explicit nav can order generated pages among authored ones. `nav.mode = "none"` now
+  really means none.
+
+## 0.13.0
+
+- Bundled TOML and Org syntax definitions, a `syntaxes_dir` for your own, and org's comma
+  escape (`,* heading` inside a block).
+
+## 0.12.0
+
+- `serve`: a development server with live reload, bound to loopback.
+- A documentation site under `docs/`, built by org-ssg itself.
+
+## 0.11.0
+
+- Table of contents as `page.toc`, section numbers, and org's `#+OPTIONS:` per-file
+  switches.
+
+## 0.10.0
+
+- Excerpts, word count, reading time, a `truncate` filter, and `#+DRAFT:` pages.
+
+## 0.9.0
+
+- `watch`: rebuilds on OS filesystem events, debounced.
+
+## 0.8.0
+
+- `site.base_url`, the `absolute` and `rfc822` filters, canonical links, and an RSS feed
+  in the scaffold that validates.
+
+## 0.7.0
+
+- Pagination for large listings, with a `paginator` template context that composes with
+  grouping.
+
+## 0.6.0
+
+- Grouped collections: one page per tag plus a tag index.
+- Generated listing pages (`[[collections]]`), sorted indexes, and feeds via XML
+  templates.
+- A config file, user templates, nav modes, an `init` scaffold, and discovery that will
+  not publish `.git`.
+
+## 0.5.0
+
+- Parse diagnostics carry `file:line`, and pages render in parallel.
+- The corpus audit (`org-ssg audit`) and the `emacs --batch` oracle.
+- `#+SLUG:` decides a page's output filename — found by auditing a real corpus, where it
+  affected 169 of 182 URLs.
+
+## 0.4.0
+
+- The full v1 construct scope, with the IN/OUT line under test.
+
+## 0.3.0
+
+- The incremental build layer: content, config and template hashing, a dependency graph,
+  per-page render keys, and a persisted cache manifest. A full build and an incremental
+  build produce byte-identical output.
+
+## 0.2.0
+
+- Multi-file site builds: a symbol table, internal link resolution, minijinja templates,
+  tables and footnotes.
+
+## 0.1.0
+
+- Parse and render a single `.org` file to HTML.
diff --git a/Cargo.lock b/Cargo.lock
index 3a6f42a..d633d27 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -675,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 19e7926..0c322d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,9 +1,26 @@
 [package]
 name = "org-ssg"
-version = "0.17.0"
+version = "0.18.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
+readme = "README.md"
+keywords = ["org-mode", "static-site-generator", "emacs", "html", "blog"]
+categories = ["command-line-utilities", "text-processing"]
+# Set before the first `cargo publish`: crates.io shows it on the crate page, and a
+# missing link is the first thing anyone evaluating a generator looks for.
+# repository = "https://git.krz.sh/cmc/org-ssg.git/"
+
+# The compiler floor, checked in CI rather than assumed. org-ssg's own code needs 1.82
+# (`Option::is_none_or`); the floor is 1.88 because dependencies in Cargo.lock declare it
+# — `plist` and `time`, both by way of syntect. Raising this is a minor-version change,
+# never a patch.
+rust-version = "1.88"
+
+# Published crates carry the source, the fixtures the tests need, and nothing else. The
+# documentation site is 14 org files plus its build output, which nobody installing a
+# binary wants to download.
+exclude = ["docs/", "target/", "/.github/"]
 
 [lib]
 name = "org_ssg"
@@ -37,3 +54,10 @@ tiny_http = "0.12.0"
 
 [dev-dependencies]
 insta = { version = "1", features = ["json"] }
+
+# Release binaries are downloaded by people evaluating the tool, so they are built to be
+# small and quick to start rather than quick to compile. Thin LTO keeps CI build times
+# reasonable; full LTO bought a few percent for minutes per job.
+[profile.release]
+lto = "thin"
+strip = "symbols"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fd9f61b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Christian Cleberg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 19ac383..8884351 100644
--- a/README.md
+++ b/README.md
@@ -387,7 +387,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **19** | **Export parity: relative heading levels, special strings, sub/superscript, caption numbering, checkbox and counter markup, table marker columns, special blocks** | **done** |
 | **20** | **Correctness debt: org's entity table, table captions, a reported `#+INCLUDE:`, and an oracle that separates deliberate divergence from defects** | **done** |
 | **21** | **Extra asset roots; per-template hashing so one layout edit does not re-render the site** | **done** |
-| 22 | Release engineering: CI, MSRV, published binaries, changelog, a written compatibility promise | 1.0 |
+| **22** | **Release engineering: CI on both platforms, a checked MSRV, release binaries, a changelog, and a written compatibility promise** | **done** |
 
 ### v0.2 in / out
 
@@ -688,6 +688,32 @@ anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; pa
 plain lists (unordered + ordered) with checkboxes; source blocks; inline markup (`*bold*`,
 `/italic/`, `_underline_`, `+strike+`, `=verbatim=`, `~code~`); links and bare URLs.
 
+## Compatibility
+
+Versions mean something as of 1.0. The **stable surface** — changing incompatibly requires
+a major version — is what you actually build a site against:
+
+| Stable | Detail |
+|---|---|
+| `org-ssg.toml` keys | Names, types and meaning. New keys are minor releases; removing one is major. |
+| Template context | `page`, `site`, `nav`, `root`, `pages`, `group`, `groups`, `paginator`, `stylesheet`, and the `absolute` / `rfc822` / `truncate` filters. |
+| CLI | Command names, flags, and exit codes. |
+| URLs | How a source path becomes an output path, including `#+SLUG:`. A generator that moves your URLs breaks every link anyone has to you. |
+
+Explicitly **not stable**, so that the above can be:
+
+- **The incremental cache.** Versioned, discarded on mismatch, never a correctness
+  dependency. It changes whenever it needs to, in any release.
+- **Rendered HTML details.** org-ssg tracks what Emacs exports from the same file, and
+  closing a gap changes markup. Changes that affect output are called out in
+  [CHANGELOG.md](CHANGELOG.md) — the class names the documentation names (`post-list`,
+  `figure-number`, `section-number-N`, `footnote-ref`) are the ones to write CSS against.
+- **The Rust API.** The crate is published so the binary can be installed with
+  `cargo install`; the library exists to serve it, and its types move as the tool does.
+
+The **MSRV is 1.88**, checked in CI on every change. org-ssg's own code compiles on
+1.82; the floor comes from dependencies. Raising it is a minor version, never a patch.
+
 ## Dependencies
 
 Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
@@ -702,7 +728,7 @@ development server), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `an
 
 ```
 cargo build
-cargo test                                                # 156 tests
+cargo test                                                # 191 tests
 cargo run -- init my-site                                 # scaffold a new site
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..b819ed1
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,78 @@
+# Releasing
+
+A release is three things that must agree: a version in `Cargo.toml`, a git tag, and a
+changelog entry. The release workflow checks the first two against each other and refuses
+to build if they differ, because a release tagged `v0.18.0` containing a binary that
+reports `0.17.0` is the kind of mistake nobody notices for months.
+
+## Before the first publish
+
+`repository` in `Cargo.toml` is commented out, because a wrong URL on a crates.io page is
+worse than none. Set it, then:
+
+```bash
+cargo login          # a crates.io token, once per machine
+cargo publish --dry-run
+```
+
+## Every release
+
+1. **Write the changelog entry first.** [CHANGELOG.md](CHANGELOG.md) names behaviour, not
+   commits — someone reading it wants to know what their next build will do differently.
+   Anything that changes rendered HTML gets said out loud.
+
+2. **Bump the version** in `Cargo.toml`, and build once so `Cargo.lock` follows.
+
+   Patch for fixes that change nothing about the stable surface. Minor for new config
+   keys, new template variables, an MSRV bump, or output that changes to track Emacs more
+   closely. Major for anything that breaks the promises in the README's Compatibility
+   section — config keys, template context, CLI, or URLs.
+
+3. **Check it.**
+
+   ```bash
+   cargo test
+   cargo clippy --all-targets -- -D warnings
+   cargo run -- build docs -o docs/_site --strict
+   cargo package
+   ```
+
+   `cargo package` is the one people forget: it builds the crate exactly as crates.io will
+   receive it, and catches a file the `exclude` list should not have removed.
+
+4. **Verify against a real corpus.** The test suite says the code does what it did; a
+   corpus says the *site* does. Build a site you know with `--no-cache` and diff the
+   output against the previous version's. A release that quietly changes 200 pages should
+   do so on purpose.
+
+5. **Commit, tag, push.**
+
+   ```bash
+   git commit -am "0.18: <what changed>"
+   git tag -a v0.18.0 -m "0.18.0"
+   git push && git push --tags
+   ```
+
+6. **Publish the crate.**
+
+   ```bash
+   cargo publish
+   ```
+
+   This is irreversible: a published version can be yanked but never replaced.
+
+7. **Finish the GitHub release.** Pushing the tag builds binaries for macOS (arm64 and
+   x86_64) and Linux (gnu and musl) and opens a *draft* release with them attached. Paste
+   the changelog entry in and publish it. The draft is deliberate — a release that
+   publishes itself before anyone has read it cannot be edited quietly.
+
+## If a release goes wrong
+
+Yank rather than delete, and ship a fix as a new version:
+
+```bash
+cargo yank --version 0.18.0
+```
+
+Yanking stops new dependents from selecting it; anyone who already has it keeps working.
+Then release `0.18.1` with the fix and a changelog entry that says what happened.
diff --git a/docs/guide/10-deploying.org b/docs/guide/10-deploying.org
index 1e01bdc..f668865 100644
--- a/docs/guide/10-deploying.org
+++ b/docs/guide/10-deploying.org
@@ -112,3 +112,10 @@ built 182 page(s) (182 rendered, 0 cached), copied 3 asset(s) from content -> _s
 Both zeros matter. Unresolved links are internal links pointing at nothing; diagnostics
 are malformed org that degraded rather than failing. With =--strict= neither can reach
 this line, because either would have failed the build.
+
+* After an upgrade
+
+The first build on a new version is worth running with =--no-cache=, so you compare the
+new output to the old rather than to a cache written by both. What a version number
+promises — and what it does not — is in [[file:11-versioning.org][Versioning and
+upgrades]].
diff --git a/docs/guide/11-versioning.org b/docs/guide/11-versioning.org
new file mode 100644
index 0000000..f03a1b7
--- /dev/null
+++ b/docs/guide/11-versioning.org
@@ -0,0 +1,72 @@
+#+TITLE: Versioning and upgrades
+#+DESCRIPTION: What a version number promises, what it does not, and how to upgrade safely.
+#+LEDE: The stable surface is what you build a site against, not what the code happens to do.
+
+A generator you point at ten years of writing needs to be boring about compatibility.
+This page says exactly what is promised.
+
+* The stable surface
+
+Changing any of this incompatibly requires a major version.
+
+| Stable | What that covers |
+|--------+------------------|
+| =org-ssg.toml= keys | Their names, types and meaning. |
+| Template context | =page=, =site=, =nav=, =root=, =pages=, =group=, =groups=, =paginator=, =stylesheet=, and the =absolute=, =rfc822= and =truncate= filters. |
+| The CLI | Command names, flags and exit codes. |
+| URLs | How a source path becomes an output path, =#+SLUG:= included. |
+
+*URLs are on that list deliberately.* A generator that quietly moves your pages breaks
+every link anyone has ever made to you, and no upgrade note fixes an inbound link.
+
+Adding things — a new config key, a new template variable — is a minor release. Nothing
+you already wrote stops working.
+
+* What is not stable
+
+Three things move freely, so the list above can hold still.
+
+** The incremental cache
+
+=<output>/.org-ssg-cache.json= is versioned and discards itself on a mismatch. A cache
+format bump means one full rebuild, and nothing else. It is never a correctness
+dependency: a missing, stale or corrupt cache produces exactly the same site, more slowly.
+
+** Rendered HTML details
+
+org-ssg aims at what Emacs exports from the same file, and closing a gap changes markup.
+That is the product working rather than a regression — but it is called out in the
+changelog every time, because your stylesheet is downstream of it.
+
+The class names the documentation names are the ones to write CSS against:
+=post-list=, =post-list-item=, =figure-number=, =table-number=, =section-number-N=,
+=footnote-ref=, =verbatim=, and the =on=/=off=/=trans= classes on checkbox items.
+
+** The Rust API
+
+The crate is on crates.io so the binary can be installed with =cargo install=. The library
+exists to serve the binary, and its types move as the tool does.
+
+* The compiler floor
+
+The MSRV is *1.88*, checked in CI on every change rather than assumed — which is how it
+came to be 1.88 rather than the 1.82 org-ssg's own code needs. The floor is set by
+dependencies, and a dependency raising its own is invisible until someone on an older
+compiler tries to build.
+
+Raising it is a minor version, never a patch.
+
+* Upgrading
+
+#+BEGIN_SRC sh
+cargo install org-ssg          # or download a release binary
+org-ssg build content -o _site --no-cache --strict
+#+END_SRC
+
+=--no-cache= makes the first build after an upgrade a full one, so you are comparing the
+new version's output to the old version's output rather than to a cache written by a
+mixture of both. =--strict= turns a link that stopped resolving into a failure.
+
+If you keep your built site in version control, the diff after that command *is* the
+upgrade report — which is the most useful review a generator can give you, and the reason
+the changelog names behaviour rather than commits.