cmc/cleberg.net

My personal web garden & blog.

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

bbf4eec5cd941f040ab46d6a0cbae560a1900821

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T04:20:58Z

Add a dry-run deploy, and document which invocation writes to the server

The deploy is rsync --delete-before, so a build that silently produced fewer
pages would quietly remove the rest from the server. DRY_RUN=true connects and
compares but transfers nothing, listing every file that would be sent or
deleted. --itemize-changes because --dry-run alone prints almost nothing, and
run() grew an echo flag because it swallows stdout on success, which would have
hidden the entire output.

The README described three variables without saying that one combination of
them overwrites production, and suggested deploying by scp or SFTP, which is
not what the script does. It now carries the full matrix. The clone URL still
pointed at sr.ht.

No default behaviour changes: without DRY_RUN the rsync arguments are byte for
byte what they were.
 README.md | 46 +++++++++++++++++++++++++++++++++-------------
 build.py  | 55 +++++++++++++++++++++++++++++++++++++------------------
 2 files changed, 70 insertions(+), 31 deletions(-)

diff --git a/README.md b/README.md
index 4b1df21..13bcf02 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ To obtain a working copy of this repository, execute the following
 commands within a shell environment or Emacs shell interface:
 
 ``` shell
-git clone https://git.sr.ht/~ccleberg/cleberg.net
+git clone https://github.com/ccleberg/cleberg.net
 cd cleberg.net
 emacs -nw
 ```
@@ -69,32 +69,52 @@ For users employing Doom Emacs, open any repository Org file using
 The `build.py` script wraps the build: it runs orgo, and then either
 deploys the result or serves it locally.
 
-Two environment variables control what it does, and both default to
-off:
+Environment variables control what it does, and all default to off:
 
 - `BUILD=true` performs the build.
 - `DEPLOY=true` deploys in production, or starts a development server
   on port 8000 otherwise.
-
-A third, `ENV`, selects the mode. Setting `ENV=prod` rewrites image
-URLs to be root-relative (see Deployment below) and skips the `ruff`
-pass; anything else builds for development.
+- `ENV=prod` selects production: image URLs are rewritten to be
+  root-relative (see Deployment below) and the `ruff` pass is skipped.
+  Anything else builds for development.
+- `DRY_RUN=true` turns a production deploy into a report. Production
+  only; ignored everywhere else.
+
+Exactly one combination writes to the server:
+
+| `ENV`  | `BUILD` | `DEPLOY` | `DRY_RUN` | What happens                          | Output       | Server            |
+|--------|---------|----------|-----------|---------------------------------------|--------------|-------------------|
+| unset  | `true`  | —        | —         | Development build, `ruff` first       | `.build-dev/`| Untouched         |
+| unset  | `true`  | `true`   | —         | Development build, then serve on :8000| `.build-dev/`| Untouched         |
+| `prod` | `true`  | —        | —         | Production build and image rewrite    | `.build/`    | Untouched         |
+| `prod` | —       | `true`   | `true`    | Reports what a deploy would change    | —            | Read only         |
+| `prod` | `true`  | `true`   | —         | Production build, then deploy         | `.build/`    | **Overwritten**   |
 
 ``` shell
-# Production build:
-ENV=prod BUILD=true uv run build.py
-
 # Development build:
 BUILD=true uv run build.py
 
 # Development build, then serve it on localhost:8000:
 BUILD=true DEPLOY=true uv run build.py
+
+# Production build, no deploy:
+ENV=prod BUILD=true uv run build.py
+
+# What would a deploy change? Connects, compares, transfers nothing:
+ENV=prod DEPLOY=true DRY_RUN=true uv run build.py
+
+# Production build and deploy. This is the one that writes to the server:
+ENV=prod BUILD=true DEPLOY=true uv run build.py
 ```
 
 Generated site files reside in `.build/` for production and
-`.build-dev/` for development, ready for deployment. You can deploy the
-resulting static site files via standard file transfer protocols such as
-`scp` or SFTP.
+`.build-dev/` for development.
+
+The deploy is `rsync --delete-before`, so files the build no longer
+produces are removed from the server. That is what makes the dry run
+worth having: a build that silently produced fewer pages would quietly
+delete the rest. Lines beginning `*deleting` in the dry-run output are
+what the real deploy would remove.
 
 Builds are incremental: orgo keeps an `.orgo-cache.json` inside the
 output directory and re-renders only what changed, so the directory is
diff --git a/build.py b/build.py
index 61f6110..c4e1954 100755
--- a/build.py
+++ b/build.py
@@ -31,13 +31,19 @@ import sys
 from pathlib import Path
 
 
-def run(cmd, error):
-    """Run cmd quietly, exiting with its stderr if it fails."""
+def run(cmd, error, echo=False):
+    """Run cmd quietly, exiting with its stderr if it fails.
+
+    echo prints stdout on success. Only the dry run needs it: its output is the
+    whole reason to run it, and the default of swallowing stdout would hide it.
+    """
     result = subprocess.run(cmd, capture_output=True, text=True, check=False)
     if result.returncode != 0:
         print(error, file=sys.stderr)
         print(result.stderr, file=sys.stderr)
         sys.exit(1)
+    if echo:
+        print(result.stdout, end="")
 
 
 def run_ruff():
@@ -102,23 +108,31 @@ def run_orgo_build(build_dir):
         sys.exit(1)
 
 
-def deploy_to_server(build_dir, server):
+def deploy_to_server(build_dir, server, dry_run=False):
+    """Push the built site to the server, or show what pushing it would do.
+
+    The deploy deletes remote files the build no longer produces, so "what would
+    this remove" is a question worth being able to ask before answering it
+    irreversibly. DRY_RUN=true asks it: rsync connects and compares, then reports
+    instead of transferring.
+    """
     remote_path = f"{server}:/var/www/cleberg.net/"
-    print(f"Deploying {build_dir}/ → {remote_path}")
-    run(
+    print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
+    cmd = [
+        "rsync",
+        "-r",
+        "--delete-before",
         # The build cache lives in the output directory because it describes it, but it
         # is not part of the site. Excluding it also stops --delete removing it locally.
-        [
-            "rsync",
-            "-r",
-            "--delete-before",
-            "--exclude",
-            ".orgo-cache.json",
-            f"{build_dir}/",
-            remote_path,
-        ],
-        "Error during rsync deployment:",
-    )
+        "--exclude",
+        ".orgo-cache.json",
+    ]
+    if dry_run:
+        # --itemize-changes because --dry-run alone prints almost nothing: the
+        # point is to name every file that would be sent or deleted.
+        cmd += ["--dry-run", "--itemize-changes"]
+    cmd += [f"{build_dir}/", remote_path]
+    run(cmd, "Error during rsync deployment:", echo=dry_run)
 
 
 def start_dev_server(build_dir):
@@ -158,8 +172,13 @@ def main():
 
     if os.environ.get("DEPLOY", "").casefold() == "true":
         if prod:
-            print("Deploying to production...")
-            deploy_to_server(build_dir, "homelab")
+            dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
+            print(
+                "Dry run — the server will not be modified"
+                if dry_run
+                else "Deploying to production..."
+            )
+            deploy_to_server(build_dir, "homelab", dry_run)
         else:
             start_dev_server(build_dir)