Automating weblorg Deployments
Table of Contents
• 1. Weblorg Configuration
• 2. Python Build Script
• 3. GitHub Actions
• 3.1. The Custom Docker Image
• 3.2. Building and Pushing to GHCR
• 3.3. The Build and Deploy Workflow
• 4. Conclusion
As I've mentioned in previous posts, I utilize a unique pipeline to draft posts, compose my website, and to build and deploy the static files.
This stack uses the following software:
• Emacs
• Weblorg
• Python
• Minify
• rsync
• Environment variables
I've historically relied on the following build and deployment methods:
1. Manually running ENV=prod emacs --script publish.el;
2. Then building out a build.py script to automate the Weblorg publishing method and allow for custom steps, like adding recent blog posts to index.html;
3. Then adding GitHub Actions to automate all steps whenever I merge a pull request into main.
This post will describe the process I've created to automatically build and deploy my site with this stack via GitHub Actions.
1. Weblorg Configuration
The basis for the build process is publish.el. The challenge with using Emacs static site generators is path management. Specifically, I've needed to ensure that the necessary packages (weblorg, htmlize, & templatel) are available regardless of whether I'm building the site on macOS (my dev machine) or a Linux-based runner.
To solve this, I use a simple conditional to set the site-lisp-base path. This allows the script to find the cloned repositories in their respective locations. Additionally, I use an environment variable check (ENV=prod) to toggle the weblorg-default-url. If I’m just testing locally, it defaults to localhost. Otherwise, it points to the live domain.
If we run a command such as ENV=prod emacs --script publish.el, Emacs will return a .build/ directory with our resulting HTML files. At this point, we could manually enter the .build/ directory and run python -m http.server for a local dev server or rsync to deploy to production.
However, that's just way too much work. Let's keep going.
2. Python Build Script
Building on the previous step, I wanted to add some quality-of-life improvements that Weblorg does not provide:
• Update index.html with the three latest blog posts.
• Clean up the .build/ directory with each run so we don't run into any conflicts with old or removed files.
• Minify CSS and HTML.
• Silence Emacs/Weblorg stdout / stderr when running for production.
• Generate a sitemap.
• Allow the option to deploy to a remote endpoint via rsync or start the local dev server.
Python allows for this by acting as the orchestrator, as well as relying on environment variables to decide its behavior:
• ENV: Determines if we use production URLs or local ones.
• BUILD: Triggers the actual Emacs export and asset minification.
• DEPLOY: In a local context, this spins up a dev server. In CI, we leave this false because GitHub Actions handles the rsync logic separately.
See below for the main() function within build.py for the logic used to drive the process to the rest of the functions in the Python file.
Awesome! Now we can run uv run build.py to build and deploy locally or ENV=prod uv run build.py to build and deploy for production. Enabling BUILD and DEPLOY variables will tweak the process, as mentioned above.
However, that's way too manual for me. Let's be lazy and take it even further.
3. GitHub Actions
So, how do we push it further? By removing the need to run a command (outside of git) at all!
This process will:
1. Create a custom Docker image with the tools we need to build and deploy.
2. Build the Docker image and store it within GitHub's image registry.
3. Build and deploy the website upon a push or pull request to main.
3.1. The Custom Docker Image
Let's start by building a Docker image that has all the tools I need to build the site. Standard CI runners don't come pre-installed with the specific mix of tools I need (Emacs, Homebrew, uv, and minify). Instead of installing these on every single run, we will build the image and store it for future use.
The Dockerfile uses python:3.12-slim as a base, installs Linuxbrew for easy package management, and clones the necessary Emacs packages into the expected directory. This ensures the build environment is consistent and fast.
3.2. Building and Pushing to GHCR
Next, let's use the image we built as the base for the rest of our automation. I use a dedicated workflow (docker-build.yml) to keep the image up to date. Whenever I modify the Dockerfile or my requirements, GitHub Actions builds the image and pushes it to the GitHub Container Registry (GHCR). This image then serves as the environment for the final deployment step.
3.3. The Build and Deploy Workflow
Finally, the deploy.yml brings it all together. I split into two jobs: the build-job, which runs inside our custom container to execute the Python orchestrator, and the deploy-job, which handles the SSH handshake and rsync transfer.
4. Conclusion
Amazing! Now my site will build and deploy whenever I push to the main branch. I have more tweaks to make (e.g., build a development server and environment for pull requests prior to main), but I've automated most of it and have drastically reduced the administrative burden for the site. After making updates, I simply need to git add ... and merge my PR to trigger the deployment.