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
56commands within a shell environment or Emacs shell interface: 56commands within a shell environment or Emacs shell interface:
57 57
58``` shell 58``` shell
59git clone https://git.sr.ht/~ccleberg/cleberg.net 59git clone https://github.com/ccleberg/cleberg.net
60cd cleberg.net 60cd cleberg.net
61emacs -nw 61emacs -nw
62``` 62```
@@ -69,32 +69,52 @@ For users employing Doom Emacs, open any repository Org file using
69The `build.py` script wraps the build: it runs orgo, and then either 69The `build.py` script wraps the build: it runs orgo, and then either
70deploys the result or serves it locally. 70deploys the result or serves it locally.
71 71
72Two environment variables control what it does, and both default to 72Environment variables control what it does, and all default to off:
73off:
74 73
75- `BUILD=true` performs the build. 74- `BUILD=true` performs the build.
76- `DEPLOY=true` deploys in production, or starts a development server 75- `DEPLOY=true` deploys in production, or starts a development server
77 on port 8000 otherwise. 76 on port 8000 otherwise.
78 77- `ENV=prod` selects production: image URLs are rewritten to be
79A third, `ENV`, selects the mode. Setting `ENV=prod` rewrites image 78 root-relative (see Deployment below) and the `ruff` pass is skipped.
80URLs to be root-relative (see Deployment below) and skips the `ruff` 79 Anything else builds for development.
81pass; 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** |
82 92
83``` shell 93``` shell
84# Production build:
85ENV=prod BUILD=true uv run build.py
86
87# Development build: 94# Development build:
88BUILD=true uv run build.py 95BUILD=true uv run build.py
89 96
90# Development build, then serve it on localhost:8000: 97# Development build, then serve it on localhost:8000:
91BUILD=true DEPLOY=true uv run build.py 98BUILD=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
92``` 108```
93 109
94Generated site files reside in `.build/` for production and 110Generated site files reside in `.build/` for production and
95`.build-dev/` for development, ready for deployment. You can deploy the 111`.build-dev/` for development.
96resulting static site files via standard file transfer protocols such as 112
97`scp` or SFTP. 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.
98 118
99Builds are incremental: orgo keeps an `.orgo-cache.json` inside the 119Builds are incremental: orgo keeps an `.orgo-cache.json` inside the
100output directory and re-renders only what changed, so the directory is 120output directory and re-renders only what changed, so the directory is
build.py +37 −18
@@ -31,13 +31,19 @@ import sys
31from pathlib import Path 31from pathlib import Path
32 32
33 33
34def run(cmd, error): 34def run(cmd, error, echo=False):
35 """Run cmd quietly, exiting with its stderr if it fails.""" 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 """
36 result = subprocess.run(cmd, capture_output=True, text=True, check=False) 40 result = subprocess.run(cmd, capture_output=True, text=True, check=False)
37 if result.returncode != 0: 41 if result.returncode != 0:
38 print(error, file=sys.stderr) 42 print(error, file=sys.stderr)
39 print(result.stderr, file=sys.stderr) 43 print(result.stderr, file=sys.stderr)
40 sys.exit(1) 44 sys.exit(1)
45 if echo:
46 print(result.stdout, end="")
41 47
42 48
43def run_ruff(): 49def run_ruff():
@@ -102,23 +108,31 @@ def run_orgo_build(build_dir):
102 sys.exit(1) 108 sys.exit(1)
103 109
104 110
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 """
106 remote_path = f"{server}:/var/www/cleberg.net/" 119 remote_path = f"{server}:/var/www/cleberg.net/"
107 print(f"Deploying {build_dir}/ → {remote_path}") 120 print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
108 run( 121 cmd = [
122 "rsync",
123 "-r",
124 "--delete-before",
109 # The build cache lives in the output directory because it describes it, but it 125 # The build cache lives in the output directory because it describes it, but it
110 # is not part of the site. Excluding it also stops --delete removing it locally. 126 # is not part of the site. Excluding it also stops --delete removing it locally.
111 [ 127 "--exclude",
112 "rsync", 128 ".orgo-cache.json",
113 "-r", 129 ]
114 "--delete-before", 130 if dry_run:
115 "--exclude", 131 # --itemize-changes because --dry-run alone prints almost nothing: the
116 ".orgo-cache.json", 132 # point is to name every file that would be sent or deleted.
117 f"{build_dir}/", 133 cmd += ["--dry-run", "--itemize-changes"]
118 remote_path, 134 cmd += [f"{build_dir}/", remote_path]
119 ], 135 run(cmd, "Error during rsync deployment:", echo=dry_run)
120 "Error during rsync deployment:",
121 )
122 136
123 137
124def start_dev_server(build_dir): 138def start_dev_server(build_dir):
@@ -158,8 +172,13 @@ def main():
158 172
159 if os.environ.get("DEPLOY", "").casefold() == "true": 173 if os.environ.get("DEPLOY", "").casefold() == "true":
160 if prod: 174 if prod:
161 print("Deploying to production...") 175 dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
162 deploy_to_server(build_dir, "homelab") 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)
163 else: 182 else:
164 start_dev_server(build_dir) 183 start_dev_server(build_dir)
165 184