很多开源项目在刚开始时,发布新版本的流程通常是这样的:
- 本地切到准备发布的提交;
- 编译 Windows、Linux、macOS 版本;
- 手动压缩;
- 打 Tag;
- 打开 GitHub 的 Releases 页面;
- 新建 Release;
- 一份一份上传安装包;
- 再手写一遍更新说明。
项目小的时候,这套流程似乎没什么问题。但只要版本开始频繁发布,或者需要同时维护多个平台,手工发版很快就会变成一件又慢、又容易出错的事情。
好消息是,GitHub Actions 本身就足以把这一整套流程自动化。
这篇文章从零搭一套完整的发布流水线:以后发布版本时,只需要创建一个 Git Tag 并推送到远端,GitHub Actions 就会自动完成构建、打包、汇总、生成校验值、创建 Release 和上传附件。
本文以一个最小 Go 项目作为可以直接运行的示例,但真正重要的是整套工作流结构。Node.js、Rust、Java、Tauri、Electron、C/C++ 项目都可以沿用,只需要替换中间的“构建”步骤。
本文按 2026 年 8 月 GitHub.com 当前版本编写,示例使用
actions/checkout@v6、actions/setup-go@v7、actions/upload-artifact@v7和actions/download-artifact@v8。以后阅读时如果官方 Action 已更新大版本,请优先参考其官方仓库说明。
一、最终效果是什么?
我们的目标很简单:
本地代码
│
├─ git tag v1.0.0
│
└─ git push origin v1.0.0
│
▼
GitHub Actions
│
┌─────┼──────────┐
▼ ▼ ▼
Windows Linux macOS
│ │ │
└─────┼──────────┘
▼
Build Artifact
│
▼
Release Job
│
├─ SHA256SUMS.txt
├─ 自动生成 Release Note
└─ 上传所有安装包
│
▼
GitHub Release v1.0.0
以后发布一个版本,只需要:
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
剩下的事情交给 GitHub。
下面这种 Release 页面,就是我们最终想得到的效果:版本号、更新说明、不同平台的二进制包都集中在 Assets 中。

图:GitHub Release 的 Assets 区域示例。图片来自掘金 CDN,在国内网络下通常比直接引用 GitHub Raw 图片稳定。
二、先分清 Tag、Artifact 和 Release
第一次写自动发布流程时,最容易混淆的其实不是 YAML,而是这三个概念。
1. Git Tag
Tag 是 Git 仓库里的一个“固定标记”,通常用来指向某个正式版本对应的提交。
例如:
v1.0.0 -> commit 8e2f4c1
v1.1.0 -> commit a93cf25
v2.0.0 -> commit db73c11
GitHub Release 本身也是建立在 Git Tag 之上的。
2. Actions Artifact
Artifact 是 一次 Workflow 运行过程中产生的临时构建产物。
比如:
release-windows-amd64
release-linux-amd64
release-darwin-arm64
它主要解决的是“不同 Job 之间怎么传文件”以及“构建结束后怎么临时保存产物”。
GitHub Actions 的运行页面底部就可以看到 Artifacts:

图:矩阵构建结束后产生的多个 Artifact。
3. GitHub Release Asset
Release Asset 才是最终用户在 Releases 页面里下载的附件,比如:
demo-v1.0.0-windows-amd64.zip
demo-v1.0.0-linux-amd64.tar.gz
demo-v1.0.0-darwin-arm64.tar.gz
SHA256SUMS.txt
可以把三者理解成:
Tag = 这个版本对应哪一次代码
Artifact = CI 中间产生并传递的文件
Release = 面向最终用户发布的版本
Asset = Release 页面真正提供下载的附件
这几个概念分清以后,下面的 Workflow 就很好理解了。
三、准备一个最小项目
如果你已经有自己的项目,可以直接跳到下一节。
为了让整篇教程能够完整复现,这里用一个最简单的 Go 程序演示。
新建目录:
mkdir github-actions-release-demo
cd github-actions-release-demo
go mod init example.com/github-actions-release-demo
创建 main.go:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("Hello from %s/%s!\n", runtime.GOOS, runtime.GOARCH)
}
本地先测试:
go run .
正常情况下会看到类似:
Hello from windows/amd64!
提交到 Git:
git add .
git commit -m "feat: initial release demo"
git push
四、创建 GitHub Actions 工作流
GitHub Actions 的 Workflow 文件必须放在:
.github/workflows/
我们创建:
.github/workflows/release.yml
最终目录大概是这样:
github-actions-release-demo/
├── .github/
│ └── workflows/
│ └── release.yml
├── go.mod
├── main.go
├── README.md
└── LICENSE
接下来一步一步写。
五、第一步:只在推送 Tag 时触发
先写最外层:
name: Release
on:
push:
tags:
- 'v*'
这段配置的含义是:
只有当远端收到一个以
v开头的 Tag 时,才执行这个 Workflow。
例如:
v1.0.0 ✅
v1.2.3 ✅
v2.0.0-beta ✅
1.0.0 ❌
release-1.0 ❌
如果你的项目严格使用 v1.2.3 这种版本号,可以把团队规范固定下来,避免出现 release-final-final-2 之类难以维护的 Tag。
注意:
git tag v1.0.0
只会在本地创建 Tag。
你还必须执行:
git push origin v1.0.0
Workflow 才会真正触发。
六、第二步:给默认 Token 最小权限
继续添加:
permissions:
contents: read
GitHub Actions 每个 Job 都可以拿到一个由 GitHub 自动生成的 GITHUB_TOKEN。我们不需要为了创建同仓库 Release 再单独申请一个 Personal Access Token。
但权限不要一上来就全部放开。
构建 Job 只需要读仓库,所以顶层给:
permissions:
contents: read
真正发布 Release 的 Job 再单独提升到:
permissions:
contents: write
这样比给整个 Workflow 全局写权限更合理。
七、第三步:矩阵构建多个平台
现在开始写 build Job。
jobs:
build:
name: Build ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: windows
goarch: amd64
ext: .exe
archive: zip
- goos: linux
goarch: amd64
ext: ''
archive: tar.gz
- goos: darwin
goarch: amd64
ext: ''
archive: tar.gz
- goos: darwin
goarch: arm64
ext: ''
archive: tar.gz
这里定义了四个目标:
Windows x64
Linux x64
macOS Intel
macOS Apple Silicon
因为纯 Go 项目可以交叉编译,所以这里四个目标都放在 ubuntu-latest 上跑,速度快、配置也简单。
如果你的项目使用 CGO,或者是 Tauri、Electron、Qt、原生 C/C++ GUI 等需要真实系统环境打包的项目,后面会讲如何改成 Windows / macOS / Linux 原生 Runner 矩阵。
fail-fast: false 也值得保留。
如果 Windows 构建失败,我们通常仍然希望 Linux 和 macOS 的构建继续完成,这样更容易一次性看清楚到底哪些平台出了问题。
八、第四步:Checkout 和准备 Go 环境
添加 Steps:
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
checkout 会把触发当前 Workflow 的代码检出到 Runner。
因为本次 Workflow 是被 Tag 触发的,所以检出的就是这个 Tag 对应的代码,而不是“此时 main 分支最新的代码”。
这一点非常重要。
例如:
main 当前: commit C
v1.0.0 指向: commit B
你推送 v1.0.0 时,构建的应该是 B,而不是后来已经继续开发的 C。
这也是为什么正式 Release 应该绑定 Tag,而不是简单地“构建 main 最新代码”。
九、第五步:编译并打包
加入构建步骤:
- name: Build and package
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build package
go build \
-trimpath \
-ldflags="-s -w" \
-o "build/demo${{ matrix.ext }}" \
.
[ -f README.md ] && cp README.md build/ || true
[ -f LICENSE ] && cp LICENSE build/ || true
if [ "${{ matrix.archive }}" = "zip" ]; then
(
cd build
zip -r \
"../package/demo-${GITHUB_REF_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}.zip" \
.
)
else
tar -C build -czf \
"package/demo-${GITHUB_REF_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" \
.
fi
这里最值得注意的是:
${GITHUB_REF_NAME}
当 Workflow 由:
refs/tags/v1.0.0
触发时,GitHub 会提供:
GITHUB_REF_NAME=v1.0.0
因此最后得到的文件名会自动带上版本号:
demo-v1.0.0-windows-amd64.zip
demo-v1.0.0-linux-amd64.tar.gz
demo-v1.0.0-darwin-amd64.tar.gz
demo-v1.0.0-darwin-arm64.tar.gz
这比每次在 YAML 里手工改版本号可靠得多。
十、第六步:上传 Artifact
每个矩阵任务构建完以后,把它上传成 Artifact:
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
path: package/*
if-no-files-found: error
这里故意让每个平台的 Artifact 名称不同:
release-windows-amd64
release-linux-amd64
release-darwin-amd64
release-darwin-arm64
这是一个很重要的细节。
新版 upload-artifact 的 Artifact 是不可变的,同一个矩阵里的多个 Job 不应该反复向同名 Artifact 追加文件。给每个平台一个唯一名称,可以避免冲突。
同时:
if-no-files-found: error
意味着如果构建脚本因为路径写错,结果一个安装包都没找到,Workflow 会直接失败。
发版流程里,我更推荐“明确失败”,而不是构建了一个空 Release 以后才发现附件没上传。
十一、第七步:汇总所有构建产物
构建结束后,我们增加第二个 Job:
release:
name: Publish Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
其中:
needs: build
表示:
只有
build矩阵全部执行结束以后,才开始发布。
接着下载所有 Artifact:
- name: Download artifacts
uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true
pattern 会匹配前面所有:
release-*
而:
merge-multiple: true
会把多个 Artifact 的内容统一放到 dist/ 目录。
最后得到:
dist/
├── demo-v1.0.0-windows-amd64.zip
├── demo-v1.0.0-linux-amd64.tar.gz
├── demo-v1.0.0-darwin-amd64.tar.gz
└── demo-v1.0.0-darwin-arm64.tar.gz
到这里,跨 Job 文件传递就完成了。
十二、顺手生成 SHA256 校验文件
既然都自动发版了,就别只上传安装包。
加入:
- name: Generate checksums
run: |
cd dist
sha256sum * > SHA256SUMS.txt
之后 Release 中会多一个:
SHA256SUMS.txt
内容类似:
6737be... demo-v1.0.0-linux-amd64.tar.gz
ba4901... demo-v1.0.0-windows-amd64.zip
...
用户下载以后就能校验:
sha256sum -c SHA256SUMS.txt
Windows PowerShell 也可以:
Get-FileHash .\demo-v1.0.0-windows-amd64.zip -Algorithm SHA256
对于公开分发的软件,这一步几乎没有额外成本,却比“扔几个 zip 上去就算发版”完整得多。
十三、不用第三方 Release Action,直接用 GitHub CLI
网上很多旧教程会写:
uses: softprops/action-gh-release@...
或者其他第三方发布 Action。
它们当然可以用,但这篇教程故意不需要它们。
GitHub 官方说明中,GitHub-hosted Runner 已经预装 gh CLI,因此创建 Release 可以直接执行:
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" dist/* \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--generate-notes \
--verify-tag
这里几个参数分别是:
GH_TOKEN
GH_TOKEN: ${{ github.token }}
GitHub CLI 会读取这个 Token 进行认证。
同仓库发 Release 时,我们使用 Actions 自动生成的 Token 即可,不必把自己的 PAT 塞进 Secrets。
--generate-notes
让 GitHub 自动生成 Release Notes。
GitHub 会根据从上一个 Release 到当前版本之间的提交、Pull Request 和贡献者生成说明。
--verify-tag
这个参数我强烈建议保留。
因为 gh release create 本身支持在 Tag 不存在时自动创建 Tag,而我们的设计是:
Tag 是发版入口,Release 必须建立在已经明确推送的 Tag 上。
因此使用:
--verify-tag
如果远端没有这个 Tag,就直接失败,避免意外从错误的提交创建 Release。
十四、完整 release.yml
到这里,整个文件可以直接合并成下面这样:
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: windows
goarch: amd64
ext: .exe
archive: zip
- goos: linux
goarch: amd64
ext: ''
archive: tar.gz
- goos: darwin
goarch: amd64
ext: ''
archive: tar.gz
- goos: darwin
goarch: arm64
ext: ''
archive: tar.gz
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Build and package
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build package
go build \
-trimpath \
-ldflags="-s -w" \
-o "build/demo${{ matrix.ext }}" \
.
[ -f README.md ] && cp README.md build/ || true
[ -f LICENSE ] && cp LICENSE build/ || true
if [ "${{ matrix.archive }}" = "zip" ]; then
(
cd build
zip -r \
"../package/demo-${GITHUB_REF_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}.zip" \
.
)
else
tar -C build -czf \
"package/demo-${GITHUB_REF_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" \
.
fi
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
path: package/*
if-no-files-found: error
release:
name: Publish Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v8
with:
pattern: release-*
path: dist
merge-multiple: true
- name: Generate checksums
run: |
cd dist
sha256sum * > SHA256SUMS.txt
- name: Show files
run: ls -lah dist
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" dist/* \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--generate-notes \
--verify-tag
把它提交:
git add .github/workflows/release.yml
git commit -m "ci: add automatic release workflow"
git push
到这里,自动发版系统已经搭好了。
十五、第一次正式发版
假设现在要发布:
v1.0.0
建议先确认工作区干净:
git status
再确认你准备发布的提交:
git log --oneline -5
创建一个 annotated tag:
git tag -a v1.0.0 -m "Release v1.0.0"
检查:
git tag
然后推送:
git push origin v1.0.0
这一步完成以后,本地已经不需要继续做任何事情。
打开仓库:
Actions -> Release
你应该能看到 Workflow 自动开始执行。
构建成功后,运行页底部会先出现多个 Artifact;随后 Publish Release Job 会把它们汇总起来并创建正式 Release。
最终在 Releases 页面中会出现类似下面这样的 Assets:

图:GitHub Release 中附加构建包的示例。图片来源于 macrozheng.com,国内网络通常可以直接访问。
十六、Tag 建议怎么命名?
如果项目没有特殊需求,我建议直接遵循语义化版本:
v主版本.次版本.修订版本
例如:
v1.0.0
v1.1.0
v1.1.1
v2.0.0
一个非常简化的判断方式是:
修 Bug
v1.1.0 -> v1.1.1
增加兼容的新功能
v1.1.1 -> v1.2.0
出现不兼容的大改动
v1.2.0 -> v2.0.0
预发布版本可以写:
v2.0.0-alpha.1
v2.0.0-beta.1
v2.0.0-rc.1
由于我们的触发器使用:
tags:
- 'v*'
这些版本都会触发发布。
如果希望带 -beta、-rc 的 Tag 自动标记成 GitHub Pre-release,可以再在 Workflow 中判断 Tag 名称,然后给 gh release create 加 --prerelease。这可以作为后续扩展。
十七、如果我是 Tauri、Electron、Rust 或 Java 项目怎么办?
整套结构基本不变:
Tag Trigger
↓
Build Matrix
↓
Upload Artifact
↓
Download Artifact
↓
Create Release
你只需要替换中间的构建命令。
Node.js / 前端项目
例如:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
然后上传:
path: dist/**
Rust
核心构建命令换成:
cargo build --release
Java / Gradle
可以换成:
./gradlew build
然后上传:
build/libs/*.jar
Tauri / Electron / 原生 GUI
这类项目往往不能像纯 Go 一样全部在 Ubuntu 上交叉编译。
这时应该让矩阵直接决定 Runner:
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
这样:
Windows 安装包 -> Windows Runner 构建
macOS .dmg -> macOS Runner 构建
Linux AppImage -> Linux Runner 构建
构建完成以后仍然使用相同的 upload-artifact -> download-artifact -> gh release create 链路即可。
换句话说,自动发布的骨架和你的编程语言没有太大关系。真正变化的通常只有 build/package 那几步。
十八、为什么不让每个 Build Job 直接上传 Release?
很多人第一次写矩阵发布时,会让 Windows、Linux、macOS 三个 Job 分别调用 Release API 上传附件。
理论上能做,实际很容易出现竞争:
Windows Job ─┐
Linux Job ─┼─> 同时修改 v1.0.0 Release
macOS Job ─┘
有的 Job 在创建 Release,有的 Job 在上传附件,有的 Job 可能因为 API 状态或重复资源直接失败。
更稳妥的结构是:
多个 Build Job
↓
各自产生 Artifact
↓
唯一 Release Job
↓
一次性创建 Release
这也是为什么本文把“构建”和“发布”拆成两个 Job。
构建可以并行,发布应该集中处理。
十九、几个很常见的坑
1. 我打了 Tag,Actions 为什么没动?
先检查 Tag 是否真的推到了远端:
git ls-remote --tags origin
只执行:
git tag v1.0.0
是不够的。
还必须:
git push origin v1.0.0
2. 创建 Release 报 403
重点检查:
release:
permissions:
contents: write
如果仓库或组织对 Actions Token 有额外限制,也要到仓库的 Actions 设置中检查 Workflow 权限策略。
不要看到 403 就条件反射地创建一个长期 PAT。
对于“同仓库创建 Release”这种场景,优先把内置 GITHUB_TOKEN 权限配正确。
3. Artifact 名称冲突
不要让矩阵任务全部这样写:
name: release
而应该包含平台信息:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
新版 Artifact 是不可变对象,矩阵 Job 使用唯一名字更稳妥。
4. Linux 二进制下载以后没有执行权限
Artifact 在默认压缩/解压过程中并不保证保留 Unix 文件权限。
所以对 Linux/macOS 发布包,我更推荐先自己打成:
.tar.gz
再把这个压缩包作为 Artifact 上传。
这样文件权限由 tar 包内部保存,而不是指望 Artifact 的目录压缩行为替你保留。
5. 重新运行 Workflow,Release 已经存在
gh release create v1.0.0 的语义是“新建”。
如果第一次已经成功创建 Release,但后面的某一步出了问题,再整套重跑就可能遇到“Release already exists”。
正式项目可以把发布设计成两种策略之一:
不可重复发布:
同一个 Tag 一旦发布就不允许覆盖。发现错误就打 v1.0.1。
这其实是我更推荐的方式。
或者明确实现“更新已有 Release”:
gh release upload v1.0.0 ./dist/* --clobber
但要注意,覆盖历史发布包会降低版本的可追溯性。
6. 自托管 Runner 突然跑不了新版 Action
2026 年的官方 Action 已陆续迁移到较新的 Node.js Runtime。
如果使用 GitHub-hosted Runner,一般不需要操心。
如果使用 self-hosted Runner,就要保持 Runner 本身及时更新。否则可能出现 Workflow YAML 没改,但 Action 升级后突然无法运行的情况。
本文也主要面向 GitHub.com。GitHub Enterprise Server 对新版 Artifact Action 的支持节奏与 GitHub.com 不完全一致,使用 GHES 时请以对应版本官方文档为准。
二十、再进一步:自动整理 Release Notes
我们现在用了:
--generate-notes
GitHub 已经可以自动生成版本说明。
如果项目开始有比较稳定的 Pull Request 和 Label 规范,可以继续创建:
.github/release.yml
例如:
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: '🚀 New Features'
labels:
- enhancement
- feature
- title: '🐛 Bug Fixes'
labels:
- bug
- title: '📦 Dependencies'
labels:
- dependencies
- title: 'Other Changes'
labels:
- '*'
之后 GitHub 自动生成的 Release Notes 就不会再是一大坨提交记录,而会按功能、Bug、依赖更新自动归类。
这一步特别适合多人维护的开源项目。
二十一、安全上再多做一步
Actions 本质上也是代码,而且运行在拥有仓库 Token 的环境里。
本文已经尽量减少第三方 Action,只使用 GitHub 官方 actions/* 和预装的 gh CLI。
如果是要求更高的生产仓库,还可以进一步:
- 把 Action 固定到完整 Commit SHA,而不是只写大版本 Tag;
- 给每个 Job 单独设置最小
permissions; - 不把长期 PAT 当成万能钥匙;
- 对发布产物生成 SHA256;
- 进一步接入 Artifact Attestation、代码签名或平台签名;
- 对正式 Release 开启保护流程和人工审批。
CI/CD 自动化的目标不是“少点几下鼠标”这么简单。
真正有价值的是:让同一个版本无论今天、下周还是换一个维护者来发,都走完全相同、可以审计的流程。
二十二、最后把完整发版动作压缩成三行
工作流配置好以后,以后日常发版真的只剩:
git tag -a v1.2.0 -m "Release v1.2.0"
git push origin v1.2.0
然后去 GitHub Actions 看结果。
自动化系统会负责:
检出 Tag 对应代码
↓
多平台构建
↓
打包
↓
上传 Artifact
↓
汇总所有平台产物
↓
生成 SHA256SUMS
↓
生成 Release Notes
↓
创建 GitHub Release
↓
上传安装包
当一套发布流程稳定以后,你会发现一个很明显的变化:
以前“发版本”是一项需要集中注意力完成的操作;以后它只是开发流程最后一个确定性的自动步骤。
而这,才是 CI/CD 真正应该解决的问题。
参考资料
GitHub Actions 从零自动发布 Release:打 Tag 后自动构建、上传多平台安装包
https://wangling.hauchet.cn/archives/github-actions-tag-auto-release-build-upload-assets
评论