很多开源项目在刚开始时,发布新版本的流程通常是这样的:

  1. 本地切到准备发布的提交;
  2. 编译 Windows、Linux、macOS 版本;
  3. 手动压缩;
  4. 打 Tag;
  5. 打开 GitHub 的 Releases 页面;
  6. 新建 Release;
  7. 一份一份上传安装包;
  8. 再手写一遍更新说明。

项目小的时候,这套流程似乎没什么问题。但只要版本开始频繁发布,或者需要同时维护多个平台,手工发版很快就会变成一件又慢、又容易出错的事情。

好消息是,GitHub Actions 本身就足以把这一整套流程自动化。

这篇文章从零搭一套完整的发布流水线:以后发布版本时,只需要创建一个 Git Tag 并推送到远端,GitHub Actions 就会自动完成构建、打包、汇总、生成校验值、创建 Release 和上传附件。

本文以一个最小 Go 项目作为可以直接运行的示例,但真正重要的是整套工作流结构。Node.js、Rust、Java、Tauri、Electron、C/C++ 项目都可以沿用,只需要替换中间的“构建”步骤。

本文按 2026 年 8 月 GitHub.com 当前版本编写,示例使用 actions/checkout@v6actions/setup-go@v7actions/upload-artifact@v7actions/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 中的多平台构建产物示例

图: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:

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 上传安装包后的界面示例

图: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 真正应该解决的问题。


参考资料