aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/deploy.yml13
-rw-r--r--.github/workflows/docker-build.yml6
-rw-r--r--content/blog/2026-02-12-automating-weblorg-deployments.org357
-rw-r--r--publish.el10
4 files changed, 372 insertions, 14 deletions
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 820251b..310b432 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -2,9 +2,14 @@ name: Build and Deploy
on:
push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
+ branches:
+ - main
+ paths-ignore:
+ - '.github/**'
+ - 'screenshots/**'
+ - 'utils/**'
+ - 'LICENSE'
+ - 'README.org'
jobs:
build-job:
@@ -58,6 +63,6 @@ jobs:
eval $(ssh-agent -s)
echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
rsync -avz --delete \
- -e "ssh -p 2169 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
+ -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
.build/ \
$SERVER_USER@$SERVER_IP:/var/www/cleberg.net/
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 6172cc4..b3d0455 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -7,12 +7,6 @@ on:
- 'Dockerfile'
- 'requirements.txt'
- '.github/workflows/docker-build.yml'
- pull_request:
- branches: [ "main" ]
- paths:
- - 'Dockerfile'
- - 'requirements.txt'
- - '.github/workflows/docker-build.yml'
jobs:
build:
diff --git a/content/blog/2026-02-12-automating-weblorg-deployments.org b/content/blog/2026-02-12-automating-weblorg-deployments.org
new file mode 100644
index 0000000..9c00297
--- /dev/null
+++ b/content/blog/2026-02-12-automating-weblorg-deployments.org
@@ -0,0 +1,357 @@
+#+date: <2026-02-12 Thu 20:26:32>
+#+title: Automating Weblorg Deployments with GitHub Actions
+#+description: Learn how I've automated all deployment for my org-mode blog with GitHub Actions and a custom Docker image.
+#+slug: automating-weblorg-deployments
+
+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:
+- [[https://www.gnu.org/software/emacs/][Emacs]]
+- [[https://emacs.love/weblorg/][Weblorg]]
+- [[https://www.python.org/][Python]]
+- [[https://formulae.brew.sh/formula/minify][Minify]]
+- [[https://rsync.samba.org/][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.
+
+* 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.
+
+#+begin_src elisp
+;;; -*- lexical-binding: t -*-
+;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution
+(defvar site-lisp-base
+ (if (eq system-type 'darwin)
+ "~/.config/emacs/.local/straight/repos" ; macOS path
+ "/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path
+
+;; Explicitly load packages
+(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base))
+(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base))
+(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base))
+
+(require 'htmlize)
+(require 'weblorg)
+
+;; Set default URL for Weblorg
+;; Only works if environment variable ENV=prod
+(if (string-equal-ignore-case (getenv "ENV") "prod")
+ (setq weblorg-default-url "https://cleberg.net"))
+
+;; Define site metadata
+(weblorg-site
+ :theme nil
+ :template-vars '(("site_name" . "cleberg.net")
+ ("site_owner" . "Christian Cleberg <[email protected]>")
+ ("site_description" . "Just a blip of ones and zeroes.")))
+
+;; Define routes for rendering content
+;; ...
+;; /scrubbed for brevity/
+
+;; Export all content using Weblorg engine
+(weblorg-export)
+#+end_src
+
+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.
+
+* 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.
+
+#+begin_src python
+# File scrubbed for brevity
+
+def main():
+ # Updates index.html with the 3 most recent blog posts
+ html_snippet = get_recent_posts_html("./content/blog", num_posts=3)
+
+ # Defines the build path, theme path, and CSS paths
+ build_dir = Path(".build")
+ theme_dir = Path("theme/static")
+ css_src = theme_dir / "styles.css"
+ css_min = theme_dir / "styles.min.css"
+
+ # Check environment for ENV, BUILD, and DEPLOY variables
+ env = os.environ.get("ENV", "").casefold()
+ build = os.environ.get("BUILD", "").casefold() == "true"
+ deploy = os.environ.get("DEPLOY", "").casefold() == "true"
+
+ if env == "prod":
+ # If ENV = prod (case-insensitive), will build for production
+ print("Environment: Production")
+ # Will only build if BUILD=true
+ if build:
+ remove_build_directory(build_dir)
+ minify_css(css_src, css_min)
+ run_emacs_publish(dev_mode=False)
+ update_index_html(html_snippet)
+ minify_html("./.build/index.html", "./.build/index.html")
+ generate_sitemap()
+ # Will only deploy if DEPLOY=true
+ # False for GitHub Actions because deploy.yml deploys via rsync directly
+ if deploy:
+ print("Deploying to production...")
+ deploy_to_server(build_dir, "homelab-remote")
+ return
+ else:
+ # If ENV != prod (case-insensitive), will build for localhost
+ print("Environment: Development")
+ # Will only build if BUILD=true
+ if build:
+ remove_build_directory(build_dir)
+ minify_css(css_src, css_min)
+ run_emacs_publish(dev_mode=True)
+ update_index_html(html_snippet)
+ minify_html("./.build/index.html", "./.build/index.html")
+ generate_sitemap()
+ # Will only deploy if DEPLOY=true
+ if deploy:
+ start_dev_server(build_dir)
+#+end_src
+
+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.
+
+* 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~.
+
+** 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.
+
+#+begin_src Dockerfile
+FROM python:3.12-slim
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ HOMEBREW_NO_AUTO_UPDATE=1 \
+ PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ git \
+ procps \
+ build-essential \
+ ca-certificates \
+ openssh-client \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN useradd -m -s /bin/bash linuxbrew
+USER linuxbrew
+WORKDIR /home/linuxbrew
+
+RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+RUN brew install emacs rsync uv minify
+
+RUN mkdir -p ~/.config/emacs/.local/straight/repos && \
+ cd ~/.config/emacs/.local/straight/repos && \
+ git clone --depth 1 https://github.com/emacsorphanage/htmlize.git && \
+ git clone --depth 1 https://github.com/emacs-love/templatel.git && \
+ git clone --depth 1 https://github.com/emacs-love/weblorg.git
+
+USER root
+WORKDIR /builds
+#+end_src
+
+** 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.
+
+#+begin_src yaml
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - 'Dockerfile'
+ - 'requirements.txt'
+ - '.github/workflows/docker-build.yml'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/${{ github.repository }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+#+end_src
+
+** 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.
+
+#+begin_src yaml
+name: Build and Deploy
+
+on:
+ push:
+ branches:
+ - main
+ paths-ignore:
+ - '.github/**'
+ - 'screenshots/**'
+ - 'utils/**'
+ - 'LICENSE'
+ - 'README.org'
+
+jobs:
+ build-job:
+ runs-on: ubuntu-latest
+ container:
+ image: ghcr.io/ccleberg/cleberg.net:main
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Run Build
+ env:
+ ENV: "prod"
+ BUILD: "true"
+ DEPLOY: "false"
+ run: |
+ echo "Environment is ready. Running build..."
+ uv run build.py
+
+ - name: Upload Build Artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-output
+ path: ${{ github.workspace }}/.build/
+ include-hidden-files: true
+
+ deploy-job:
+ runs-on: ubuntu-latest
+ needs: build-job
+ environment: production
+ container:
+ image: ghcr.io/ccleberg/cleberg.net:main
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Download Build Artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: build-output
+ path: ${{ github.workspace }}/.build/
+
+ - name: Setup SSH and Deploy
+ env:
+ SERVER_IP: ${{ secrets.SERVER_IP }}
+ SERVER_USER: ${{ secrets.SERVER_USER }}
+ SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
+ run: |
+ eval $(ssh-agent -s)
+ echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
+ rsync -avz --delete \
+ -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
+ .build/ \
+ $SERVER_USER@$SERVER_IP:/var/www/cleberg.net/
+#+end_src
+
+* 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.
diff --git a/publish.el b/publish.el
index 3399715..7f2b4be 100644
--- a/publish.el
+++ b/publish.el
@@ -1,10 +1,11 @@
;;; -*- lexical-binding: t -*-
-;; Explicitly load packages for Doom Emacs
+;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution
(defvar site-lisp-base
(if (eq system-type 'darwin)
- "~/.config/emacs/.local/straight/repos" ; macOS path
+ "~/.config/emacs/.local/straight/repos" ; macOS path
"/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path
+;; Explicitly load packages
(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base))
(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base))
(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base))
@@ -13,10 +14,11 @@
(require 'weblorg)
;; Set default URL for Weblorg
-(if (string= (getenv "ENV") "prod")
+;; Only works if environment variable ENV=prod
+(if (string-equal-ignore-case (or (getenv "ENV") "") "prod")
(setq weblorg-default-url "https://cleberg.net"))
-;; Define site configuration
+;; Define site metadata
(weblorg-site
:theme nil
:template-vars '(("site_name" . "cleberg.net")