暗无天日

=============>DarkSun的个人博客

用GitHub Actions自动构建EGO博客

1. 背景

我的博客 暗无天日 使用 EGO(Emacs + Git + Org-mode)作为静态站点生成器。源文件是 .org 格式,存放在 source 分支;生成的 HTML 存放在 master 分支,通过 GitHub Pages 托管。

一直以来,我都是在本机执行 auto_publish.el 来发布博客。但既然用了 GitHub,为什么不把构建过程也自动化呢?于是我开始折腾用 GitHub Actions 来自动构建博客。

2. 最终效果

每次 pushsource 分支时,GitHub Actions 会自动:

  1. 用 EGO 将 .org 文件转换为 HTML(增量构建)
  2. 将生成的 HTML 推送到 master 分支
  3. GitHub Pages 自动部署

顺带还会在 Habitica 上完成"写博客"任务打个卡(游戏化激励)。

3. 实现过程

3.1. 第一步:改造 auto_publish.el

原来 auto_publish.el 里的路径都是硬编码的:

(setq load-path (cons "~/EGO/" load-path))

这在本地没问题,但在 CI 环境里路径完全不同。所以我把所有硬编码路径改为从环境变量读取,保留默认值确保本地兼容:

(setq load-path (cons (or (getenv "EGO_DIR") "~/EGO/") load-path))

ego-project-config-alist 中的 :repository-directory:store-dir 也做了类似处理:

`(("blog" :repository-directory ,(or (getenv "REPO_DIR") "~/source")
   ...
   :store-dir ,(or (getenv "STORE_DIR") "~/web")))

最后,用 condition-case 包裹 ego-do-publication 调用,确保 EGO 出错时不会静默失败:

(condition-case err
    (ego-do-publication "blog" nil nil nil)
  (error
   (message "EGO ERROR: %s" (error-message-string err))
   (message "EGO ERROR backtrace:")
   (backtrace)))

3.2. 第二步:编写 GitHub Actions Workflow

最终的 workflow 文件 .github/workflows/github-action.yml

name: 提交 habitica 任务 + 构建博客
on:
  push:
    branches: [source]

permissions:
  contents: write

jobs:
  Finish-Habitica-Tasks:
    runs-on: ubuntu-latest
    steps:
      - name: Check out habash code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          repository: nasfarley88/habash
      - name: 确保 habash 可执行
        run: chmod +x ./habash
      - name: 提交 habitica 任务
        run: |
          set -x
          ./habash up "写博客"
        env:
          HABITICA_TOKEN: ${{ secrets.HABITICA_TOKEN }}
          HABITICA_UUID: ${{ secrets.HABITICA_UUID }}

  Build-Blog:
    runs-on: ubuntu-latest
    env:
      CI: true
      EGO_DIR: /tmp/EGO
      REPO_DIR: ${{ github.workspace }}
      STORE_DIR: /tmp/web
      REPO: https://github.com/lujun9972/${{ github.event.repository.name }}
    steps:
      - name: Checkout 博客源文件
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          ref: source

      - name: Checkout EGO
        run: git clone https://github.com/lujun9972/EGO.git "$EGO_DIR"

      - name: Checkout master 分支到 store-dir
        run: |
          git clone --branch master --single-branch \
            https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git \
            "$STORE_DIR"
          cd "$STORE_DIR"
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: 安装 Emacs 和系统依赖
        run: sudo apt-get update && sudo apt-get install -y emacs-nox

      - name: 执行 auto_publish.el 构建博客
        run: |
          git config core.quotePath false
          emacs --batch -l auto_publish.el

      - name: 推送 HTML 到 master 分支
        run: |
          cd "$STORE_DIR"
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add -A
          git diff --staged --quiet || git commit -m "auto publish: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
          git push origin master

两个 Job 并行运行,互不影响。关键设计决策:

  • **EGO_DIR 和 STORE_DIR 指向 /tmp**:不在 github.workspace 内,避免污染 source 仓库的 git 状态(详见坑3)
  • **~git config core.quotePath false~**:解决 EGO 无法识别中文路径的问题(详见坑4)
  • **增量构建**:~ego-do-publication~ 第二个参数为 ~nil~,只重建变更文件

3.3. 第三步:踩过的坑

3.3.1. 坑1:EGO 缺少依赖

auto_publish.el 在本地运行时,~ht~ 和 dash 两个包早就安装过了。但 CI 是全新环境,~package-install~ 列表里没有它们,导致 EGO 加载失败:

Cannot open load file: No such file or directory, ht

解决:在 package-install 列表中加上 ht 和 ~dash~。

3.3.2. 坑2:EGO 内部 git commit 缺少身份

EGO 构建完后会用 vc-git-commit 提交 store-dir 的变更,但 store-dir 里没配 git user:

Failed (status 128): git –no-pager commit -m Update published html files…

解决:clone master 分支后立即配置 ~git config user.name/email~。

3.3.3. 坑3:EGO 的 stash/pop 在 CI 中失败

这是最有意思的一个坑。

EGO 在发布博客前,会通过 ego-git-repo-up2date-p 检查 repo 是否干净:

(defun ego-git-repo-up2date-p (repo-dir)
  (let* ((default-directory (file-name-as-directory repo-dir))
         (state (vc-git-state "")))
    (equal state 'up-to-date)))

如果检测到 state 不是 up-to-date~,EGO 会先 ~git stash 保护变更,构建完成后再 git stash pop 恢复。但在 CI 中 stash pop 总是失败:

Failed (status 1): git –no-pager stash pop -q 0 .

我一开始以为是 CI 环境 git 版本的问题,试了 git clean -fd~、~git checkout -- . 确保构建前 repo 干净,都没用。试了全量构建模式 (ego-do-publication "blog" t nil "CI auto publish") 绕过 stash,虽然能工作但丢失了增量构建的优势。

后来仔细看 CI 日志,发现了真正的原因:

repo home/runner/work/lujun9972.github.com/lujun9972.github.com state is edited

repo 是 edited 而不是 ~up-to-date~! 明明是从空白环境 checkout 出来的,怎么会有未提交的变更?

答案是:我最初把 EGO 和 master 分支都 checkout 到了 github.workspace 的子目录下:

EGO_DIR: ${{ github.workspace }}/EGO      # ❌ 在 workspace 内
STORE_DIR: ${{ github.workspace }}/web     # ❌ 在 workspace 内

这样 github.workspace~(即 source 分支的 git repo)下面多了 ~EGO/web/ 两个未跟踪目录,~vc-git-state~ 自然返回 ~edited~。

解决:把 EGO 和 master 分支 checkout 到 /tmp 下,与 source repo 完全隔离:

EGO_DIR: /tmp/EGO      # ✅ 在 workspace 外
STORE_DIR: /tmp/web    # ✅ 在 workspace 外

改完后日志变为:

repo home/runner…/lujun9972.github.com/ state is up-to-date

EGO 不再触发 stash,增量构建也正常工作了。

3.3.4. 坑4:中文路径导致 git ls-tree 无法识别 .org 文件

这个坑藏得最深,折腾了我很久。

EGO 的 ego-git-get-all-files 函数用 git ls-tree -r --name-only HEAD 获取所有 org 文件,然后用 string-suffix-p ".org" 过滤:

(defun ego-git-get-all-files (repo-dir &optional branch)
  (let ((output (ego--shell-command
                 repo-dir
                 (concat "env LC_ALL=C git ls-tree -r --name-only "
                         (or branch "HEAD"))
                 t)))
    (delq nil (mapcar #'(lambda (line)
                          (when (string-suffix-p ".org" line t)
                            (expand-file-name line repo-dir)))
                      (split-string output "\n")))))

看起来没问题对吧?但 git ls-tree 默认会对非 ASCII 路径进行八进制转义:

"Emacs\344\271\213\346\200\222/Connection Local Variable.org"

注意路径被双引号包裹,中文变成了 \344\271\213\346\200\222~。这样一来,每行的实际结尾是 ~.org"~(带引号),而不是 ~.org~,导致 ~string-suffix-p ".org" 匹配失败。

结果就是只有纯 ASCII 路径的 about.orgREADME.org 能被匹配到,整个博客 663 个 org 文件中只有这 2 个被 EGO 识别。首页也只显示了 about 一个分类。

解决:在运行 EGO 前设置 ~git config core.quotePath false~,让 git 输出原始 UTF-8 路径:

- name: 执行 auto_publish.el 构建博客
  run: |
    git config core.quotePath false
    emacs --batch -l auto_publish.el

改完后 EGO 正确识别了所有 662 个 org 文件,首页也恢复了完整的 10 个分类。

3.3.5. 坑5:force-all 模式的静默失败

在调试坑4 的过程中,我尝试用 ~force-all=t~(全量构建)来绕过问题:

(ego-do-publication "blog" t nil nil)  ; 第二个参数 t = force-all

但 EGO 静默失败了——没有任何错误输出,构建日志在 "current-branch is already source" 之后就断了。

查看 EGO 源码发现,~force-all=t~ 时 base-git-commit 会被设为 ~nil~:

(base-git-commit (unless force-all    ; force-all=t → nil!
                    (or (ego--get-first-commit-before-publish ...)
                        "HEAD~1")))

base-git-commitnil 时,EGO 的 changed-files 会变成 ~(:update 全部文件 :delete 全部文件)~,即同时删除并重建所有文件。这个逻辑矛盾导致了静默失败。

解决:使用增量构建模式 ~force-all=nil~,让 EGO 自动根据 master 分支的上次发布时间计算变更:

(ego-do-publication "blog" nil nil nil)  ; 增量构建

4. 总结

整个折腾过程有个关键收获:

  1. **checkout 路径隔离**:GitHub Actions 中多个仓库的 checkout 不要互相嵌套,否则会污染父仓库的 git 状态
  2. **中文路径注意 ~core.quotePath~**:git 默认转义非 ASCII 路径,在需要解析路径的场景下记得关闭

虽然中间踩了不少坑,但最终效果还是很满意的——以后 push 博客源文件就能自动构建发布,再也不用手动跑 auto_publish.el 了。