使用 Github Action 自动编译 Haskell 项目并发布 (2022-12-27)

前几个月看到群友在自己的 Unity 的 repo 上用 Github Actions 自动编译可执行文件,当时心想我的项目似乎也可以这样搞。于是刚好这两天心想写篇年终总结啥的,发现自己上个月刚换的电脑没编译博客引擎,顺带把这个 actions 给他整了。

我博客引擎的代码在 nutr1t07/gcwdr,这是一个用 stack 管理的 Haskell 项目。尽管现在 stack 已经被众多 haskeller 所唾弃,但是这个工具对我一个显而易见的新手来说还是非常方便的。stack 会在项目目录下生成 package.yaml ,这是 stack 的项目配置文件。同时 stack 会根据这个文件对应相同构建信息的 cabal 文件 <project-name>.cabal。实际上,stack 就是利用 cabal 进行构建的。

总之 Github Actions 的市场上有现成的 Haskell CI 可用,不过它是用 cabal 工具来构建的。但是没关系,我的项目文件里有 cabal 的文件给它用。

所以我就直接从上面弄一个模板下来了。他的模板长这样:

name: Haskell CI

on:
  push:
    branches: [ "master" ]
  pull_request:
    branches: [ "master" ]

permissions:
  contents: read

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-haskell@v1
      with:
        ghc-version: '8.10.3'
        cabal-version: '3.2'

    - name: Cache
      uses: actions/cache@v3
      env:
        cache-name: cache-cabal
      with:
        path: ~/.cabal
        key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }}
        restore-keys: |
          ${{ runner.os }}-build-${{ env.cache-name }}-
          ${{ runner.os }}-build-
          ${{ runner.os }}-

    - name: Install dependencies
      run: |
        cabal update
        cabal build --only-dependencies --enable-tests --enable-benchmarks
    - name: Build
      run: cabal build --enable-tests --enable-benchmarks all
    - name: Run tests
      run: cabal test all

workflow 的语法很好懂,这个模板的意思大概就是:构建一个只要求'读'权限的 action,该 action 在每次 push 和有新 pull request 的时候触发;action 内有一个 job,它将按步骤执行steps里的每一个步骤。

当然这是用来自动构建的 Workflow 文件,它是用来测试项目是否能够成功构建的,如果构建成功,那么它会给你这次 commit/pr 打个勾;否则给你打个叉。

不过在这个基础上我们可以让他进行自动发布可执行文件。比如在每次推送 vX.X.X 的 tag 时候,让他把编译好的可执行文件发布到 release 页上。

github-gh-release

actions 上当然也已经写好了这类应用的包。在过去前辈们用的是 actions/upload-release-asset,不过这个项目在 Nov 9, 2022 的时候已经遗憾归档了。

在归档的项目页面提到有 softprops/action-gh-release 仍在积极维护中。那我们就用这个了。

首先这个包要求项目的写权限,所以得在开头把权限的 read 改成 write。然后就是调包侠最爱干的事情了,直接在 steps 里加上要发布到 release 页里的文件就行了。

- name: Release
  uses: softprops/action-gh-release@v1
  with:
    files: |
      README.txt
      xxx-xxx-xxx-binary

但是这个对比 actions/upload-release-asset 比较不人性化的一点就是不能在发布的时候改文件名。如果像我一样想跨平台编译多个可执行文件,就得先用 mv 把文件名改了。比如,我们编译完得到一个 out 文件( Windows 下可能是 out.exe ),我们可以这样写:

- name: Set binary path name (Unix)
  run: |
    mv ./out ./${{ runner.os }}-out
    echo "BINARY_PATH=./${{ runner.os }}-out" >> $GITHUB_ENV

上面这个 step 在 Unix 下工作得很完美。Github 会自动帮你填上 runner.os 里的内容,比如当前 job 在 ubuntu-latest 上运行,那 runner.os 就会是 Linux。在重命名完文件名后,还需要设置一个环境变量来记录文件的位置,也就是第二行的内容。然而:在 Windows 的 powershell 上,你不能写 $GITHUB_ENV,而是应该写成 $env:GITHUB_ENV。所以在 Windows 版本上,我们要改成下面这样:

- if: runner.os == "Windows"
  name: Set binary path name (Windows)
  run: |
    mv ./exe ./windows-exe
    echo "BINARY_PATH=./windows-exe" >> $env:GITHUB_ENV

设置完 BINARY_PATH 这个环境变量,接下来就可以进行发布了。

- name: Release
  uses: softprops/action-gh-release@v1
  with:
    files: ${{ env.BINARY_PATH }}

Matrix

如果想要在一个 job 里实现跨平台编译,那么 workflow 提供的 strategy matrix 就可以在此时派上用场。

在 job 内定义要编译的 os matrix ,之后 job 就会依次将 matrix 的字段替换成相应的值并重复执行。比如下面的 job 会执行三次,分别将 ${{ matrix.os }} 替换成 ubuntu-latest, macOS-latest, windows-latest 。

build:
  strategy:
    fail-fast: false
    matrix:
      os: [ubuntu-latest, macOS-latest, windows-latest]
  runs-on: ${{ matrix.os }}

这里 strategy 中 fail-fast 字段控制 job 是否在某个 matrix 任务失败后就立刻停止所有任务。我们想要在某个平台编译失败后继续编译其他平台的文件,因此我们将其设置为 false

upx-action

通常用 Haskell 写的项目编译出来的可执行文件都非常大。但我们可以在发布之前将编译完成的文件用 upx1 压缩一遍。令本调包侠非常开心的是同样有现成的包可以用,它叫做 svenstaro/upx-action

于是在编译结束后调用一次 upx 就可以了。

- name: Compress binary
  uses: svenstaro/upx-action@2.0.1
  with:
    file: ${{ env.BINARY_PATH }}

经过 upx 压缩的可执行文件或库文件通常能减少 50%~70% 的体积。

拼在一起

就变成了 https://paste.sr.ht/~u2x1/687b34785f784cc2f73715812b9fa443e142426a

于是万能的 Github Actions 现在会自动在每一次 git push origin v*.*.* 的时候帮你发 release 了。

RELEASE.png

RELEASE-1.png


  1. "The term UPX is a shorthand for the Ultimate Packer for eXecutables." website