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
README.md | 46 +++++++++++++++++++++++++++++++++------------- build.py | 55 +++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 70 insertions(+), 31 deletions(-) @@ -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 @@ -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)