diff --git a/.forgejo/workflows/test.yaml b/.forgejo/workflows/test.yaml
new file mode 100644
index 0000000..2666b23
--- /dev/null
+++ b/.forgejo/workflows/test.yaml
@@ -0,0 +1,70 @@
+# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie
+# SPDX-License-Identifier: MIT
+
+# Runs the checks on every push and publishes the API documentation to
+# https://jeff.jcollie.page/isbn.zig/ when the push was to main.
+#
+# Everything goes through `nix develop`, so the toolchain is the one in
+# flake.nix rather than whatever the runner happens to carry.
+
+name: test
+
+on:
+ push:
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: nixos-do-s
+ steps:
+ - name: Check out
+ # Pinned to a commit rather than a tag, in the format pinact writes, so
+ # that what runs here cannot change under a moved tag.
+ uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Check REUSE compliance
+ run: nix develop -c reuse lint
+
+ - name: Check Zig formatting compliance
+ run: nix develop -c zig fmt --check .
+
+ # Both check digit implementations, because -Dsimd picks one and the
+ # test suite then exercises only that one. The default comes first.
+ - name: Test (scalar)
+ run: nix develop -c zig build test -Dsimd=false --summary all
+
+ - name: Test (SIMD)
+ run: nix develop -c zig build test -Dsimd=true --summary all
+
+ - name: Build
+ run: nix develop -c zig build
+
+ # The benchmark, the documentation server, and the range generator:
+ # nothing above builds them, so without this they could stop compiling
+ # and no other step would notice.
+ - name: Build everything that is not built by the steps above
+ run: nix develop -c zig build check
+
+ # Published only from main, so that a branch build cannot replace the site
+ # with a work in progress, and only after `test` has passed, so that it
+ # cannot document a tree that does not compile.
+ docs:
+ needs: test
+ if: ${{ forge.ref == 'refs/heads/main' }}
+ runs-on: nixos-do-s
+ steps:
+ - name: Check out
+ uses: https://code.forgejo.org/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Build API docs
+ run: nix develop -c zig build docs
+
+ # --upload-dir replaces the site with the contents of the directory and
+ # is incremental, so a push that changed one doc comment uploads what
+ # actually changed rather than the whole 17 MB source tarball.
+ - name: Publish docs
+ run: >
+ nix develop -c git-pages-cli https://jeff.jcollie.page/isbn.zig/
+ --server jcollie.page
+ --token ${{ forge.token }}
+ --upload-dir zig-out/docs
diff --git a/.tangled/workflows/test.yml b/.tangled/workflows/test.yml
deleted file mode 100644
index 95f6df5..0000000
--- a/.tangled/workflows/test.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie
-# SPDX-License-Identifier: MIT
-
-when:
- - event: ["push", "pull_request"]
- branch: ["main"]
-
-engine: "microvm"
-image: nixos-small
-
-dependencies:
- - zig_0_16
- - reuse
-
-steps:
- - name: "reuse lint"
- command: "reuse lint"
- - name: "zig fmt"
- command: "zig fmt --check ."
- - name: "test (scalar)"
- command: "zig build test -Dsimd=false"
- - name: "test (simd)"
- command: "zig build test -Dsimd=true"
- # Everything `test` builds but never runs: the benchmark, the
- # documentation server, and the range generator.
- - name: "check"
- command: "zig build check"
- - name: "docs"
- command: "zig build docs"
diff --git a/README.md b/README.md
index cf389e0..fb2758e 100644
--- a/README.md
+++ b/README.md
@@ -9,10 +9,11 @@ A Zig library for parsing, validating, and converting International
Standard Book Numbers (ISBNs) in both the 10-digit and 13-digit
formats.
-The API documentation is generated from the doc comments in the source:
-build it with `zig build docs` and read it with `zig build docs-serve`,
-which serves `zig-out/docs` on . A server is
-needed rather than opening the files directly, because the viewer
+The API documentation is generated from the doc comments in the source
+and published at . To read it
+locally instead, `zig build docs` builds it into `zig-out/docs` and
+`zig build docs-serve` serves it on . A server
+is needed rather than opening the files directly, because the viewer
fetches `sources.tar` and `main.wasm` at runtime and a browser refuses
those from a `file://` page.
diff --git a/flake.nix b/flake.nix
index 8417254..764b936 100644
--- a/flake.nix
+++ b/flake.nix
@@ -32,6 +32,10 @@
default = pkgs.mkShell {
nativeBuildInputs = [
pkgs.zig_0_16
+ # Uploads zig-out/docs to https://jeff.jcollie.page/isbn.zig/;
+ # the workflow runs it through this shell rather than carrying
+ # its own copy.
+ pkgs.git-pages-cli
pkgs.reuse
];
};