Appearance
GitHub Actions 自动化签名与可信发布
使用 GitHub 托管的 Windows Runner 构建应用,通过 VSign 云端密钥签名,验证发布者和时间戳,再交付同一份已验证文件。本教程提供可下载的签名 Action、两种工作流和最小 .NET 测试程序。
流程为:构建 → 校验 CLI → 临时恢复操作员 → 云签 → 补齐时间戳并验签 → 生成报告与摘要 → 打包发布。任何签名、时间戳或验签错误都会阻止制品上传。
与 GitLab CI 常驻 Runner 不同,本方案每次使用新的 windows-2022 Runner,临时恢复操作员 PFX。操作员 PFX 是云签认证身份,不是云端代码签名私钥的导出文件,仍须作为敏感凭据保管。
典型架构:构建、远程签名与发布
GitHub Actions 编排客户的流水线,构建与验签在 Windows Runner 中执行;VSign 由 TrustAsia 远程提供,代码签名私钥保留在云端。
- Runner 部署位置可选:本文下载示例使用 GitHub 云端的
windows-2022。自托管 Runner 可部署在客户自己的云主机或本地服务器,需另行配置 Windows 工具链、网络和凭据隔离,不直接套用临时 Runner 的清理假设。GitHub 自托管 Runner 说明 - 签名服务独立于构建环境:Runner 安装 CLI 并持有操作员认证凭据,通过 HTTPS 调用 VSign;代码签名私钥不导出到 Runner。时间戳服务使用独立地址。
- 验签通过才交付:签名、时间戳或验签失败即停止上传。图中虚线表示后续 CD 发布,本文示例止于 Artifact 上传;Release 发布按第 6 节接入,复用同一份已签名制品,不重新构建。
1. 下载示例
下载 完整示例包,解压到测试仓库根目录。保留 .github 目录层级;包内不含 CLI 二进制、PFX、密码或 PIN。
| 文件 | 保存位置 | 用途 |
|---|---|---|
| action.yml | .github/actions/vsign-sign/action.yml | 下载工具、恢复身份、签名、验签和清理 |
| verify-vsign-exe.yml | .github/workflows/verify-vsign-exe.yml | 首次测试:生成自包含单文件 EXE |
| build-sign.yml | .github/workflows/build-sign.yml | 多文件 .NET 应用:签名后打包完整发布目录 |
| Program.cs 与 项目文件 | src/VSignSmokeTest/ | 最小 .NET 8 控制台测试程序 |
运行前提
示例沿用配套 CLI 的 vsign 子命令和文件操作员存储接口,适用于支持 SSIGNCODE_OPERATOR_STORE=file 的交付版本。需要准备匹配版本的 ssigncode.exe、可用的云签证书与操作员、RFC 3161 时间戳服务和 Windows SDK SignTool。
请先运行测试工作流完成环境验收,再接入正式发布。CLI SHA-256 校验确认工具文件未变,不代表不同版本的命令接口自动兼容。
2. 准备云签身份与 CLI
在 CertCloud 找到已签发的云签代码签名证书订单,创建用于 CI 的操作员,设置云签密钥 PIN。按交付流程在可信 Windows 电脑上安装操作员,取得操作员 PFX、解密密码及其证书指纹。
| 信息 | 用途 |
|---|---|
| 安装 Token | 初次安装操作员,不是证书指纹 |
| 操作员 PFX 和密码 | Runner 恢复认证身份;PFX 应包含私钥且处于有效期内 |
| 操作员证书指纹 | 验证恢复的是正确操作员 |
| 代码签名证书指纹 | 指定云签证书,并核对最终软件发布者 |
| 云签密钥 PIN | 授权云端签名操作 |
从 VSign 官网 获取客户端。交付手册中的 CLI 路径为 %LOCALAPPDATA%\Programs\vsigntoolui\binaries\ssigncode.exe;以实际安装目录和交付版本为准。
在可信电脑上计算工具摘要:
powershell
Get-FileHash -LiteralPath 'C:\tools\ssigncode.exe' -Algorithm SHA256在当前私有仓库创建工具 Release,标签例如 vsign-cli-v1,只上传 ssigncode.exe。将经核对的 SHA-256 保存为下一节的环境变量。Action 下载后先校验摘要,再执行工具;下载失败或摘要不符立即停止。
本例使用当前仓库的 GITHUB_TOKEN 读取 Release。若把 CLI 放在另一个私有仓库,需要另外配置访问方式,当前仓库 Token 不会自动获得跨仓库权限。GitHub Token 权限
3. 配置受保护的签名环境
在 Settings → Environments 创建 release-signing,并完成:
- 将
main配置为受保护分支,限制合并和工作流修改权限。 - Environment 仅允许计划用于签名的
main分支和应用发布标签。 - 正式标签设置保护规则,只允许发布负责人为经过审查的提交创建标签。
- 套餐支持时,配置 Required reviewers 和禁止自审。
两个示例都检查 github.ref_protected。 手动测试只允许受保护的 main;标签签名只接受受保护的应用标签。未保护引用时,作业会跳过,不会尝试取得签名凭据。
私有仓库的 Environment 和 Secrets 需要相应套餐;Free、Pro、Team 的 Required reviewers 仅适用于公开仓库。没有此功能时,应使用组织已有的审批流程,不能把点击 Run workflow 当作独立审批。环境与审批能力
Environment Secrets
| 名称 | 内容 |
|---|---|
VSIGN_OPERATOR_PFX_B64 | 操作员 PFX 的 Base64 |
VSIGN_OPERATOR_PWD | PFX 解密密码 |
VSIGN_KEY_PIN | 云签密钥 PIN |
在保存 PFX 的可信 Windows 电脑上执行,直接复制 Base64 到剪贴板:
powershell
$pfxPath = 'C:\vsign\operator.pfx'
if (-not (Test-Path -LiteralPath $pfxPath -PathType Leaf)) { throw '未找到操作员 PFX。' }
[Convert]::ToBase64String([IO.File]::ReadAllBytes($pfxPath)) | Set-Clipboard粘贴到 Secret 后清理剪贴板。Base64 不是加密,不打印到终端、不写入仓库。PFX 密码与云签 PIN 即使相同,也分别维护;检查单个 Secret 的大小限制。Secrets 使用说明
Environment Variables
| 名称 | 内容 |
|---|---|
VSIGN_SERVER | 实际 HTTPS 云签地址;本交付方案使用 https://vsign.trustasia.com |
VSIGN_OPERATOR_SHA1 | 操作员证书指纹,不是安装 Token |
VSIGN_CERT_HASH | 代码签名证书指纹 |
VSIGN_TOOL_RELEASE | 工具 Release 标签,例如 vsign-cli-v1 |
VSIGN_TOOL_SHA256 | 经核对的 CLI SHA-256,64 位十六进制 |
VSIGN_TIMESTAMP_URL | 可访问的 RFC 3161 时间戳地址,不能填云签 API 地址 |
两类证书指纹都是 40 位十六进制标识;文件签名和时间戳使用 SHA-256。不要复制其他项目的指纹或工具摘要。
4. 先运行单文件测试
将示例包提交到受保护的 main 后,在 Actions → Verify standalone VSign EXE → Run workflow 选择 main。环境需要审批时,先完成审批。
单文件示例生成自包含的 Windows x64 EXE,并纳入本机运行库,避免只上传 EXE 却缺少运行依赖。签名后运行这个最小测试程序,再次确认文件摘要与验签时一致,最后上传:
VSignSmokeTest.exe:已签名的自包含测试程序。VSignSmokeTest.exe.signature.json:签名者、时间戳、最终摘要、工具摘要、源码提交和运行链接。SHA256SUMS.txt:最终 EXE 的 SHA-256。
查看完整单文件测试工作流
yaml
name: Verify standalone VSign EXE
'on':
workflow_dispatch: null
permissions:
contents: read
concurrency:
group: vsign-release-signing
cancel-in-progress: false
jobs:
build-sign-verify:
if: github.ref_protected && (github.ref == 'refs/heads/main' || (github.event_name == 'push' && startsWith(github.ref,
'refs/tags/v')))
runs-on: windows-2022
environment: release-signing
timeout-minutes: 30
defaults:
run:
shell: pwsh
steps:
- name: Check out reviewed source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
persist-credentials: false
- name: Set up .NET 8
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1
with:
dotnet-version: 8.0.x
- name: Build application
run: |
$ErrorActionPreference = 'Stop'
dotnet publish src/VSignSmokeTest/VSignSmokeTest.csproj `
--configuration Release --runtime win-x64 --self-contained true `
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true `
-p:DebugType=None -p:DebugSymbols=false `
--output dist/unsigned
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
if (-not (Test-Path -LiteralPath 'dist/unsigned/VSignSmokeTest.exe' -PathType Leaf)) { throw 'Build output not found.' }
- name: Sign and verify EXE
id: vsign
uses: ./.github/actions/vsign-sign
with:
input-file: dist/unsigned/VSignSmokeTest.exe
output-file: dist/signed/VSignSmokeTest.exe
server: ${{ vars.VSIGN_SERVER }}
signing-cert-sha1: ${{ vars.VSIGN_CERT_HASH }}
operator-cert-sha1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
tool-release: ${{ vars.VSIGN_TOOL_RELEASE }}
tool-sha256: ${{ vars.VSIGN_TOOL_SHA256 }}
timestamp-url: ${{ vars.VSIGN_TIMESTAMP_URL }}
operator-pfx-base64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
operator-pfx-password: ${{ secrets.VSIGN_OPERATOR_PWD }}
key-pin: ${{ secrets.VSIGN_KEY_PIN }}
github-token: ${{ github.token }}
- name: Run verified demo and prepare release files
env:
VERIFIED_SHA256: ${{ steps.vsign.outputs.file-sha256 }}
run: |
$ErrorActionPreference = 'Stop'
$target = 'dist/signed/VSignSmokeTest.exe'
& $target
if ($LASTEXITCODE -ne 0) { throw 'The signed smoke-test application failed.' }
$hash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash
if ($hash -ne $env:VERIFIED_SHA256) { throw 'The signed file changed after verification.' }
"$hash VSignSmokeTest.exe" | Set-Content dist/signed/SHA256SUMS.txt -Encoding utf8
- name: Upload verified release files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: vsign-standalone-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
dist/signed/VSignSmokeTest.exe
dist/signed/VSignSmokeTest.exe.signature.json
dist/signed/SHA256SUMS.txt
if-no-files-found: error
retention-days: 7外部 Actions 已固定为核对过的提交 SHA,分别对应 checkout v6、setup-dotnet v5、upload-artifact v7。升级时更新经过审查的提交,不改回浮动标签。示例使用 .NET 8.0.x;如需固定构建环境,再用 global.json 锁定 SDK 补丁版本。Actions 安全使用建议 · .NET 单文件部署
Action 如何判定成功
Action 的认证参数全部通过 with 传入,并只在内部需要的步骤映射为环境变量,不写入跨步骤共享的 GITHUB_ENV。每次调用创建独立的随机临时目录。
- 输入必须是工作区内未签名的 EXE、DLL 或 MSI;输出必须是同类型、不同路径且尚不存在的文件。示例不覆盖旧产物,也不接受符号链接或目录联接。
- 校验 CLI 摘要、PFX 私钥、操作员证书指纹和有效期,再调用云签。
- 要求
Get-AuthenticodeSignature返回Valid,签名者等于配置的代码签名证书。 - CLI 没有生成时间戳时,由 SignTool 使用
VSIGN_TIMESTAMP_URL补齐;已有时间戳则保留并验证。 - 执行
signtool verify /pa /all /v /tw,非零退出码包括警告都判为失败,再检查最终签名者和时间戳,写入报告。
成功后只输出已验证文件的信息。失败时删除本次生成的不合格输出,清理步骤无论成功或失败都会移除本次临时 PFX 和 CLI。Runner 被强制终止时清理不保证执行,仍需依赖临时环境销毁和操作员凭据生命周期管理。
CLI 的现有接口通过命令参数接收 PIN;示例不打印展开后的命令或原始签名日志。不要开启会记录进程参数的调试采集,或在共享 Runner 上运行此凭据方案。
5. 接入真实应用
build-sign.yml 展示普通多文件 .NET 应用:保留完整 publish 目录,包括 .deps.json、运行配置和依赖,只用已验证的签名文件替换待发布 EXE,再从打包目录运行测试程序。
此示例为依赖 .NET 8 运行时的应用,接收方需要安装对应运行时;若要独立运行,使用上一节的自包含方案。应用包含其他需签名 DLL/MSI 时,为每个文件单独调用 Action;只签一个 EXE 不代表依赖文件也具有 Authenticode 签名。
多文件示例上传 VSignSmokeTest-win-x64.zip 及 ZIP 摘要。ZIP 内含完整应用、签名报告和文件摘要清单,不通过随意挑选几个文件来打包。
调用接口
下列参数名与下载的 action.yml 一致;无需自行猜测 env 到 Action 的映射:
yaml
- name: Sign application
id: vsign
uses: ./.github/actions/vsign-sign
with:
input-file: dist/unsigned/YourApp.exe
output-file: dist/signed/YourApp.exe
server: ${{ vars.VSIGN_SERVER }}
signing-cert-sha1: ${{ vars.VSIGN_CERT_HASH }}
operator-cert-sha1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
tool-release: ${{ vars.VSIGN_TOOL_RELEASE }}
tool-sha256: ${{ vars.VSIGN_TOOL_SHA256 }}
timestamp-url: ${{ vars.VSIGN_TIMESTAMP_URL }}
operator-pfx-base64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
operator-pfx-password: ${{ secrets.VSIGN_OPERATOR_PWD }}
key-pin: ${{ secrets.VSIGN_KEY_PIN }}
github-token: ${{ github.token }}| Action 输出 | 含义 |
|---|---|
signed-file | 已验证文件的绝对路径 |
signer-thumbprint | 已验证的发布者证书指纹 |
file-sha256 | 最终文件摘要,时间戳处理已完成 |
report-file | JSON 验签报告路径 |
替换构建命令、文件名、打包及测试步骤时保持这些约束:
- 先 checkout,再调用本地 Action;每次调用使用唯一 step
id和输出路径。 - 只把签名输入传给 Action,不将 PFX、密码或 PIN 放在 job 全局环境或制品目录。
- 示例中的运行步骤仅用于最小控制台程序。真实 GUI、安装程序或其他应用应替换为合适的非交互测试,不盲目运行所有输出文件。
- 发布内容来自同一次成功签名运行,不再重新编译;打包前核对最终 EXE 摘要仍与 Action 输出相同。
- 签名、验签和打包都不使用
continue-on-error;上传只在前序成功时执行,always()仅用于清理。
build-sign.yml 的标签模式是 v[0-9]*,用于应用标签,如 v1.0.0。工具标签 vsign-cli-v1 不会触发应用签名。两个工作流使用相同并发组限制同时签名的任务,这不是保证先进先出的发布队列。
6. 发布与接收方核验
Artifact 是有保留期限的运行产物;正式版本可通过 GitHub Release 分发同一份文件。以下三层信息作用不同:
| 信息 | 验证什么 |
|---|---|
| Authenticode 和可信时间戳 | 预期发布者、签名完整性及签名时间依据 |
| SHA-256 | 下载文件是否与已核对文件的字节一致 |
| 提交、运行记录及可选构建证明 | 从哪个仓库和工作流生成,关联哪个提交 |
报告是审计记录,不能独立充当不可伪造的来源证明。文件和摘要若同时被替换,单纯比较摘要不能建立信任;签名也不保证软件没有漏洞或已取得 SmartScreen 信誉。
发布同一份已签名文件
下载成功运行的 Artifact,解压后按 验签指南 再次检查文件,并核对 SHA-256。确认报告中的提交就是发布版本后,创建指向该提交的受保护应用标签。
单文件示例可用以下命令创建 Release 草稿;替换仓库和标签:
powershell
gh release create v1.0.0 `
.\VSignSmokeTest.exe .\VSignSmokeTest.exe.signature.json .\SHA256SUMS.txt `
--repo OWNER/REPOSITORY --verify-tag --draft --title 'v1.0.0'
if ($LASTEXITCODE -ne 0) { throw 'Release draft creation failed.' }多文件示例则上传 ZIP 和 ZIP 摘要。--verify-tag 只确认标签存在,仍须核对它指向报告中的提交。人工核对草稿附件后再发布。GitHub CLI Release
需要自动发布时,只给独立的发布 job 授予 contents: write;它应依赖成功签名的 job,下载该运行的指定 Artifact,再发布。签名 job 保持 contents: read。示例本身只上传 Artifact,不会自动创建 Release。
可选构建来源证明
可在签名之后为最终 EXE 生成 GitHub artifact attestation。签名或添加时间戳都会改变文件摘要,签名前的证明不能直接用于最终文件。
仅给生成证明的 job 授予所需的 id-token: write、attestations: write。私有或内部仓库需要 GitHub Enterprise Cloud;按 GitHub 官方配置 接入。
接收方还可运行:
powershell
gh attestation verify .\VSignSmokeTest.exe --repo OWNER/REPOSITORY
if ($LASTEXITCODE -ne 0) { throw 'Artifact attestation verification failed.' }检查证明中的仓库、工作流和提交符合预期,再结合 Authenticode 与摘要判断。构建证明的作用与边界
7. 验收与排查
先使用授权测试证书和测试项目验证以下场景,再切换正式签名证书:
| 场景 | 预期结果与处理 |
|---|---|
| 作业被跳过 | 检查是否选择受保护的 main,或使用允许的受保护应用标签 |
| CLI Release 404 | 核对仓库、标签、附件名和 Token 权限 |
| CLI 摘要不符 | 工具执行前失败;重新核对供应商交付及受控摘要 |
| PFX 密码错误、缺少私钥、过期或指纹不符 | 签名前失败,检查操作员身份 |
| PIN 错误或云签授权不可用 | 签名失败,不上传发布制品 |
| 签名状态非 Valid 或发布者错误 | 验签失败,删除本次不合格输出 |
| 时间戳不存在或服务失败 | 尝试补齐;补齐或严格验签失败时停止 |
| 输出已存在、路径相同或越出工作区 | 拒绝操作;使用新的、不同的输出路径 |
| 连续调用两次 Action | 每次使用独立临时目录,不复用上一份 PFX 或状态 |
| 发布后下载 | 重新验签、核对文件或 ZIP 摘要,按需验证构建证明 |
示例包的结构与离线失败分支检查不能替代真实 Windows Runner、交付 CLI、云签服务和时间戳的联调。排查时提供版本、运行链接和脱敏错误;不上传原始 PFX、Base64、密码、PIN 或安装 Token。
