cmc/cleberg.net

My personal web garden & blog.

clone: git clone https://gitbay.org/cmc/cleberg.net.git

fab92e702d2db99b85572b8e505ce7b194de5130

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T03:34:42Z

Build the site in CI on pull requests and pushes to main

The site had no CI: a broken org file or an orgo regression was caught by
looking at cleberg.net after deploying to it.

Builds with ENV=prod rather than the development path, for two reasons. The
development path runs 'ruff check --fix' and 'ruff format', which rewrite files
in place, and CI should verify the tree rather than edit it. Production is also
the build that actually ships, including the onion image-URL rewrite that runs
only when ENV=prod.

DEPLOY is left unset so main() builds and stops. Because that is an assumption
rather than a guarantee, rsync is shadowed with a failing stub for the duration
of the job: if the deploy path is ever reached from CI, the job fails instead of
quietly reaching for the production host.

orgo is pinned and cached on that pin, since compiling it dominates the run and
nothing bumps a 'cargo install' automatically.
 .github/workflows/build.yml | 77 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..850b316
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,77 @@
+name: Build
+
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+
+env:
+  # orgo is installed from crates.io, so nothing bumps this automatically —
+  # dependabot does not see `cargo install` in a workflow. Raise it by hand when
+  # a new orgo lands, which is also how you find out orgo broke the site.
+  ORGO_VERSION: "0.22.0"
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v7
+
+      # Compiling orgo takes minutes; the binary only changes when the pinned
+      # version does, so key the cache on it.
+      - name: Cache orgo
+        id: cache-orgo
+        uses: actions/cache@v6
+        with:
+          path: ~/.cargo/bin/orgo
+          key: orgo-${{ env.ORGO_VERSION }}-${{ runner.os }}
+
+      - name: Install orgo
+        if: steps.cache-orgo.outputs.cache-hit != 'true'
+        run: |
+          cargo --version
+          cargo install orgo --version "$ORGO_VERSION" --locked
+
+      - name: Show versions
+        run: |
+          orgo --version
+          python3 --version
+
+      # build.py deploys with rsync when DEPLOY=true and ENV=prod. CI passes
+      # neither, but "we did not set the variable" is an assumption, not a
+      # guarantee. Shadowing rsync with a failing stub turns it into one: if the
+      # deploy path is ever reached from CI, the job fails loudly instead of
+      # quietly reaching for a production host.
+      - name: Block the deploy path
+        run: |
+          mkdir -p "$RUNNER_TEMP/bin"
+          cat > "$RUNNER_TEMP/bin/rsync" <<'EOF'
+          #!/usr/bin/env bash
+          echo "::error::rsync was invoked from CI — the deploy path must never run here" >&2
+          exit 1
+          EOF
+          chmod +x "$RUNNER_TEMP/bin/rsync"
+          echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
+
+      # ENV=prod is deliberate. The development path in build.py runs
+      # `ruff check --fix` and `ruff format`, which rewrite files in place —
+      # CI should verify the tree, not edit it. Production is also the build
+      # that actually ships, including the onion image-URL rewrite. DEPLOY is
+      # left unset, so main() builds and stops.
+      - name: Build the site
+        env:
+          ENV: prod
+          BUILD: "true"
+        run: python3 build.py
+
+      - name: Check the build produced pages
+        run: |
+          pages=$(find .build -name '*.html' | wc -l)
+          echo "built $pages html pages"
+          if [ "$pages" -lt 100 ]; then
+            echo "::error::only $pages pages built; expected the full site"
+            exit 1
+          fi