首页
/ GitHub CLI 发布机制实战:script/release、deployment.yml 工作流与多平台签名全解析

GitHub CLI 发布机制实战:script/release、deployment.yml 工作流与多平台签名全解析

2026-09-07 17:52:43作者:何举烈Damon

本篇技术指南以 GitHub CLI 官方仓库的 docs/releasing.md 为核心,完整讲解维护者如何通过 script/release 一条命令发起生产发布、deployment.yml 工作流如何并行构建并签名 Linux/macOS/Windows 制品,以及发布后的制品如何流入 GitHub Release 与官方包仓库。读完本文,你将能够独立执行 staging 与本地构建测试、读懂每个签名环节(macOS 公证、Azure 可信签名、GPG 包签名)的底层实现,并掌握失败发布的回滚与重发流程。

一条命令发起生产发布:script/release

发起一次新的生产部署只需在仓库根目录执行:

script/release vX.Y.Z

更多参数可通过 script/release --help 查看。需要注意:该部署工作流的运行需要维护者审批(工作流绑定 production environment,触发后需手动批准)。

script/release 的源码看,该脚本支持完整的参数组合:

To tag a new release:
  script/release [--staging] [--dry-run] <tag-name> [--platform {linux|macos|windows}] [--branch <branch>]

To build staging binaries from the current branch:
  script/release --current [--platform {linux|macos|windows}]

To build binaries locally with goreleaser:
  script/release --local --platform {linux|macos|windows}

各参数在脚本中的实际作用(参见 script/release 的解析逻辑):

参数 默认值 作用
<tag-name> 必填 要打的版本号,如 v2.100.0 或预发布 v2.100.0-rc.1
--staging deploy_envproduction 切换为 staging,跳过一切签名与发布动作
--dry-run false 触发完整生产构建(含签名),但跳过 GitHub Release 创建、站点推送等对外可见变更
--platform {linux|macos|windows} 只构建单个平台,用于调试单一 job
--branch <branch> trunk 指定触发 workflow_dispatch 时检出的 ref
--local 不调用远端工作流,直接在本机用 GoReleaser 构建,产物落在 dist/
--current 从当前分支构建 staging 二进制:自动取最近 tag 作为版本号、自动检测 upstream 分支,且要求工作区干净(有未提交修改时直接拒绝并退出),随后执行 git push 再触发部署

脚本的核心行为分两条路径:

  • --local 路径:调用 trigger_deployment,其本质是执行 gh workflow -R cli/cli run deployment.yml --ref "$branch" -f tag_name="$tag_name" -f environment="$deploy_env" -f dry_run="$dry_run"(见 script/release)。即它并不是自己打 tag,而是以 workflow_dispatch 事件启动远端工作流,tag 最终由 gh release create 在发布阶段创建。触发后若处于 production 环境,脚本会提示维护者去 Slack 完成人工审批。
  • --local 路径:进入 build_local 函数,用 sed.goreleaser.yml 中裁剪出目标平台段,生成 .goreleaser.generated.yml,再执行 goreleaser release -f <config> --clean --skip validate,publish,announce,并通过 GORELEASER_CURRENT_TAG 环境变量注入版本号(见 script/release)。

这种"脚本触发工作流、工作流再回调脚本"的设计是理解整套发布体系的关键:每个 OS 构建 job 内部同样执行 script/release --local "$TAG_NAME" --platform <os>,让远端构建与本地测试走完全相同的构建逻辑。

deployment.yml 工作流总览

生产发布的真正实现位于 .github/workflows/deployment.yml。更完整的维护者视角深度剖析可参考同目录下的 docs/release-process-deep-dive.md,本文以当前仓库实际配置为准。

工作流仅由 workflow_dispatch 触发,表单输入如下(见 deployment.yml):

输入 默认值 说明
tag_name 必填 标签名,如 v2.100.0v2.100.0-rc.1
environment production 部署环境,staging 时所有签名与发布动作被跳过
platforms linux,macos,windows 逗号分隔的构建平台列表,可用于只调试单个 OS job
release true 是否执行最终的 release job
dry_run true 执行完整生产构建(含签名)但不发布任何对外可见内容

几个值得注意的顶层配置:

  • run-name:当 dry_runtrue 时,运行名称追加 (dry run) 后缀,便于在 Actions 界面一眼识别(deployment.yml)。
  • concurrency:按 workflow + ref 分组并 cancel-in-progress: true,避免同一分支重复触发时堆积运行。
  • permissions:显式授予 attestations: writecontents: writeid-token: write,分别用于制品溯源声明、写发布内容与 OIDC 签名认证。

任务依赖图与发布护栏

工作流的任务结构为:

validate-tag-name
      ├── linux   ──┐
      ├── macos    ──┼──▶ release(needs: [linux, macos, windows])
      └── windows  ──┘

三个 OS job 均设置 timeout-minutes: 20,防止签名等步骤挂起时耗尽默认超时。所有 OS job 和 release job 都会检出触发 workflow_dispatch 的 ref(即 script/release --branch 传入的分支)。

发布动作受两层护栏控制,这是理解 staging / dry run 行为的核心:

  1. environment 护栏:大量步骤以 if: inputs.environment == 'production' 保护,包括签名、创建 GitHub Release、推送站点等需要 secrets 或产生外部变更的操作。script/release --staging 就是利用这一点跳过签名与发布。
  2. dry_run 护栏:即使 environment 为 production,dry_run: true 时仍会执行完整生产构建(含签名与打包),但跳过所有对外变更。各步骤的具体守卫条件:
步骤 守卫条件
Attest release artifacts inputs.environment == 'production' && !inputs.dry_run
Create the release(gh release create DO_PUBLISH: inputs.environment == 'production' && !inputs.dry_run
Publish site(推送 cli.github.com DO_PUBLISH: production 且 tag 不含 '-' 且 !inputs.dry_run

DO_PUBLISHfalse 时的降级方式很巧妙:Create the release 步骤会给 gh release create 命令前加 echo 前缀(只打印不执行);Publish site 步骤则改用 git log / git diff 打印待推送内容(见 deployment.yml)。这意味着 dry run 可以端到端演练整条流水线而不产生任何外部副作用。

注意默认值差异:工作流表单上 dry_run 默认为 true(手动触发默认是演练);而 script/releasedry_run 默认为 false,只有显式传 --dry-run 才转发 dry_run=true。也就是说 ./script/release <tag-name> 执行的是真实发布。

另外,每个 OS job 在构建前都有一个 Create temporary tag 步骤:在 HEAD 本地创建同名 tag(不推送到远端),目的只是让 GoReleaser 从 git 描述信息中提取到正确版本号,从而嵌入二进制(见 deployment.yml 的注释说明)。

标签命名校验:validate-tag-name

validate-tag-name 是所有 OS 构建的前置任务,用一个正则强制标签符合语义化版本:

if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
  echo "Invalid tag name format. Must be in the form v1.2.3 or v1.2.3-rc.1"
  exit 1
fi

(见 deployment.yml

规则要点:

  • 必须是 v + major.minor.patch 三段纯数字,允许 -rc.1 这类预发布后缀;
  • 明确拒绝 + 后的构建元数据(如 v1.2.3+001),因为后续多个步骤是"靠连字符检测预发布"来分支决策的。

从源码结构看,tag 名中的连字符会同时影响三件事:release job 会给 gh release create 追加 --prerelease 参数(deployment.yml);站点(cli.github.com)不会被发布更新;以及 .goreleaser.ymlprerelease: auto 会让 GoReleaser 自动将预发布版本标记为预发布(见 .goreleaser.yml)。

分平台构建 job

三个 OS job 的结构一致:checkout → 安装 Go(go-version-file: go.mod)→ 安装固定版本的 GoReleaser → 执行 script/release --local <tag> --platform <os> → 平台特有的签名/打包步骤 → actions/upload-artifactretention-days: 7if-no-files-found: error)。

GoReleaser 版本在三个 job 中都被显式钉住为 v2.13.1,注释说明原因:不仅是安全考虑,还因为发布脚本依赖 GoReleaser 生成的特定文件命名(见 deployment.yml)。

Linux job

linux job 的上传物为 dist/*.tar.gzdist/*.rpmdist/*.deb(见 deployment.yml)。除二进制外,它还负责构建 CLI 在线手册 所需的 Markdown 页面:

go run ./cmd/gen-docs --website --doc-path dist/manual
tar -czvf dist/manual.tar.gz -C dist -- manual

其中 cmd/gen-docs 是仓库内的文档生成器,供 release job 后续更新站点使用。

Linux 没有制品签名.deb/.rpm 的 GPG 签名统一留到 release job 完成,避免每个平台 job 各自管理 GPG 密钥。

macOS job

macOS 是最复杂的平台,涉及代码签名、公证与 pkg 安装器三条链路。

1. 签名证书注入(仅 production)Install code signing certificate 步骤从 secrets GATEWATCHER_DEVELOPER_ID_CERT / GATEWATCHER_DEVELOPER_ID_PASSWORD 取出 base64 编码的 .p12 证书,流程为(见 deployment.yml):

PW=pwd.${{ github.run_number }}
security create-keychain -p $PW "$RUNNER_TEMP/build.keychain"
security set-keychain-settings -lut 21600 "$RUNNER_TEMP/build.keychain"
security default-keychain -s "$RUNNER_TEMP/build.keychain"
security unlock-keychain -p $PW "$RUNNER_TEMP/build.keychain"
base64 -d <<< "$DEVELOPER_ID_CERT" > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$RUNNER_TEMP/build.keychain" -P "$DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $PW "$RUNNER_TEMP/build.keychain"
rm "$RUNNER_TEMP/cert.p12"

要点:keychain 密码由 github.run_number 派生而非硬编码;-lut 21600 将自动锁超时拉长到 6 小时防止构建中途上锁;证书与密码通过 env: 映射进环境变量而非直接插值进脚本,避免密码含 shell 元字符时破坏引号;set-key-partition-list 限制只有 Apple 工具链与 codesign 能访问该密钥;最后立即删除 cert.p12 防止泄漏。

2. 公证凭据Add App Store Connect API key to keychainnodeselector/setup-apple-codesign action)物化 App Store Connect API key(.p8),随后 Configure notarization credentials 步骤执行 xcrun notarytool store-credentials "notarytool-password" 把 API key 存入 keychain profile,供后续 notarytool submit 引用,替代了旧的 Apple ID + app 专用密码方式。

3. 构建与签名Build release binaries 步骤执行 script/release --local "$TAG_NAME" --platform macos,Go 可执行文件的签名发生在 GoReleaser 的 post build hook 中:./script/sign '{{ .Path }}'script/sign 的行为:

  • DO_SIGN_ARTIFACTS 未设置或为 false、或 DEVELOPER_ID_CERT_IDENTIFIER(来自仓库变量 MAC_APP_SIGNING_IDENTITY)缺失、或 KEYCHAIN 缺失,则打印日志并优雅跳过——这保证 staging 构建不会因 keychain 未配置而失败;
  • .zip 文件执行 xcrun notarytool submit "$file" --keychain "$KEYCHAIN" --keychain-profile "notarytool-password" --wait(公证);
  • 对二进制执行 codesign --timestamp --options=runtime -s "<identity>" -v "$file"。其中 --timestamp--options=runtime(启用 hardened runtime)是 Apple 公证的硬性要求。

构建后还有独立的 Notarize macOS archives 步骤对所有 dist/gh_*_macOS_*.zip 再走一遍 script/sign 完成归档公证。

4. Universal pkg 安装器Build & notarize universal macOS pkg installer(production)或 Build universal macOS pkg installer(staging)步骤执行 script/pkgmacos "$TAG_NAME",它串联三个工具:

  • lipo:把 arm64amd64 二进制合并为 universal 二进制;
  • pkgbuild:构建组件包 com.github.cli.pkg,内容包含 universal 二进制、zsh 补全与 man 页;
  • productbuild:生成产品归档,引用仓库内的 build/macOS/distribution.xml 安装脚本描述文件,并附带 LICENSE;production 环境下通过 APPLE_DEVELOPER_INSTALLER_ID 变量(来自仓库变量)对 .pkg 执行 productbuild 签名。

Makefile 中对应的 macospkg 目标 走的是同一条 script/release --local "$VERSION" --platform macos 路径,可见本地与 CI 构建逻辑完全一致。

Windows job

Windows job 运行在 windows-2022 runner,构建 .exe/.zip 与 MSI 安装器,并全部走 Azure 云端签名(HSM 中保管证书)。

1. Azure 可信签名客户端Install Azure Code Signing Client 步骤下载 Microsoft.Trusted.Signing.Client/1.0.95 NuGet 包并解出 signtool.exe 所需的 Azure.CodeSigning.Dlib.dll,同时生成 metadata.json 描述签名账户(CertificateProfileName/CodeSigningAccountNameGitHubInc)、CorrelationId(指向本次 Actions 运行)与端点 https://wus3.codesigning.azure.net/(见 deployment.yml)。

2. OIDC 认证。production 环境额外执行 azure/login action,以 service principal 的 client-id/tenant-id 做 OIDC 登录(allow-no-subscriptions: true)。认证信息仍通过 AZURE_CLIENT_ID/AZURE_TENANT_ID 环境变量传入,供 DefaultAzureCredential 识别身份(见 deployment.yml 的注释)。

3. 构建与 MSIscript/release --local "$TAG_NAME" --platform windows 产出各架构 zip;可执行文件签名由 GoReleaser post hook 调用 pwsh .\script\sign.ps1 完成。随后 MSBuild 步骤将每个 zip 包装为 MSI,按架构映射源目录与平台(见 deployment.yml):

zip 后缀 GoReleaser 源目录 MSI 平台
*_386 dist/windows_windows_386_sse2 x86
*_amd64 dist/windows_windows_amd64_v1 x64
*_arm64 dist/windows_windows_arm64_v8.0 arm64

调用形如:

"${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"

WiX 工程文件位于 build/windows/gh.wixproj(配套 gh.wxsui.wxs)。注意 ProductVersion 会去掉 v 前缀与预发布后缀,保证 MSI 版本号保持纯数字。

4. 签 MSISign .msi release binaries 步骤对 dist/*.msi 逐一执行 script/sign.ps1,其内部调用 signtool.exe

& $signtool sign /d "GitHub CLI" /fd sha256 /td sha256 /tr http://timestamp.acs.microsoft.com /v /dlib "$Env:DLIB_PATH" /dmdf "$Env:METADATA_PATH" $Args[0]

参数含义:/fd 文件摘要算法、/td 时间戳摘要算法、/tr 时间戳服务器(证明签名时间)、/dlib 指向 Azure 签名 DLL、/dmdf 指向 metadata 文件。script/sign.ps1 开头同样检查 DO_SIGN_ARTIFACTSDLIB_PATHMETADATA_PATH,缺失即跳过,与 macOS 侧的护栏设计一致。

release job:聚合、GPG 签名与发布

release job 运行于 ubuntu-latestneeds: [linux, macos, windows],因此只构建单个平台时 release job 不会被执行(这正是调试 platforms 输入时的预期行为)。

1. 合并制品与站点检出actions/download-artifact 把三个 OS 的产物落回本地;production 环境先用 actions/create-github-app-token(App 凭据 SITE_DEPLOY_APP_CLIENT_ID / SITE_DEPLOY_APP_PRIVATE_KEY,限定 github/cli.github.com 仓库)生成短期 GitHub App token,再检出文档站点仓库到 site/——用短期 App token 替代长期 PAT 是明显的安全改进(见 deployment.yml)。

2. 更新站点手册Update site man pages 步骤将 linux job 上传的 manual.tar.gz 解包进站点仓库,替换旧的 manual/gh*.md,并用 sed 更新 index.html 中内嵌的 assign version 字段,最后以 cli automation <noreply@github.com> 身份提交(暂不推送)。

3. GPG 密钥装载与 RPM 签名Set up GPG 步骤从 secrets 导入两对 GPG 密钥(含 GPG_PUBKEY_2026 等第二组密钥,可推断是为密钥轮换准备的过渡安排),然后:

echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
base64 -d <<<"$GPG_PASSPHRASE" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP"

gpg-preset-passphrase 把密码预先驻留内存,使后续非交互签名无需反复喂密码。Sign RPMs 步骤复制 script/rpmmacros~/.rpmmacros 后执行 rpmsign --addsign dist/*.rpm

4. RPM 仓库元数据Run createrepo 把 rpm 拷入 site/packages/rpm/,执行 script/createrepo.sh 生成 repodata/repomd.xml,最后用显式指定 --default-key 2C6106201985B60E6C7AC87323F3D4EA75716059repomd.xml 做分离签名(deployment.yml)——由于同时导入两把密钥,显式指定主钥 ID 是必要的。

5. Debian 仓库元数据Run repreproRELEASES 列表(cosmic ... kali-rolling 一长串历史发行版名,注释标明"不再新增、未来会移除遗留项")对每个 dist/*.deb 执行 reprepro --confdir="+b/script" includedeb,签名密钥 ID 由 script/distributions 配置文件的 SignWith 行指定,产物拷入 site/packages/

6. 制品溯源声明Attest release artifacts 步骤(actions/attest v4.2.2)对 dist/gh_* 全部制品创建 Attestation,create-storage-record: false;该步骤受 production && !dry_run 双重守卫——溯源记录是对外可见的产物身份凭证,只应为真实发布生成。

7. 创建 GitHub ReleaseCreate the release 步骤(deployment.yml):

pushd dist
shasum -a 256 gh_* > checksums.txt
mv checksums.txt gh_${TAG_NAME#v}_checksums.txt
popd
release_args=(
  "$TAG_NAME"
  --title "GitHub CLI ${TAG_NAME#v}"
  --target "$GITHUB_SHA"
  --generate-notes
)
if [[ $TAG_NAME == *-* ]]; then
  release_args+=( --prerelease )
fi
guard="echo"
[ "$DO_PUBLISH" = "false" ] || guard=""
script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" --

细节解读:

  • 校验和文件gh_<version>_checksums.txt 作为发布资产之一上传,供用户验证制品完整性;
  • changelog 自动生--generate-notes 让 GitHub 根据已合并的 pull request 自动生成 release notes;--target "$GITHUB_SHA" 将 release 指向触发构建的确切 commit;
  • display labelscript/label-assetsdist/gh_* 文件转换为 "路径#显示标签" 对(如 gh_2.100.0_linux_amd64.tar.gz#GitHub CLI 2 100 0 linux amd64.msi/.deb/.rpm 分别追加 installer/deb/RPM 后缀),利用 gh release createasset#label 语法让人类可读;
  • dry run 降级DO_PUBLISH=falseguard=echo,整条 gh release create 只打印不执行。

8. 推送站点Publish site 步骤在 site/ 目录提交包仓库文件(Add rpm and deb packages for $TAG_NAME),仅当 DO_PUBLISH=true(production、tag 不含连字符、非 dry run)时执行 git push,该 push 会触发站点仓库自身的部署工作流;否则只打印 git log / git diff 供检查。预发布版本不更新站点,避免未正式发布的版本出现在官方安装页。

Homebrew 分发由 Homebrew 官方的 autobump 机制接管,周期性(约每 3 小时)自动 bump 官方 gh formula;需要更快滚动时,可手动开 PR:把 formula 中 url 的版本号改为新版本、重新计算并替换 sha256 值、提交 PR 即可。历史上曾使用第三方 bump action 配长期 PAT 跨仓库开 PR,因安全风险被弃用。

版本号与发布纪律

docs/releasing.md 给出的通用规范:

  • 待发布功能应在发布至少一天前完成评审与批准;
  • 功能发布应提升次版本号(minor);
  • 破坏性变更应提升主版本号(major),且应保持罕见。

不发真版的构建系统演练

1. staging 部署(不产生真实 release,用于验证远端构建链路):

script/release --staging vX.Y.Z --branch patch-1 -p macos

构建产物可通过 gh run download <RUN> -n macos 下载。此时 environment=staging,所有 inputs.environment == 'production' 守卫的步骤(证书注入、公证、Azure 签名、GPG 签名等)均被跳过,DO_SIGN_ARTIFACTSfalsescript/sign / script/sign.ps1 会打印 skip 日志并返回 0。

2. 本地构建(完全不涉及远端):

  1. 安装 GoReleaser(例如 brew install goreleaser);
  2. 执行 script/release --local(可加 --platform <os> 限定平台);
  3. dist/ 目录下查看构建产物。

本地路径下 build_local 会用 sed 从 .goreleaser.yml 中删掉其他平台的 #build:<os> 段,只保留目标平台配置,再调用 goreleaser release --skip validate,publish,announce

3. 理解 .goreleaser.yml 的构建契约.goreleaser.yml 中几个与发布强相关的配置:

  • before.hooks:在构建前执行 make manpages GH_VERSION={{.Version}}make completions(生成 man 页与 bash/fish/zsh 补全,见 Makefile 的对应目标);非 Windows 宿主上还会通过 script/gen-winres.ps1 生成嵌入 Windows 版本信息的 .syso 文件;
  • 三个 builds 条目(id: macos/linux/windows,均带 #build:<os> 标记供 sed 裁剪)通过 ldflags 注入版本与日期:-X .../internal/build.Version={{.Version}} -X .../internal/build.Date={{time "2006-01-02"}},这就是 CI 中"临时打 tag"步骤要达成的效果;
  • archives 定义了各平台的 name_template(如 gh_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }})与打包格式(linux 为 tar.gz,macOS/Windows 为 zip),linux/macos 归档还打入 LICENSEshare/man/man1/gh*.1 手册页;
  • nfpms#build:linux)生成 debrpm 包,声明对 git 的依赖,并把 man 页、各 shell 补全装入 /usr 下标准路径(Debian 的 zsh 补全额外装到 vendor-completions 以兼容默认不读 site-functions 的发行版)。

清理一次失败的发布

当发布损坏需要重发时,docs/releasing.md 给出的步骤:

  1. 删除该 release 及其关联 tag;
  2. 重新执行发布,并密切监视工作流运行日志;
  3. 开一个 PR 更新 gh 的 Homebrew formula 为新的 SHA 值,并在描述中链接上一个 formula PR;
  4. 验证最终产物:Debian 包、RPM 包与 Homebrew formula。

参考文件

文件 作用
docs/releasing.md 发布操作入口文档(本文主体)
docs/release-process-deep-dive.md 面向未来维护者的发布流程深度剖析
script/release 发布触发器:参数解析、gh workflow run 封装、本地 GoReleaser 构建
.github/workflows/deployment.yml 生产发布工作流:校验、构建、签名、发布
.goreleaser.yml 多平台构建/打包/签名钩子配置
script/sign / script/sign.ps1 macOS codesign + notarytool、Windows signtool 封装
script/label-assets / script/pkgmacos release 资产标签生成、universal pkg 安装器构建
script/createrepo.sh / script/distributions / script/rpmmacros RPM/Debian 包仓库元数据与签名配置
build/macOS/distribution.xml / build/windows/gh.wixproj macOS pkg 与 Windows MSI 的安装器描述文件

适用前提说明:本文所有配置(GoReleaser v2.13.1Microsoft.Trusted.Signing.Client/1.0.95windows-2022 runner、双 GPG 密钥导入等)均以当前仓库的 .github/workflows/deployment.yml 实际内容为准;docs/release-process-deep-dive.md 基于较早的 commit 撰写,其中个别版本与密钥细节(如 GoReleaser ~1.17.1、旧版 Azure 签名客户端与 AZURE_CLIENT_SECRET 环境变量方式)与当前配置存在代际差异,实践中以工作流文件为准。

登录后查看全文
热门项目推荐
相关项目推荐