Commit bbf4eec5cd

bbf4eec5cd941f040ab46d6a0cbae560a1900821

parent: e1dc1624c4

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 04:20 UTC

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.

Layout: unified · split

README.md +33 −13
@@ -56,7 +56,7 @@ To obtain a working copy of this repository, execute the following
5656commands within a shell environment or Emacs shell interface:
5757
5858``` shell
59git clone https://git.sr.ht/~ccleberg/cleberg.net
59git clone https://github.com/ccleberg/cleberg.net
6060cd cleberg.net
6161emacs -nw
6262```
@@ -69,32 +69,52 @@ For users employing Doom Emacs, open any repository Org file using
6969The `build.py` script wraps the build: it runs orgo, and then either
7070deploys the result or serves it locally.
7171
72Two environment variables control what it does, and both default to
73off:
72Environment variables control what it does, and all default to off:
7473
7574- `BUILD=true` performs the build.
7675- `DEPLOY=true` deploys in production, or starts a development server
7776 on port 8000 otherwise.
78
79A third, `ENV`, selects the mode. Setting `ENV=prod` rewrites image
80URLs to be root-relative (see Deployment below) and skips the `ruff`
81pass; anything else builds for development.
77- `ENV=prod` selects production: image URLs are rewritten to be
78 root-relative (see Deployment below) and the `ruff` pass is skipped.
79 Anything else builds for development.
80- `DRY_RUN=true` turns a production deploy into a report. Production
81 only; ignored everywhere else.
82
83Exactly one combination writes to the server:
84
85| `ENV` | `BUILD` | `DEPLOY` | `DRY_RUN` | What happens | Output | Server |
86|--------|---------|----------|-----------|---------------------------------------|--------------|-------------------|
87| unset | `true` | — | — | Development build, `ruff` first | `.build-dev/`| Untouched |
88| unset | `true` | `true` | — | Development build, then serve on :8000| `.build-dev/`| Untouched |
89| `prod` | `true` | — | — | Production build and image rewrite | `.build/` | Untouched |
90| `prod` | — | `true` | `true` | Reports what a deploy would change | — | Read only |
91| `prod` | `true` | `true` | — | Production build, then deploy | `.build/` | **Overwritten** |
8292
8393``` shell
84# Production build:
85ENV=prod BUILD=true uv run build.py
86
8794# Development build:
8895BUILD=true uv run build.py
8996
9097# Development build, then serve it on localhost:8000:
9198BUILD=true DEPLOY=true uv run build.py
99
100# Production build, no deploy:
101ENV=prod BUILD=true uv run build.py
102
103# What would a deploy change? Connects, compares, transfers nothing:
104ENV=prod DEPLOY=true DRY_RUN=true uv run build.py
105
106# Production build and deploy. This is the one that writes to the server:
107ENV=prod BUILD=true DEPLOY=true uv run build.py
92108```
93109
94110Generated site files reside in `.build/` for production and
95`.build-dev/` for development, ready for deployment. You can deploy the
96resulting static site files via standard file transfer protocols such as
97`scp` or SFTP.
111`.build-dev/` for development.
112
113The deploy is `rsync --delete-before`, so files the build no longer
114produces are removed from the server. That is what makes the dry run
115worth having: a build that silently produced fewer pages would quietly
116delete the rest. Lines beginning `*deleting` in the dry-run output are
117what the real deploy would remove.
98118
99119Builds are incremental: orgo keeps an `.orgo-cache.json` inside the
100120output directory and re-renders only what changed, so the directory is
build.py +37 −18
@@ -31,13 +31,19 @@ import sys
3131from pathlib import Path
3232
3333
34def run(cmd, error):
35 """Run cmd quietly, exiting with its stderr if it fails."""
34def run(cmd, error, echo=False):
35 """Run cmd quietly, exiting with its stderr if it fails.
36
37 echo prints stdout on success. Only the dry run needs it: its output is the
38 whole reason to run it, and the default of swallowing stdout would hide it.
39 """
3640 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
3741 if result.returncode != 0:
3842 print(error, file=sys.stderr)
3943 print(result.stderr, file=sys.stderr)
4044 sys.exit(1)
45 if echo:
46 print(result.stdout, end="")
4147
4248
4349def run_ruff():
@@ -102,23 +108,31 @@ def run_orgo_build(build_dir):
102108 sys.exit(1)
103109
104110
105def deploy_to_server(build_dir, server):
111def deploy_to_server(build_dir, server, dry_run=False):
112 """Push the built site to the server, or show what pushing it would do.
113
114 The deploy deletes remote files the build no longer produces, so "what would
115 this remove" is a question worth being able to ask before answering it
116 irreversibly. DRY_RUN=true asks it: rsync connects and compares, then reports
117 instead of transferring.
118 """
106119 remote_path = f"{server}:/var/www/cleberg.net/"
107 print(f"Deploying {build_dir}/ → {remote_path}")
108 run(
120 print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
121 cmd = [
122 "rsync",
123 "-r",
124 "--delete-before",
109125 # The build cache lives in the output directory because it describes it, but it
110126 # is not part of the site. Excluding it also stops --delete removing it locally.
111 [
112 "rsync",
113 "-r",
114 "--delete-before",
115 "--exclude",
116 ".orgo-cache.json",
117 f"{build_dir}/",
118 remote_path,
119 ],
120 "Error during rsync deployment:",
121 )
127 "--exclude",
128 ".orgo-cache.json",
129 ]
130 if dry_run:
131 # --itemize-changes because --dry-run alone prints almost nothing: the
132 # point is to name every file that would be sent or deleted.
133 cmd += ["--dry-run", "--itemize-changes"]
134 cmd += [f"{build_dir}/", remote_path]
135 run(cmd, "Error during rsync deployment:", echo=dry_run)
122136
123137
124138def start_dev_server(build_dir):
@@ -158,8 +172,13 @@ def main():
158172
159173 if os.environ.get("DEPLOY", "").casefold() == "true":
160174 if prod:
161 print("Deploying to production...")
162 deploy_to_server(build_dir, "homelab")
175 dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
176 print(
177 "Dry run — the server will not be modified"
178 if dry_run
179 else "Deploying to production..."
180 )
181 deploy_to_server(build_dir, "homelab", dry_run)
163182 else:
164183 start_dev_server(build_dir)
165184