| @@ -0,0 +1,357 @@ |
| 1 | #+date: <2026-02-12 Thu 20:26:32> |
| 2 | #+title: Automating Weblorg Deployments with GitHub Actions |
| 3 | #+description: Learn how I've automated all deployment for my org-mode blog with GitHub Actions and a custom Docker image. |
| 4 | #+slug: automating-weblorg-deployments |
| 5 | |
| 6 | As I've mentioned in previous posts, I utilize a unique pipeline to draft posts, |
| 7 | compose my website, and to build and deploy the static files. |
| 8 | |
| 9 | This stack uses the following software: |
| 10 | - [[https://www.gnu.org/software/emacs/][Emacs]] |
| 11 | - [[https://emacs.love/weblorg/][Weblorg]] |
| 12 | - [[https://www.python.org/][Python]] |
| 13 | - [[https://formulae.brew.sh/formula/minify][Minify]] |
| 14 | - [[https://rsync.samba.org/][rsync]] |
| 15 | - Environment variables |
| 16 | |
| 17 | I've historically relied on the following build and deployment methods: |
| 18 | |
| 19 | 1. Manually running ~ENV=prod emacs --script publish.el~; |
| 20 | 2. Then building out a ~build.py~ script to automate the Weblorg publishing method |
| 21 | and allow for custom steps, like adding recent blog posts to ~index.html~; |
| 22 | 3. Then adding GitHub Actions to automate all steps whenever I merge a pull |
| 23 | request into ~main~. |
| 24 | |
| 25 | This post will describe the process I've created to automatically build and |
| 26 | deploy my site with this stack via GitHub Actions. |
| 27 | |
| 28 | * Weblorg Configuration |
| 29 | |
| 30 | The basis for the build process is ~publish.el~. The challenge with using Emacs |
| 31 | static site generators is path management. Specifically, I've needed to ensure |
| 32 | that the necessary packages (~weblorg~, ~htmlize~, & ~templatel~) are available |
| 33 | regardless of whether I'm building the site on macOS (my dev machine) or a |
| 34 | Linux-based runner. |
| 35 | |
| 36 | To solve this, I use a simple conditional to set the ~site-lisp-base~ path. This |
| 37 | allows the script to find the cloned repositories in their respective locations. |
| 38 | Additionally, I use an environment variable check (~ENV=prod~) to toggle the |
| 39 | ~weblorg-default-url~. If I’m just testing locally, it defaults to ~localhost~. |
| 40 | Otherwise, it points to the live domain. |
| 41 | |
| 42 | #+begin_src elisp |
| 43 | ;;; -*- lexical-binding: t -*- |
| 44 | ;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution |
| 45 | (defvar site-lisp-base |
| 46 | (if (eq system-type 'darwin) |
| 47 | "~/.config/emacs/.local/straight/repos" ; macOS path |
| 48 | "/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path |
| 49 | |
| 50 | ;; Explicitly load packages |
| 51 | (add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base)) |
| 52 | (add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base)) |
| 53 | (add-to-list 'load-path (expand-file-name "templatel" site-lisp-base)) |
| 54 | |
| 55 | (require 'htmlize) |
| 56 | (require 'weblorg) |
| 57 | |
| 58 | ;; Set default URL for Weblorg |
| 59 | ;; Only works if environment variable ENV=prod |
| 60 | (if (string-equal-ignore-case (getenv "ENV") "prod") |
| 61 | (setq weblorg-default-url "https://cleberg.net")) |
| 62 | |
| 63 | ;; Define site metadata |
| 64 | (weblorg-site |
| 65 | :theme nil |
| 66 | :template-vars '(("site_name" . "cleberg.net") |
| 67 | ("site_owner" . "Christian Cleberg <hello@cleberg.net>") |
| 68 | ("site_description" . "Just a blip of ones and zeroes."))) |
| 69 | |
| 70 | ;; Define routes for rendering content |
| 71 | ;; ... |
| 72 | ;; /scrubbed for brevity/ |
| 73 | |
| 74 | ;; Export all content using Weblorg engine |
| 75 | (weblorg-export) |
| 76 | #+end_src |
| 77 | |
| 78 | If we run a command such as ~ENV=prod emacs --script publish.el~, Emacs will |
| 79 | return a ~.build/~ directory with our resulting HTML files. At this point, we |
| 80 | could manually enter the ~.build/~ directory and run ~python -m http.server~ for a |
| 81 | local dev server or ~rsync~ to deploy to production. |
| 82 | |
| 83 | However, that's just way too much work. Let's keep going. |
| 84 | |
| 85 | * Python Build Script |
| 86 | |
| 87 | Building on the previous step, I wanted to add some quality-of-life improvements |
| 88 | that Weblorg does not provide: |
| 89 | - Update ~index.html~ with the three latest blog posts. |
| 90 | - Clean up the ~.build/~ directory with each run so we don't run into any |
| 91 | conflicts with old or removed files. |
| 92 | - Minify CSS and HTML. |
| 93 | - Silence Emacs/Weblorg ~stdout~ / ~stderr~ when running for production. |
| 94 | - Generate a sitemap. |
| 95 | - Allow the option to deploy to a remote endpoint via ~rsync~ or start the local |
| 96 | dev server. |
| 97 | |
| 98 | Python allows for this by acting as the orchestrator, as well as relying on |
| 99 | environment variables to decide its behavior: |
| 100 | - *ENV*: Determines if we use production URLs or local ones. |
| 101 | - *BUILD*: Triggers the actual Emacs export and asset minification. |
| 102 | - *DEPLOY*: In a local context, this spins up a dev server. In CI, we leave this |
| 103 | ~false~ because GitHub Actions handles the ~rsync~ logic separately. |
| 104 | |
| 105 | |
| 106 | See below for the ~main()~ function within ~build.py~ for the logic used to drive |
| 107 | the process to the rest of the functions in the Python file. |
| 108 | |
| 109 | #+begin_src python |
| 110 | # File scrubbed for brevity |
| 111 | |
| 112 | def main(): |
| 113 | # Updates index.html with the 3 most recent blog posts |
| 114 | html_snippet = get_recent_posts_html("./content/blog", num_posts=3) |
| 115 | |
| 116 | # Defines the build path, theme path, and CSS paths |
| 117 | build_dir = Path(".build") |
| 118 | theme_dir = Path("theme/static") |
| 119 | css_src = theme_dir / "styles.css" |
| 120 | css_min = theme_dir / "styles.min.css" |
| 121 | |
| 122 | # Check environment for ENV, BUILD, and DEPLOY variables |
| 123 | env = os.environ.get("ENV", "").casefold() |
| 124 | build = os.environ.get("BUILD", "").casefold() == "true" |
| 125 | deploy = os.environ.get("DEPLOY", "").casefold() == "true" |
| 126 | |
| 127 | if env == "prod": |
| 128 | # If ENV = prod (case-insensitive), will build for production |
| 129 | print("Environment: Production") |
| 130 | # Will only build if BUILD=true |
| 131 | if build: |
| 132 | remove_build_directory(build_dir) |
| 133 | minify_css(css_src, css_min) |
| 134 | run_emacs_publish(dev_mode=False) |
| 135 | update_index_html(html_snippet) |
| 136 | minify_html("./.build/index.html", "./.build/index.html") |
| 137 | generate_sitemap() |
| 138 | # Will only deploy if DEPLOY=true |
| 139 | # False for GitHub Actions because deploy.yml deploys via rsync directly |
| 140 | if deploy: |
| 141 | print("Deploying to production...") |
| 142 | deploy_to_server(build_dir, "homelab-remote") |
| 143 | return |
| 144 | else: |
| 145 | # If ENV != prod (case-insensitive), will build for localhost |
| 146 | print("Environment: Development") |
| 147 | # Will only build if BUILD=true |
| 148 | if build: |
| 149 | remove_build_directory(build_dir) |
| 150 | minify_css(css_src, css_min) |
| 151 | run_emacs_publish(dev_mode=True) |
| 152 | update_index_html(html_snippet) |
| 153 | minify_html("./.build/index.html", "./.build/index.html") |
| 154 | generate_sitemap() |
| 155 | # Will only deploy if DEPLOY=true |
| 156 | if deploy: |
| 157 | start_dev_server(build_dir) |
| 158 | #+end_src |
| 159 | |
| 160 | Awesome! Now we can run ~uv run build.py~ to build and deploy locally or ~ENV=prod |
| 161 | uv run build.py~ to build and deploy for production. Enabling ~BUILD~ and ~DEPLOY~ |
| 162 | variables will tweak the process, as mentioned above. |
| 163 | |
| 164 | However, that's way too manual for me. Let's be lazy and take it even further. |
| 165 | |
| 166 | * GitHub Actions |
| 167 | |
| 168 | So, how do we push it further. By removing the need to run a command (outside of |
| 169 | ~git~) at all! |
| 170 | |
| 171 | This process will: |
| 172 | 1. Create a custom Docker image with the tools we need to build and deploy. |
| 173 | 2. Build the Docker image and store it within GitHub's image registry. |
| 174 | 3. Build and deploy the website upon a push or pull request to ~main~. |
| 175 | |
| 176 | ** The Custom Docker Image |
| 177 | |
| 178 | Let's start by building a Docker image that has all the tools I need to build |
| 179 | the site. Standard CI runners don't come pre-installed with the specific mix of |
| 180 | tools I need (Emacs, Homebrew, ~uv~, and ~minify~). Instead of installing these on |
| 181 | every single run, we will build the image and store it for future use. |
| 182 | |
| 183 | The ~Dockerfile~ uses ~python:3.12-slim~ as a base, installs Linuxbrew for easy |
| 184 | package management, and clones the necessary Emacs packages into the expected |
| 185 | directory. This ensures the build environment is consistent and fast. |
| 186 | |
| 187 | #+begin_src Dockerfile |
| 188 | FROM python:3.12-slim |
| 189 | |
| 190 | ENV DEBIAN_FRONTEND=noninteractive \ |
| 191 | HOMEBREW_NO_AUTO_UPDATE=1 \ |
| 192 | PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}" |
| 193 | |
| 194 | RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 195 | curl \ |
| 196 | git \ |
| 197 | procps \ |
| 198 | build-essential \ |
| 199 | ca-certificates \ |
| 200 | openssh-client \ |
| 201 | && rm -rf /var/lib/apt/lists/* |
| 202 | |
| 203 | RUN useradd -m -s /bin/bash linuxbrew |
| 204 | USER linuxbrew |
| 205 | WORKDIR /home/linuxbrew |
| 206 | |
| 207 | RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
| 208 | |
| 209 | RUN brew install emacs rsync uv minify |
| 210 | |
| 211 | RUN mkdir -p ~/.config/emacs/.local/straight/repos && \ |
| 212 | cd ~/.config/emacs/.local/straight/repos && \ |
| 213 | git clone --depth 1 https://github.com/emacsorphanage/htmlize.git && \ |
| 214 | git clone --depth 1 https://github.com/emacs-love/templatel.git && \ |
| 215 | git clone --depth 1 https://github.com/emacs-love/weblorg.git |
| 216 | |
| 217 | USER root |
| 218 | WORKDIR /builds |
| 219 | #+end_src |
| 220 | |
| 221 | ** Building and Pushing to GHCR |
| 222 | |
| 223 | Next, let's use the image we built as the base for the rest of our automation. I |
| 224 | use a dedicated workflow (~docker-build.yml~) to keep the image up to date. |
| 225 | Whenever I modify the Dockerfile or my requirements, GitHub Actions builds the |
| 226 | image and pushes it to the GitHub Container Registry (GHCR). This image then |
| 227 | serves as the environment for the final deployment step. |
| 228 | |
| 229 | #+begin_src yaml |
| 230 | name: Build and Push Docker Image |
| 231 | |
| 232 | on: |
| 233 | push: |
| 234 | branches: [ "main" ] |
| 235 | paths: |
| 236 | - 'Dockerfile' |
| 237 | - 'requirements.txt' |
| 238 | - '.github/workflows/docker-build.yml' |
| 239 | |
| 240 | jobs: |
| 241 | build: |
| 242 | runs-on: ubuntu-latest |
| 243 | permissions: |
| 244 | contents: read |
| 245 | packages: write |
| 246 | |
| 247 | steps: |
| 248 | - name: Checkout repository |
| 249 | uses: actions/checkout@v4 |
| 250 | |
| 251 | - name: Log in to GHCR |
| 252 | uses: docker/login-action@v3 |
| 253 | with: |
| 254 | registry: ghcr.io |
| 255 | username: ${{ github.actor }} |
| 256 | password: ${{ secrets.GITHUB_TOKEN }} |
| 257 | |
| 258 | - name: Extract metadata |
| 259 | id: meta |
| 260 | uses: docker/metadata-action@v5 |
| 261 | with: |
| 262 | images: ghcr.io/${{ github.repository }} |
| 263 | |
| 264 | - name: Build and push |
| 265 | uses: docker/build-push-action@v5 |
| 266 | with: |
| 267 | context: . |
| 268 | push: true |
| 269 | tags: ${{ steps.meta.outputs.tags }} |
| 270 | labels: ${{ steps.meta.outputs.labels }} |
| 271 | #+end_src |
| 272 | |
| 273 | ** The Build and Deploy Workflow |
| 274 | |
| 275 | Finally, the ~deploy.yml~ brings it all together. I split into two jobs: the |
| 276 | *build-job*, which runs inside our custom container to execute the Python |
| 277 | orchestrator, and the *deploy-job*, which handles the SSH handshake and ~rsync~ |
| 278 | transfer. |
| 279 | |
| 280 | #+begin_src yaml |
| 281 | name: Build and Deploy |
| 282 | |
| 283 | on: |
| 284 | push: |
| 285 | branches: |
| 286 | - main |
| 287 | paths-ignore: |
| 288 | - '.github/**' |
| 289 | - 'screenshots/**' |
| 290 | - 'utils/**' |
| 291 | - 'LICENSE' |
| 292 | - 'README.org' |
| 293 | |
| 294 | jobs: |
| 295 | build-job: |
| 296 | runs-on: ubuntu-latest |
| 297 | container: |
| 298 | image: ghcr.io/ccleberg/cleberg.net:main |
| 299 | |
| 300 | steps: |
| 301 | - name: Checkout code |
| 302 | uses: actions/checkout@v4 |
| 303 | |
| 304 | - name: Run Build |
| 305 | env: |
| 306 | ENV: "prod" |
| 307 | BUILD: "true" |
| 308 | DEPLOY: "false" |
| 309 | run: | |
| 310 | echo "Environment is ready. Running build..." |
| 311 | uv run build.py |
| 312 | |
| 313 | - name: Upload Build Artifacts |
| 314 | uses: actions/upload-artifact@v4 |
| 315 | with: |
| 316 | name: build-output |
| 317 | path: ${{ github.workspace }}/.build/ |
| 318 | include-hidden-files: true |
| 319 | |
| 320 | deploy-job: |
| 321 | runs-on: ubuntu-latest |
| 322 | needs: build-job |
| 323 | environment: production |
| 324 | container: |
| 325 | image: ghcr.io/ccleberg/cleberg.net:main |
| 326 | |
| 327 | steps: |
| 328 | - name: Checkout code |
| 329 | uses: actions/checkout@v4 |
| 330 | |
| 331 | - name: Download Build Artifacts |
| 332 | uses: actions/download-artifact@v4 |
| 333 | with: |
| 334 | name: build-output |
| 335 | path: ${{ github.workspace }}/.build/ |
| 336 | |
| 337 | - name: Setup SSH and Deploy |
| 338 | env: |
| 339 | SERVER_IP: ${{ secrets.SERVER_IP }} |
| 340 | SERVER_USER: ${{ secrets.SERVER_USER }} |
| 341 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} |
| 342 | run: | |
| 343 | eval $(ssh-agent -s) |
| 344 | echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - |
| 345 | rsync -avz --delete \ |
| 346 | -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ |
| 347 | .build/ \ |
| 348 | $SERVER_USER@$SERVER_IP:/var/www/cleberg.net/ |
| 349 | #+end_src |
| 350 | |
| 351 | * Conclusion |
| 352 | |
| 353 | Amazing! Now my site will build and deploy whenever I push to the ~main~ branch. I |
| 354 | have more tweaks to make (e.g., build a development server and environment for |
| 355 | pull requests prior to ~main~), but I've automated most of it and have drastically |
| 356 | reduced the administrative burden for the site. After making updates, I simply |
| 357 | need to ~git add ...~ and merge my PR to trigger the deployment. |