Commit e136d66716

e136d6671654561ac24fa98792442829339a639e

parent: 69fa4506b1

Unregistered key

cmc <hello@cleberg.net> · 2026-02-13 02:30 UTC
committer: <noreply@github.com>

publish-new-post: automating-weblorg-deployments (#4)

* publish new post: automating-weblorg-deployments

* fix publish.el comments

* allow for case-insensitive env variable

* obfuscate port

* update run rules

* update run rules

* allow for case-insensitive env variable

* publish new post: automating-weblorg-deployments

Layout: unified · split

.github/workflows/deploy.yml +9 −4
@@ -2,9 +2,14 @@ name: Build and Deploy
2 2
3on: 3on:
4 push: 4 push:
5 branches: [ "main" ] 5 branches:
6 pull_request: 6 - main
7 branches: [ "main" ] 7 paths-ignore:
8 - '.github/**'
9 - 'screenshots/**'
10 - 'utils/**'
11 - 'LICENSE'
12 - 'README.org'
8 13
9jobs: 14jobs:
10 build-job: 15 build-job:
@@ -58,6 +63,6 @@ jobs:
58 eval $(ssh-agent -s) 63 eval $(ssh-agent -s)
59 echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - 64 echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
60 rsync -avz --delete \ 65 rsync -avz --delete \
61 -e "ssh -p 2169 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ 66 -e "ssh -p ${{ secrets.SSH_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
62 .build/ \ 67 .build/ \
63 $SERVER_USER@$SERVER_IP:/var/www/cleberg.net/ 68 $SERVER_USER@$SERVER_IP:/var/www/cleberg.net/
.github/workflows/docker-build.yml −6
@@ -7,12 +7,6 @@ on:
7 - 'Dockerfile' 7 - 'Dockerfile'
8 - 'requirements.txt' 8 - 'requirements.txt'
9 - '.github/workflows/docker-build.yml' 9 - '.github/workflows/docker-build.yml'
10 pull_request:
11 branches: [ "main" ]
12 paths:
13 - 'Dockerfile'
14 - 'requirements.txt'
15 - '.github/workflows/docker-build.yml'
16 10
17jobs: 11jobs:
18 build: 12 build:
content/blog/2026-02-12-automating-weblorg-deployments.org added +357
@@ -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
6As I've mentioned in previous posts, I utilize a unique pipeline to draft posts,
7compose my website, and to build and deploy the static files.
8
9This 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
17I've historically relied on the following build and deployment methods:
18
191. Manually running ~ENV=prod emacs --script publish.el~;
202. 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~;
223. Then adding GitHub Actions to automate all steps whenever I merge a pull
23 request into ~main~.
24
25This post will describe the process I've created to automatically build and
26deploy my site with this stack via GitHub Actions.
27
28* Weblorg Configuration
29
30The basis for the build process is ~publish.el~. The challenge with using Emacs
31static site generators is path management. Specifically, I've needed to ensure
32that the necessary packages (~weblorg~, ~htmlize~, & ~templatel~) are available
33regardless of whether I'm building the site on macOS (my dev machine) or a
34Linux-based runner.
35
36To solve this, I use a simple conditional to set the ~site-lisp-base~ path. This
37allows the script to find the cloned repositories in their respective locations.
38Additionally, 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~.
40Otherwise, 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
78If we run a command such as ~ENV=prod emacs --script publish.el~, Emacs will
79return a ~.build/~ directory with our resulting HTML files. At this point, we
80could manually enter the ~.build/~ directory and run ~python -m http.server~ for a
81local dev server or ~rsync~ to deploy to production.
82
83However, that's just way too much work. Let's keep going.
84
85* Python Build Script
86
87Building on the previous step, I wanted to add some quality-of-life improvements
88that 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
98Python allows for this by acting as the orchestrator, as well as relying on
99environment 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
106See below for the ~main()~ function within ~build.py~ for the logic used to drive
107the process to the rest of the functions in the Python file.
108
109#+begin_src python
110# File scrubbed for brevity
111
112def 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
160Awesome! Now we can run ~uv run build.py~ to build and deploy locally or ~ENV=prod
161uv run build.py~ to build and deploy for production. Enabling ~BUILD~ and ~DEPLOY~
162variables will tweak the process, as mentioned above.
163
164However, that's way too manual for me. Let's be lazy and take it even further.
165
166* GitHub Actions
167
168So, how do we push it further. By removing the need to run a command (outside of
169~git~) at all!
170
171This process will:
1721. Create a custom Docker image with the tools we need to build and deploy.
1732. Build the Docker image and store it within GitHub's image registry.
1743. Build and deploy the website upon a push or pull request to ~main~.
175
176** The Custom Docker Image
177
178Let's start by building a Docker image that has all the tools I need to build
179the site. Standard CI runners don't come pre-installed with the specific mix of
180tools I need (Emacs, Homebrew, ~uv~, and ~minify~). Instead of installing these on
181every single run, we will build the image and store it for future use.
182
183The ~Dockerfile~ uses ~python:3.12-slim~ as a base, installs Linuxbrew for easy
184package management, and clones the necessary Emacs packages into the expected
185directory. This ensures the build environment is consistent and fast.
186
187#+begin_src Dockerfile
188FROM python:3.12-slim
189
190ENV DEBIAN_FRONTEND=noninteractive \
191 HOMEBREW_NO_AUTO_UPDATE=1 \
192 PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}"
193
194RUN 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
203RUN useradd -m -s /bin/bash linuxbrew
204USER linuxbrew
205WORKDIR /home/linuxbrew
206
207RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
208
209RUN brew install emacs rsync uv minify
210
211RUN 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
217USER root
218WORKDIR /builds
219#+end_src
220
221** Building and Pushing to GHCR
222
223Next, let's use the image we built as the base for the rest of our automation. I
224use a dedicated workflow (~docker-build.yml~) to keep the image up to date.
225Whenever I modify the Dockerfile or my requirements, GitHub Actions builds the
226image and pushes it to the GitHub Container Registry (GHCR). This image then
227serves as the environment for the final deployment step.
228
229#+begin_src yaml
230name: Build and Push Docker Image
231
232on:
233 push:
234 branches: [ "main" ]
235 paths:
236 - 'Dockerfile'
237 - 'requirements.txt'
238 - '.github/workflows/docker-build.yml'
239
240jobs:
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
275Finally, 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
277orchestrator, and the *deploy-job*, which handles the SSH handshake and ~rsync~
278transfer.
279
280#+begin_src yaml
281name: Build and Deploy
282
283on:
284 push:
285 branches:
286 - main
287 paths-ignore:
288 - '.github/**'
289 - 'screenshots/**'
290 - 'utils/**'
291 - 'LICENSE'
292 - 'README.org'
293
294jobs:
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
353Amazing! Now my site will build and deploy whenever I push to the ~main~ branch. I
354have more tweaks to make (e.g., build a development server and environment for
355pull requests prior to ~main~), but I've automated most of it and have drastically
356reduced the administrative burden for the site. After making updates, I simply
357need to ~git add ...~ and merge my PR to trigger the deployment.
publish.el +6 −4
@@ -1,10 +1,11 @@
1;;; -*- lexical-binding: t -*- 1;;; -*- lexical-binding: t -*-
2;; Explicitly load packages for Doom Emacs 2;; Allow for macOS (dev machine) & Linux (GitHub Actions) execution
3(defvar site-lisp-base 3(defvar site-lisp-base
4 (if (eq system-type 'darwin) 4 (if (eq system-type 'darwin)
5 "~/.config/emacs/.local/straight/repos" ; macOS path 5 "~/.config/emacs/.local/straight/repos" ; macOS path
6 "/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path 6 "/home/linuxbrew/.config/emacs/.local/straight/repos")) ; CI/Linux path
7 7
8;; Explicitly load packages
8(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base)) 9(add-to-list 'load-path (expand-file-name "htmlize" site-lisp-base))
9(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base)) 10(add-to-list 'load-path (expand-file-name "weblorg" site-lisp-base))
10(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base)) 11(add-to-list 'load-path (expand-file-name "templatel" site-lisp-base))
@@ -13,10 +14,11 @@
13(require 'weblorg) 14(require 'weblorg)
14 15
15;; Set default URL for Weblorg 16;; Set default URL for Weblorg
16(if (string= (getenv "ENV") "prod") 17;; Only works if environment variable ENV=prod
18(if (string-equal-ignore-case (or (getenv "ENV") "") "prod")
17 (setq weblorg-default-url "https://cleberg.net")) 19 (setq weblorg-default-url "https://cleberg.net"))
18 20
19;; Define site configuration 21;; Define site metadata
20(weblorg-site 22(weblorg-site
21 :theme nil 23 :theme nil
22 :template-vars '(("site_name" . "cleberg.net") 24 :template-vars '(("site_name" . "cleberg.net")