build.py

salary-visualization
cleberg.net/build.py history · blame · raw

249 lines · 9924 bytes · executable

  1#!/usr/bin/env python3
  2"""
  3This script automates the process of building and deploying the website.
  4It handles tasks such as:
  5
  6- Running orgo to generate site content.
  7- Rewriting the footer's orgo version to match the orgo that built the site.
  8- Rewriting image URLs for the onion service (production only).
  9- Optionally deploying the built site to a remote server.
 10- Starting a local development server for previewing changes.
 11
 12Usage:
 13    Set BUILD=true to build, DEPLOY=true to deploy or serve.
 14    Set ENV=prod for production builds; anything else builds for development.
 15
 16    Builds are incremental. Production writes .build/ and development .build-dev/;
 17    delete either one for a clean build.
 18
 19Dependencies:
 20    - Python 3
 21    - orgo, the static site generator (reads content/orgo.toml)
 22    - rsync for deployment
 23
 24Author:
 25    Christian Cleberg <hello@cleberg.net>
 26"""
 27
 28import os
 29import re
 30import subprocess
 31import sys
 32from pathlib import Path
 33
 34
 35def run(cmd, error, echo=False):
 36    """Run cmd quietly, exiting with its stderr if it fails.
 37
 38    echo prints stdout on success. Only the dry run needs it: its output is the
 39    whole reason to run it, and the default of swallowing stdout would hide it.
 40    """
 41    result = subprocess.run(cmd, capture_output=True, text=True, check=False)
 42    if result.returncode != 0:
 43        print(error, file=sys.stderr)
 44        print(result.stderr, file=sys.stderr)
 45        sys.exit(1)
 46    if echo:
 47        print(result.stdout, end="")
 48
 49
 50def run_ruff():
 51    print("Running ruff...")
 52    for cmd in [["ruff", "check", "--fix"], ["ruff", "format"]]:
 53        run(cmd, f"ruff error ({' '.join(cmd)}):")
 54
 55
 56def rewrite_img_urls(build_dir=".build"):
 57    """
 58    Rewrite absolute img.cleberg.net URLs to root-relative /img/ paths so the
 59    onion serves images from its own origin instead of fetching them off-onion.
 60    Production only: dev builds keep the absolute URLs so local previews still
 61    load images from the live image host.
 62
 63    Requires the server to serve /var/www/img/ at /img/ on the cleberg.net vhost
 64    (e.g. `ln -s /var/www/img /var/www/cleberg.net/img`).
 65
 66    The pattern tolerates a stray number of slashes after the scheme
 67    (https:/img, https:///img, ...) so a typo in the org source can't silently
 68    slip through un-rewritten and ship a broken cross-origin URL.
 69    """
 70    pattern = re.compile(r"https:/+img\.cleberg\.net/")
 71    count = 0
 72    for html in Path(build_dir).rglob("*.html"):
 73        text = html.read_text(encoding="utf-8")
 74        new_text, n = pattern.subn("/img/", text)
 75        if n:
 76            count += n
 77            html.write_text(new_text, encoding="utf-8")
 78    print(f"Rewrote {count} img.cleberg.net references to /img/")
 79
 80
 81# The footer's "built with orgo <version>" note. Matching the whole phrase rewrites a
 82# page carrying an old version as readily as one still carrying the template's
 83# placeholder, and leaves prose that ends a paragraph with a link to orgo alone.
 84ORGO_NOTE = re.compile(
 85    r'(built with <a href="https://gitbay\.org/krz/orgo">orgo</a> )[^<]*(</p>)'
 86)
 87
 88
 89def orgo_version():
 90    """The version reported by the orgo on PATH, e.g. "0.22.0"."""
 91    result = subprocess.run(
 92        ["orgo", "--version"], capture_output=True, text=True, check=False
 93    )
 94    if result.returncode != 0:
 95        print("Could not read the orgo version:", file=sys.stderr)
 96        print(result.stderr, file=sys.stderr)
 97        sys.exit(1)
 98    # `orgo --version` prints "orgo X.Y.Z". Anything else means the output format
 99    # changed, and guessing at it would ship a wrong version to every page.
100    parts = result.stdout.split()
101    if len(parts) != 2 or parts[0] != "orgo":
102        print(f"Unexpected `orgo --version` output: {result.stdout!r}", file=sys.stderr)
103        sys.exit(1)
104    return parts[1]
105
106
107def rewrite_orgo_version(build_dir):
108    """Point the footer's orgo version at the orgo that just built the site.
109
110    Runs over every page rather than only the re-rendered ones, because the build cache
111    keys on a cache *format* version and not on orgo's release version: after a version
112    bump that leaves the format alone, the pages carried over from the previous build
113    would otherwise keep printing the old one.
114
115    The whole point of this is that the note cannot go stale, so a footer that no longer
116    matches is a failure and not a no-op — matching nothing anywhere means the note was
117    removed or its markup changed, and a silent pass would ship the template's
118    placeholder to every page.
119    """
120    version = orgo_version()
121    count = 0
122    for html in Path(build_dir).rglob("*.html"):
123        text = html.read_text(encoding="utf-8")
124        new_text, n = ORGO_NOTE.subn(rf"\g<1>{version}\g<2>", text)
125        if n and new_text != text:
126            html.write_text(new_text, encoding="utf-8")
127        count += n
128    if count == 0:
129        print(
130            "No 'built with orgo' note found in the build — the footer in "
131            "content/templates/base.html no longer matches ORGO_NOTE",
132            file=sys.stderr,
133        )
134        sys.exit(1)
135    print(f"Set the footer orgo version to {version} on {count} pages")
136
137
138def run_orgo_build(build_dir):
139    """
140    Build the site with orgo.
141
142    orgo reads content/orgo.toml, which holds the routes, templates and collections: the
143    blog index groups itself by year, the tags page is a collection, the recent-posts
144    list comes from the blog collection, and the sitemap is written once base_url is set.
145
146    The output directory is left in place between builds rather than wiped, because the
147    .orgo-cache.json inside it is what makes a build incremental: orgo re-renders only
148    the pages whose content, config or templates changed, re-emits any page whose output
149    is missing, and deletes the outputs of pages that have since been removed. Wiping the
150    directory would throw that away and force a full render every time. For a clean build
151    from scratch, delete the output directory by hand.
152
153    What is left around it is what orgo does not do: rewriting image URLs for the onion.
154    """
155    print("Building with orgo...")
156    result = subprocess.run(
157        ["orgo", "build", "content", "-o", str(build_dir), "--strict"],
158        stdout=subprocess.PIPE,
159        stderr=subprocess.STDOUT,
160        text=True,
161        check=False,
162    )
163    print(result.stdout, end="")
164    if result.returncode != 0:
165        print("orgo build failed", file=sys.stderr)
166        sys.exit(1)
167
168
169def deploy_to_server(build_dir, server, dry_run=False):
170    """Push the built site to the server, or show what pushing it would do.
171
172    The deploy deletes remote files the build no longer produces, so "what would
173    this remove" is a question worth being able to ask before answering it
174    irreversibly. DRY_RUN=true asks it: rsync connects and compares, then reports
175    instead of transferring.
176    """
177    remote_path = f"{server}:/var/www/cleberg.net/"
178    print(f"{'Would deploy' if dry_run else 'Deploying'} {build_dir}/ → {remote_path}")
179    cmd = [
180        "rsync",
181        "-rt",
182        # -t, or rsync gives every transferred file the time it arrived, no two
183        # deploys agree on a timestamp, and the next run re-sends the whole site
184        # because mtimes never match. With it, only what changed goes over.
185        "--delete-before",
186        # The build cache lives in the output directory because it describes it, but it
187        # is not part of the site. Excluding it also stops --delete removing it locally.
188        "--exclude",
189        ".orgo-cache.json",
190    ]
191    if dry_run:
192        # --itemize-changes because --dry-run alone prints almost nothing: the
193        # point is to name every file that would be sent or deleted.
194        cmd += ["--dry-run", "--itemize-changes"]
195    cmd += [f"{build_dir}/", remote_path]
196    run(cmd, "Error during rsync deployment:", echo=dry_run)
197
198
199def start_dev_server(build_dir):
200    print(f"Starting development HTTP server from {build_dir}/ on port 8000")
201    os.chdir(build_dir)
202    # This will run until interrupted (Ctrl+C)
203    try:
204        subprocess.run([sys.executable, "-m", "http.server", "8000"], check=True)
205    except KeyboardInterrupt:
206        print("\nDevelopment server stopped.")
207    except subprocess.CalledProcessError as e:
208        print(f"Error starting development server: {e}", file=sys.stderr)
209        sys.exit(1)
210
211
212def main():
213    prod = os.environ.get("ENV", "").casefold() == "prod"
214    if not prod:
215        run_ruff()
216
217    # One output directory per environment, because the two differ after orgo has run:
218    # production rewrites image URLs in place and development does not. Sharing a
219    # directory would let an incremental build reuse a page rendered for the other one —
220    # a dev preview showing /img/ paths that only resolve on the server. Production keeps
221    # .build/ because that is the directory the deploy and the CI manifest name.
222    build_dir = Path(".build" if prod else ".build-dev")
223
224    print(f"Environment: {'Production' if prod else 'Development'}")
225
226    if os.environ.get("BUILD", "").casefold() == "true":
227        run_orgo_build(build_dir)
228        rewrite_orgo_version(build_dir)
229        # The onion needs same-origin images; dev previews keep the absolute URLs.
230        # Runs over every page, not just the re-rendered ones, so a page carried over
231        # from an earlier build is rewritten too.
232        if prod:
233            rewrite_img_urls(build_dir)
234
235    if os.environ.get("DEPLOY", "").casefold() == "true":
236        if prod:
237            dry_run = os.environ.get("DRY_RUN", "").casefold() == "true"
238            print(
239                "Dry run — the server will not be modified"
240                if dry_run
241                else "Deploying to production..."
242            )
243            deploy_to_server(build_dir, "homelab", dry_run)
244        else:
245            start_dev_server(build_dir)
246
247
248if __name__ == "__main__":
249    main()