定时发布:静态网站的遗憾

定时发布顾名思义就是在特定时间发布文章或内容,是CMS系统的基础功能。目前,几乎所有动态博客框架都能实现,例如Ghost、WordPress等,然而,对于静态类型的博客来说,由于缺乏能够后台定时的服务端,使得无法将定时发布变为原生功能,需要站长各凭本事实现,较为繁琐。

为什么研究这个问题?因为本博客就是一个用 Zola SSG 工具构建的静态网站,从开始移植主题建站到现在,我已经构思并调研了好几种方案,最终决定基于 GitHub Action 来实现定时发布。

几种实现方式对比

这里我先把可以实现静态网站定时发布的几种方式列举出来,并对比关键指标。

方法描述心智负担可靠性运维难度费用
自己干通过手机闹钟、手机日历提醒自己该发布啦中,节假日不保证
别人干安排一个工具人帮我发布中,取决于报酬,节假日不可用
自建发布服务预先构建网站,然后在本地或云端自建定时服务部署网站取决于是否使用付费云资源
自建CI触发服务在本地或云端自建定时服务触发CI接口构建网站,然后自动发布取决于是否使用付费云资源
使用平台原生功能依托平台能力定时触发CI实现定时发布取决于是否使用付费云资源

通过对比我们可以发现:

  • 靠人力(自己干、别人干)实现定时发布在可靠性上难以保证,因为定时发布的一个重要使用场景就是在节日等重要节点发布信息,其稳定性至关重要。
  • 依托外部服务(自建发布服务、自建 CI 触发服务)是符合直觉的,但是会引入额外的复杂度,同事可能产生一定的费用。
  • 最理想的情况是使用的工具或平台自带定时发布的功能(使用平台原生功能),但是遗憾的是,大部分平台并不具备这类功能。

为了实现最好的效果,我决定尽可能通过平台自身实现定时发布的功能。

通过 GitHub Action 实现定时发布

本博客使用 GitHub 私有仓库存储代码和文章,也就是 Git-Based 静态博客,那么接下来的工作势必要围绕 GitHub 生态进行。

在此之前,我已经通过 GitHub Action (GitHub 的持续集成 CI 服务) 实现了主分支的自动部署,若在此之上添加定时发布,必然是围绕分支合并和 CI 流水线做文章。

比较典型的方案是用新分支存放内容 + 定时合并 PR。将要发布的内容存在新分支中,到达特定时间后,自动合并,然后自动触发发布流水线,以此实现定时发布。

沿着该技术路线,我搜集到一些能够定时合并 PR 的 GitHub 扩展工具,然而,这类工具基本都需要使用付费高级版。比如有一款工具的免费版额度是每月 4 次定时合并,我想这很难满足博主的需求,毕竟定时发布除了用于发文章,也可用于发版。

最终,我找到了 Merge Schedule,可以完全基于 GitHub Action 实现定时合并 PR 进而实现自动发布。

前提条件

本方案需要具备以下条件,我想大部分静态网站应该都能轻易达成。

  • 网站存放在 GitHub,并且是 Git-Based 静态博客。
  • 代码仓库主分支已经实现自动构建、部署的流水线,能做到更新代码后自动部署到生产环境。
  • 重要更新(如文章发布、重大改版等)采用分支 + PR 的工作流,能做到通过合并 PR 即可完成所有发布内容的更新工作。

基本原理

经过深入研究,现将技术原理梳理了大概:

  1. 设置专门用于定时合并 PR 的 GitHub Action 流水线,并设置定时执行,如每小时cron: '0 * * * *'
  2. 在计划定时合并的 PR 描述中添加定时器指令,如/schedule 2022-06-08T09:00:00.000Z
  3. 每次运行定时合并流水线时,检查所有打开的 PR,如果时间匹配,则调用 API 完成 PR 合并。

这种方式巧妙的利用了 GitHub Action 的定时触发机制,实现了原生的定时合并 PR,进而做到定时发布。

部署过程

这个方案的部署十分简单,只需新增一个 GitHub Action 定义文件即可。

在代码库新建文件.GitHub/workflows/merge-schedule.yml,用于存放流水线定义。

在文件中粘贴以下内容,这里设置了时区为国内Asia/Shanghai,合并方式为merge,可以根据需要自行修改。

name: Merge Schedule

# see https://GitHub.com/gr2m/merge-schedule-action

on:
  pull_request:
    types:
      - opened
      - edited
      - synchronize
  schedule:
    # https://crontab.guru/every-hour
    - cron: '0 * * * *'

jobs:
  merge_schedule:
    runs-on: ubuntu-latest
    steps:
      - id: merge-schedule
        uses: gr2m/merge-schedule-action@v2
        with:
          # Merge method to use. Possible values are merge, squash or
          # rebase. Default is merge.
          merge_method: merge
          # Time zone to use. Default is UTC.
          time_zone: 'Asia/Shanghai'
          # Require all pull request statuses to be successful before
          # merging. Default is `false`.
          require_statuses_success: 'true'
          # Label to apply to the pull request if the merge fails. Default is
          # `automerge-fail`.
          automerge_fail_label: 'merge-schedule-failed'
        env:
          GitHub_TOKEN: ${{ secrets.GitHub_TOKEN }}

可以在官方文档中找到更高级的用法,例如如何绕过存储库安全规则等。

最后,将代码提交并推送到 GitHub 的主分支就大功告成了。

下面分享一种更高级的流水线配置,可以在定时发布之后推送通知。

这里我以 Bark 推送服务为例,需要提前配置好 GitHub Action 的 Secret 变量Bark_KEY,将推送服务的 API KEY 放进去。

name: Merge Schedule

# see https://GitHub.com/gr2m/merge-schedule-action

on:
  pull_request:
    types:
      - opened
      - edited
      - synchronize
  schedule:
    # https://crontab.guru/every-hour
    - cron: '0 * * * *'

jobs:
  merge_schedule:
    runs-on: ubuntu-latest
    steps:
      - id: merge-schedule
        uses: gr2m/merge-schedule-action@v2
        with:
          # Merge method to use. Possible values are merge, squash or
          # rebase. Default is merge.
          merge_method: merge
          # Time zone to use. Default is UTC.
          time_zone: 'Asia/Shanghai'
          # Require all pull request statuses to be successful before
          # merging. Default is `false`.
          require_statuses_success: 'true'
          # Label to apply to the pull request if the merge fails. Default is
          # `automerge-fail`.
          automerge_fail_label: 'merge-schedule-failed'
        env:
          GitHub_TOKEN: ${{ secrets.GitHub_TOKEN }}

        # run if there is any merged pull request
      - name: notification
        uses: shink/bark-action@v2
        if: ${{ fromJson(steps.merge-schedule.outputs.merged_pull_requests)[0] != null }}
        with:
          key: ${{ secrets.Bark_KEY }}
          title: GitHub PR 已自动合并
          body: 稍后请检查部署结果
          sound: alarm
          isArchive: 0
          automaticallyCopy: 0

这个流水线会在成功合并 PR 后,给用户推送通知,这样用户可以等待自动部署完成,然后访问网站看看结果。

使用流程

至此,我们可以按照直观的逻辑步骤来完成定时发布。

  1. 基于主分支创建用于定时发布的子分支,一般是文章分支。
  2. 完成内容更新,并推送到 GitHub。
  3. 在 GitHub 创建 PR 以合并到主分支,并在 PR 的描述内容最末尾,添加指明时间的以下内容
    • 特定日期,如/schedule 2022-06-08
    • 特定日期的整点时间,如/schedule 2022-06-08T09:00:00.000Z代表2022年6月8日的上午9点,时间格式遵循ISO 8601规范
    • 下一次整点时间/schedule
  4. 等待自动发布~
  5. 如果需要修改或者取消,直接删除或编辑 PR 的描述内容即可,你甚至可以简单粗暴地删除 PR ~

方案的局限性与解决措施

虽然我们实现了不出 GitHub 就能定时发布,但是仍存在一些局限性:

  1. 可选时间颗粒度较粗,默认只能按照整点时间进行配置,如8点、9点等。虽然可以满足大部分应用场景了,但存在用户希望半点发布,如8点30分,可以通过自行调整流水线定时触发频率来结婚,如每半小时。目前另外尚未测试是否存在时间的模糊匹配或近似匹配,
  2. 定时器的设置仍然较为繁琐,在 PR 中需要输入较长的指令,缺少正确性校验,可能导致失败。针对该问题,可以通过接下来在定时合并流水线中添加新增通知来解决。
  3. 缺少发布结果的预览,用户对最终效果缺少掌控。针对该问题,需要进一步定制 CI 流水线,增加子分支的自动部署,预览最终效果,还可以为项目增加预生产分支,确保合并后没有 Bug,不过这些实现起来就比较繁琐了。

总结与展望

这个方案本质是持续集成流水线的高级应用,依托 GitHub Action 的定时功能实现定时发布。围绕流水线可以添加许多有意思且实用的功能,例如构建预览环境、提前的错误检查等,这就需要发挥大家的想象力了。

其实,我最初的构想是基于免费的函数服务(如 Cloudflare Worker)实现定时触发功能,但是函数服务的冷启动问题可能会影响定时触发的稳定性,所以这个方案暂时搁置了,如果大家有解决方案欢迎在评论区分分享~

这篇文章提供了一种基于 GitHub Action 的静态网站定时发布方法,目标是为了让静态网站能享受到动态网站的功能与便利。接下来,我会继续研究如何提升静态网站的维护者体验,敬请期待!

参考资料