前言
这个假期,我的时间基本上都投入到一个用Go语言写的CMS的开发中:GopherInk
完全是靠AI进行开发,一个AI写代码Cosplay程序员,一个AI做审计Cosplay安全负责人,互相掐架,我Cosplay产品经理,这不比游戏好玩多了?
其实也有一定原因是我开发完成NekoEcho主题后太上头了,于是打算继续Vibe Coding下一个更有意思的玩具:毕竟NekoEcho是我自用的主题,别人用不到,开发一个开源的玩具,然后发布,大家都能玩得到(和之前的恶搞病毒一样 bushi),岂不美哉?于是乎就有了这个项目的开发。
其实开发也有一段时间了,本来打算八月初上线的,后面因为家里面有一大堆破事烦着,再加上后面测试发现被CC的时候会造成严重的性能消耗,导致在低配VPS上面CPU和内存双双爆炸引发拒绝服务。所以我决定继续完善程序,延迟发布,反正又不是什么商业产品,只是自己的一个写来玩的玩具,没有KPI,没有甲方(我就是甲方),不如好好打磨好在端出来(虽然应该不会有什么人用就是了,无人在意这一块)。
废话了,接下来切入主题。
因为我希望支持多种数据库,并且希望做到零配置部署,那必然需要用到SQLite这个数据库。然而,对于使用Go语言,要使用这个数据库就需要引入CGO,造成glibc库的版本依赖。因为Go语言标准的 net 库和 sqlite3 的 C 驱动在动态链接 glibc 时,容易在不同发行版上遭遇 GLIBC_2.x 找不到的报错。对于希望做到下载后直接运行就能部署的CMS来说,这并不合适。而使用glibc又不好搞静态编译,于是,我看向了另一个选择——musl
musl是一个精简版本的C语言库,使用这个库典型的发行版就是Alpine Linux,也是老朋友了。而在这个发行版上面,我们可以实现完全的静态编译——因为C库足够精简。
而此时,我又希望能尝试一下在GitHub用一下传说中的自动构建,那就,开始吧。
配置 Workflows
要实现自动构建这个功能,需要在GitHub上面配置一个工作流:
在仓库创建.github/workflows/目录,然后把配置文件放在里面,例如build-beta.yaml
在配置这个文件的时候,我深刻体会到了国产AI和国外AI的差距:不是智商,而是训练数据集的污染。
得益于中文互联网中最大的技术噪音源CSDN(Copy Steal pay-Download Net)复制粘贴,杂乱无章的数据源污染,我试了几个国内的AI:通义千问,Deepseek,给我的yaml配置文件都是错漏百出,基本上都是各种花式报错。而把报错复制给他们,他们口口声声认错,接着继续给你一个稳定报错的答案。我试了好几次都搞不定,直接开魔法去问ChatGPT,直接轻松秒杀。
我的需求比较复杂,不是直接构建就了事。因为我的CMS设计的时候就是可拓展的架构,功能和外观可以靠插件和主题进行能力的拓展,这一点和Typecho一样。我是把一些实现比较复杂的主题单开了一个仓库进行管理,而作为基础功能补充的功能插件:例如虚拟文件、站点地图、友链管理,这些则是和CMS项目主体一起管理。但是,我希望能在构建“官方版本”的时候,额外把访客统计,服务器状态,邮件提醒这几个比较刚需的插件一起编译进来,让想用原版的可以直接一键启动。这就需要在构建时在特定的目录拉取其他仓库进行构建了。
别忘了,我还要配置Alpine环境用于构建,接下来就是ChatGPT帮我做好的版本,有相关需求的可以直接参考。
首先是build-beta.yaml,这个负责用于构建测试:
name: Beta Build Test
on:
push:
tags:
- 'v*beta*'
workflow_dispatch:
permissions:
contents: read
env:
GO_VERSION: '1.25.x'
jobs:
build-linux:
name: Linux (${{ matrix.goarch }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
platform: linux/amd64
goarm: ''
- goarch: arm64
platform: linux/arm64
goarm: ''
- goarch: 386
platform: linux/386
goarm: ''
- goarch: arm
platform: linux/arm/v7
goarm: '7'
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up QEMU
if: matrix.goarch == 'arm64' || matrix.goarch == 'arm'
uses: docker/setup-qemu-action@v4
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Build static Linux binary
shell: bash
run: |
set -euxo pipefail
mkdir -p dist
docker run --rm \
--platform "${{ matrix.platform }}" \
-v "${{ github.workspace }}:/app" \
-w /app \
golang:1.25-alpine \
sh -euxc '
apk add --no-cache gcc musl-dev
# Builder 必须先按当前容器平台编译,不能让目标 GOOS/GOARCH 污染 go run。
GOWORK=off GOFLAGS= \
go build -trimpath -o /tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=linux \
GOARCH=${{ matrix.goarch }} \
GOARM=${{ matrix.goarm }} \
GOFLAGS= \
/tmp/gopherink-builder \
-trimpath \
-ldflags="-linkmode external -extldflags=-static -s -w" \
-o "/app/dist/gopherink-linux-${{ matrix.goarch }}"
'
- name: Verify Linux binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-linux-${{ matrix.goarch }}"
- name: Upload Linux artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-linux-${{ matrix.goarch }}
archive: false
if-no-files-found: error
retention-days: 7
build-darwin:
name: macOS (${{ matrix.goarch }})
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
runner: macos-15-intel
- goarch: arm64
runner: macos-15
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Build macOS binary
shell: bash
run: |
set -euxo pipefail
mkdir -p .tmp dist
# 先构建宿主平台可执行的 builder。
GOWORK=off GOFLAGS= \
go build -trimpath -o .tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=darwin \
GOARCH=${{ matrix.goarch }} \
GOFLAGS= \
./.tmp/gopherink-builder \
-trimpath \
-ldflags="-s -w" \
-o "dist/gopherink-darwin-${{ matrix.goarch }}"
- name: Verify macOS binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-darwin-${{ matrix.goarch }}"
- name: Upload macOS artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-darwin-${{ matrix.goarch }}
archive: false
if-no-files-found: error
retention-days: 7
build-windows:
name: Windows (${{ matrix.goarch }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
cc: x86_64-w64-mingw32-gcc
pkg: gcc-mingw-w64-x86-64
- goarch: 386
cc: i686-w64-mingw32-gcc
pkg: gcc-mingw-w64-i686
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Install MinGW
shell: bash
run: |
set -euxo pipefail
sudo apt-get update
sudo apt-get install -y "${{ matrix.pkg }}"
- name: Build Windows binary
shell: bash
run: |
set -euxo pipefail
mkdir -p .tmp dist
# 这里不能在 GOOS=windows 的环境里执行 go run builder;
# 否则 builder 自己会先被编译成 Windows 程序,Ubuntu 无法执行。
GOWORK=off GOFLAGS= \
go build -trimpath -o .tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=windows \
GOARCH=${{ matrix.goarch }} \
CC=${{ matrix.cc }} \
GOFLAGS= \
./.tmp/gopherink-builder \
-trimpath \
-ldflags="-linkmode external -extldflags=-static -s -w" \
-o "dist/gopherink-windows-${{ matrix.goarch }}.exe"
- name: Verify Windows binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-windows-${{ matrix.goarch }}.exe"
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-windows-${{ matrix.goarch }}.exe
archive: false
if-no-files-found: error
retention-days: 7然后是build-release.yaml,这个负责用于构建测试:
name: Stable Build and Release
on:
push:
tags:
- 'v*'
- '!v*beta*'
permissions:
contents: read
env:
GO_VERSION: '1.25.x'
jobs:
build-linux:
name: Linux (${{ matrix.goarch }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
platform: linux/amd64
goarm: ''
- goarch: arm64
platform: linux/arm64
goarm: ''
- goarch: 386
platform: linux/386
goarm: ''
- goarch: arm
platform: linux/arm/v7
goarm: '7'
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up QEMU
if: matrix.goarch == 'arm64' || matrix.goarch == 'arm'
uses: docker/setup-qemu-action@v4
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Build static Linux binary
shell: bash
run: |
set -euxo pipefail
mkdir -p dist
docker run --rm \
--platform "${{ matrix.platform }}" \
-v "${{ github.workspace }}:/app" \
-w /app \
golang:1.25-alpine \
sh -euxc '
apk add --no-cache gcc musl-dev
# Builder 必须先按当前容器平台编译,不能让目标 GOOS/GOARCH 污染 go run。
GOWORK=off GOFLAGS= \
go build -trimpath -o /tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=linux \
GOARCH=${{ matrix.goarch }} \
GOARM=${{ matrix.goarm }} \
GOFLAGS= \
/tmp/gopherink-builder \
-trimpath \
-ldflags="-linkmode external -extldflags=-static -s -w" \
-o "/app/dist/gopherink-linux-${{ matrix.goarch }}"
'
- name: Verify Linux binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-linux-${{ matrix.goarch }}"
- name: Upload Linux artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-linux-${{ matrix.goarch }}
archive: false
if-no-files-found: error
retention-days: 7
build-darwin:
name: macOS (${{ matrix.goarch }})
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
runner: macos-15-intel
- goarch: arm64
runner: macos-15
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Build macOS binary
shell: bash
run: |
set -euxo pipefail
mkdir -p .tmp dist
# 先构建宿主平台可执行的 builder。
GOWORK=off GOFLAGS= \
go build -trimpath -o .tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=darwin \
GOARCH=${{ matrix.goarch }} \
GOFLAGS= \
./.tmp/gopherink-builder \
-trimpath \
-ldflags="-s -w" \
-o "dist/gopherink-darwin-${{ matrix.goarch }}"
- name: Verify macOS binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-darwin-${{ matrix.goarch }}"
- name: Upload macOS artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-darwin-${{ matrix.goarch }}
archive: false
if-no-files-found: error
retention-days: 7
build-windows:
name: Windows (${{ matrix.goarch }})
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- goarch: amd64
cc: x86_64-w64-mingw32-gcc
pkg: gcc-mingw-w64-x86-64
- goarch: 386
cc: i686-w64-mingw32-gcc
pkg: gcc-mingw-w64-i686
steps:
- name: Checkout main repository
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Clone plugins
shell: bash
run: |
set -euxo pipefail
mkdir -p plugins
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
https://github.com/Chocola-X/GopherInk-CommentNotifier \
https://github.com/Chocola-X/GopherInk-VistorLogger
do
name="$(basename "$repo")"
rm -rf "plugins/$name"
git clone --depth 1 "$repo" "plugins/$name"
done
- name: Install MinGW
shell: bash
run: |
set -euxo pipefail
sudo apt-get update
sudo apt-get install -y "${{ matrix.pkg }}"
- name: Build Windows binary
shell: bash
run: |
set -euxo pipefail
mkdir -p .tmp dist
# 这里不能在 GOOS=windows 的环境里执行 go run builder;
# 否则 builder 自己会先被编译成 Windows 程序,Ubuntu 无法执行。
GOWORK=off GOFLAGS= \
go build -trimpath -o .tmp/gopherink-builder ./cmd/gopherink-builder
CGO_ENABLED=1 \
GOOS=windows \
GOARCH=${{ matrix.goarch }} \
CC=${{ matrix.cc }} \
GOFLAGS= \
./.tmp/gopherink-builder \
-trimpath \
-ldflags="-linkmode external -extldflags=-static -s -w" \
-o "dist/gopherink-windows-${{ matrix.goarch }}.exe"
- name: Verify Windows binary
shell: bash
run: |
set -euxo pipefail
file "dist/gopherink-windows-${{ matrix.goarch }}.exe"
- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
path: dist/gopherink-windows-${{ matrix.goarch }}.exe
archive: false
if-no-files-found: error
retention-days: 7
release:
name: Publish Release
# 二次保险:即使触发过滤规则以后被改动,beta 仍然不会发布。
if: github.ref_type == 'tag' && !contains(github.ref_name, 'beta')
needs:
- build-linux
- build-darwin
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download build outputs
uses: actions/download-artifact@v8
with:
pattern: gopherink-*
path: dist
merge-multiple: true
- name: Prepare release packages
shell: bash
run: |
set -euxo pipefail
mkdir -p release package
# Linux
for arch in amd64 arm64 386 arm; do
src="dist/gopherink-linux-$arch"
pkg="GopherInk-linux-$arch"
test -f "$src"
mkdir -p "package/$pkg"
cp "$src" "package/$pkg/gopherink"
chmod +x "package/$pkg/gopherink"
tar -C package -czf "release/$pkg.tar.gz" "$pkg"
done
# macOS
for arch in amd64 arm64; do
src="dist/gopherink-darwin-$arch"
pkg="GopherInk-darwin-$arch"
test -f "$src"
mkdir -p "package/$pkg"
cp "$src" "package/$pkg/gopherink"
chmod +x "package/$pkg/gopherink"
tar -C package -czf "release/$pkg.tar.gz" "$pkg"
done
# Windows
for arch in amd64 386; do
src="dist/gopherink-windows-$arch.exe"
pkg="GopherInk-windows-$arch"
test -f "$src"
mkdir -p "package/$pkg"
cp "$src" "package/$pkg/gopherink.exe"
(
cd package
zip -9 -r "../release/$pkg.zip" "$pkg"
)
done
- name: Generate checksums
shell: bash
run: |
set -euxo pipefail
cd release
sha256sum *.tar.gz *.zip > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
prerelease: false
generate_release_notes: true
fail_on_unmatched_files: true
files: |
release/*.tar.gz
release/*.zip
release/SHA256SUMS.txt其实两个构建的脚本是高度相似的,只不过一个只是用于构建测试而不发布release,另一个用于构建并发布release。
详细解释
这两个配置文件看起来又长又吓人,其实骨子里逻辑非常清晰。既然都踩了这么多坑,干脆给大家把里面的核心“黑魔法”掰碎了讲讲。这两个配置(Beta版和Release版)大部分步骤是重合的,核心差异就在于触发条件和是否打包发布。
触发器与二次保险
在 build-beta.yaml 中,我设定了当推送包含 v*beta* 标签时(比如 v1.0.0-beta.1),或者手动点击按钮(workflow_dispatch)时触发。这主要是为了我自己开发阶段测试编译通不通。
而在 build-release.yaml 中,触发条件变成了拦截正常的版本号(比如 v1.0.0),并且显式排除了 beta 标签(!v*beta*)。不仅如此,在最后的 release 步骤里,我还加了一道锁:
if: github.ref_type == 'tag' && !contains(github.ref_name, 'beta')这就保证了无论怎么瞎折腾,Beta版本绝对不会被当成正式版发布出去。
多平台矩阵构建
GitHub Actions 的 Matrix 功能是真的香。我们不需要为每个系统写一遍冗长的流程,直接定义一个矩阵:
- Linux: 包含了
amd64,arm64,386,arm四个架构。 - macOS: 包含原生 Apple Silicon (
arm64) 和老牌 Intel (amd64)。 - Windows: 包含
amd64和386。
流水线会像影分身一样,同时启动多个任务并行构建,极大缩短了编译时间。为了让 ARM 架构的编译在普通的 Ubuntu 宿主机上跑起来,我还特意引入了 docker/setup-qemu-action@v4 来提供 QEMU 模拟器支持。
插件热插拔:构建前的“偷梁换柱”
正如前文所说,为了做出一键启动的“官方满血版”,我需要在编译时把分离的插件仓库拉过来。
for repo in \
https://github.com/Chocola-X/GopherInk-ServerInfo \
...
do
name="$(basename "$repo")"
git clone --depth 1 "$repo" "plugins/$name"
done这段 Shell 脚本会在真正执行 go build 之前,通过 git clone --depth 1(只拉取最新提交,省时省力)把必要的插件拉取到代码树的 plugins 目录下,随后顺理成章地被 Go 编译器一起打包进最终的二进制文件里。
搞定 CGO 交叉编译的三重境界
这部分是整个 Workflow 最值钱、也是国内很多 AI 经常胡言乱语的地方。因为用到了 C 语言写的 SQLite,必须开启 CGO_ENABLED=1,而一旦开启 CGO,交叉编译就会变成一场噩梦。来看看我是怎么分系统解决的:
- Linux 环境 (Docker + Alpine + Musl)
我没有在 GitHub 默认的 Ubuntu 环境里硬磕,而是直接启动了一个纯净的golang:1.25-alpineDocker 容器。在这个容器里安装gcc和musl-dev。关键点来了:
编译时传入参数-ldflags="-linkmode external -extldflags=-static -s -w",强制使用外部链接器并将所有 C 库全部静态打包。这样生成的二进制文件,不管是丢进 CentOS、Debian 还是各种奇葩套壳 Linux,统统能直接跑起来,彻底告别glibc缺失的报错。 - macOS 环境 (原生 Runner)
对于 Mac,省事很多。我直接使用了 GitHub 提供的macos-15和macos-15-intel运行环境。因为是同平台编译(Mac 编 Mac),CGO 只要开启,用系统自带的 Clang 就能顺滑通过,不用搞一堆额外的交叉编译工具链。 - Windows 环境 (Ubuntu + MinGW)
让 Windows 环境去编译 CGO 经常会遇到各种匪夷所思的环境变量问题,所以我是在 Ubuntu 上跨平台编译 Windows 程序。
绝招就是通过 apt-get install -y gcc-mingw-w64 安装 MinGW 工具链,然后在编译时指定 CC=x86_64-w64-mingw32-gcc (64位) 或 CC=i686-w64-mingw32-gcc (32位),搭配静态链接参数,完美输出 .exe 文件。
避免宿主机污染
你可能注意到了,我在所有的编译命令前,都先单独跑了一次这一段:
GOWORK=off GOFLAGS= go build -trimpath -o /tmp/gopherink-builder ./cmd/gopherink-builder
这是因为我在项目中写了一个辅助构建工具 gopherink-builder。如果直接在开启了交叉编译环境变量(比如 GOOS=windows)的上下文中用 go run 去跑它,编译器会试图把这个 builder 编译成 Windows 程序,然后在 Ubuntu 系统上执行它——那当然会当场暴毙报错!
所以我先用宿主机的默认环境(关闭各种花哨 flag)把 builder 编译出来,再把它用在后续的目标构建流程中。
6. 打包与分发 (Release 专属)
最后,当所有平台的编译物(Artifacts)汇聚在一起后,build-release.yaml 的最后一个任务开始收尾:
- 将 Linux 和 macOS 的二进制文件赋予执行权限(
chmod +x),打包成.tar.gz。 - 将 Windows 的
.exe打包成.zip。 - 生成防伪用的
SHA256SUMS.txt校验文件。 - 最后调用
softprops/action-gh-release,直接发版到 GitHub Releases。
至此,只要我推一个 v1.x.x 的标签,喝杯咖啡的功夫,全平台的静态编译包、校验文件就已经整整齐齐地躺在发布页里了。