| @@ -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. |