Skip to content

Commit 3722223

Browse files
yotsudaclaude
andcommitted
ci: add release workflow — Azure Key Vault sign + npm provenance
Triggered by tag push (v*). Pipeline: 1. NativeAOT publish to dist\ripple.exe 2. Run unit tests (sanity gate before publish) 3. Verify tag matches csproj <Version> and npm package version 4. OIDC login to Azure (no client secret; federated credential trusts repo:yotsuda/ripple:environment:release) 5. AzureSignTool sign via Key Vault access token (cert never leaves the HSM-backed vault) 6. Mirror signed binary to npm/dist 7. npm publish --provenance --access public 8. Create GitHub Release with CHANGELOG section as body and dist\ripple.exe attached as a release asset Gating: - Trigger restricted to v* tags - environment: release adds required-reviewer gate (yotsuda) and deployment_branch_policy locks deploys to v* tags - NPM_TOKEN is environment-scoped (only release env jobs read it) - id-token: write enables both Azure OIDC and npm SLSA provenance attestation Replaces the previous "build locally + npm publish from dev machine" flow. Local Build.ps1 -Sign still works for ad-hoc unsigned/signed builds during development; release artifacts now go through CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 432e6ba commit 3722223

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
name: Release
2+
3+
# Triggered only by tag pushes matching v*. The release environment's
4+
# deployment_branch_policy further restricts execution to tag refs
5+
# matching v*; subscribed environment reviewers must approve before
6+
# the publish job runs.
7+
on:
8+
push:
9+
tags:
10+
- 'v*'
11+
12+
# Concurrency: serialize releases so a quick double-tag doesn't race
13+
# two npm publish jobs against each other.
14+
concurrency:
15+
group: release-${{ github.ref }}
16+
cancel-in-progress: false
17+
18+
permissions:
19+
contents: write # gh release create
20+
id-token: write # OIDC for Azure + npm provenance
21+
22+
jobs:
23+
release:
24+
name: Build, sign, and publish
25+
runs-on: windows-latest
26+
environment: release # gates: required reviewers + tag-only deploy policy + NPM_TOKEN secret scope
27+
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@v4
31+
with:
32+
fetch-depth: 0 # tags + history for changelog/notes extraction
33+
34+
- name: Setup .NET 9
35+
uses: actions/setup-dotnet@v4
36+
with:
37+
dotnet-version: '9.0.x'
38+
39+
- name: Setup Node.js
40+
uses: actions/setup-node@v4
41+
with:
42+
node-version: '20'
43+
registry-url: 'https://registry.npmjs.org'
44+
45+
# Restore the AOT compiler workload + project deps so the publish
46+
# step pulls only from the local nuget cache (no network surprise).
47+
- name: dotnet restore
48+
run: dotnet restore ripple.csproj -r win-x64
49+
50+
# NativeAOT publish — produces dist\ripple.exe identical (modulo
51+
# signature) to what Build.ps1 produces locally.
52+
- name: dotnet publish (NativeAOT, win-x64)
53+
run: dotnet publish ripple.csproj -c Release -r win-x64 -o dist --no-restore
54+
55+
# Tests run against the framework-dependent build under
56+
# bin/Release; --tests in NativeAOT mode is unsupported, so this
57+
# is a sanity gate using the regular dotnet build.
58+
- name: Run unit tests
59+
shell: pwsh
60+
run: |
61+
dotnet build -c Release --nologo
62+
.\bin\Release\net9.0\ripple.exe --test
63+
if ($LASTEXITCODE -ne 0) { throw "ripple --test failed (exit $LASTEXITCODE)" }
64+
65+
- name: Verify tag matches csproj version
66+
shell: pwsh
67+
run: |
68+
$tagVersion = '${{ github.ref_name }}'.TrimStart('v')
69+
$csprojVersion = ([xml](Get-Content ripple.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1
70+
$npmVersion = (Get-Content npm/package.json | ConvertFrom-Json).version
71+
"Tag: $tagVersion"
72+
"csproj: $csprojVersion"
73+
"package: $npmVersion"
74+
if ($tagVersion -ne $csprojVersion) { throw "Tag $tagVersion does not match csproj <Version> $csprojVersion" }
75+
if ($tagVersion -ne $npmVersion) { throw "Tag $tagVersion does not match npm package version $npmVersion" }
76+
77+
# OIDC login to Azure — no client secret. The federated credential
78+
# on the gha-ripple-signing app trusts repo:yotsuda/ripple:environment:release.
79+
- name: Azure OIDC login
80+
uses: azure/login@v2
81+
with:
82+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
83+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
84+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
85+
86+
# AzureSignTool fetches the signing cert from Azure Key Vault and
87+
# signs in-process. Uses the action's bundled Azure auth from the
88+
# azure/login step above (the Az CLI / Az PowerShell context that
89+
# azure/login establishes is consumed transparently).
90+
- name: Install AzureSignTool
91+
shell: pwsh
92+
run: dotnet tool install --global AzureSignTool
93+
94+
# Sign dist\ripple.exe with the cert in Key Vault.
95+
# `--azure-key-vault-managed-identity` would use the IMDS-backed
96+
# ManagedIdentityCredential, which is wrong for our OIDC
97+
# federated credential. Instead, fetch a Key Vault access token
98+
# via the Azure CLI session that azure/login established, then
99+
# hand it to AzureSignTool with --azure-key-vault-accesstoken.
100+
# The token never leaves this step's PowerShell process.
101+
- name: Sign dist\ripple.exe with Azure Key Vault
102+
shell: pwsh
103+
env:
104+
AZURE_KV_URL: ${{ vars.KEY_VAULT_URL }}
105+
AZURE_KV_CERT: ${{ vars.SIGNING_CERT_NAME }}
106+
run: |
107+
$tokenObj = az account get-access-token --resource https://vault.azure.net | ConvertFrom-Json
108+
if (-not $tokenObj.accessToken) { throw "Failed to acquire Key Vault access token" }
109+
AzureSignTool sign `
110+
--azure-key-vault-url $env:AZURE_KV_URL `
111+
--azure-key-vault-certificate $env:AZURE_KV_CERT `
112+
--azure-key-vault-accesstoken $tokenObj.accessToken `
113+
--file-digest sha256 `
114+
--timestamp-rfc3161 'http://timestamp.digicert.com' `
115+
--description 'ripple - declarative shell adapter framework' `
116+
--description-url 'https://github.com/yotsuda/ripple' `
117+
'dist\ripple.exe'
118+
if ($LASTEXITCODE -ne 0) { throw "AzureSignTool failed (exit $LASTEXITCODE)" }
119+
# Verify the signature landed.
120+
$sig = Get-AuthenticodeSignature 'dist\ripple.exe'
121+
if ($sig.Status -ne 'Valid') { throw "Signature status: $($sig.Status) - $($sig.StatusMessage)" }
122+
"Signed: $($sig.SignerCertificate.Subject) | thumb=$($sig.SignerCertificate.Thumbprint)"
123+
124+
# Mirror to npm/dist so the npm publish step packages the signed
125+
# binary, matching Build.ps1's local layout.
126+
- name: Mirror signed binary to npm/dist
127+
shell: pwsh
128+
run: |
129+
New-Item -ItemType Directory -Force -Path npm\dist | Out-Null
130+
Copy-Item dist\ripple.exe npm\dist\ripple.exe -Force
131+
$size = [Math]::Round((Get-Item npm\dist\ripple.exe).Length / 1MB, 2)
132+
"Mirrored to npm\dist\ripple.exe ($size MB)"
133+
134+
# Publish with provenance. --provenance + id-token: write +
135+
# GitHub-hosted runner = npm records the SLSA build attestation
136+
# linking @ytsuda/ripple@VERSION to this exact workflow run.
137+
- name: npm publish (with provenance)
138+
shell: pwsh
139+
working-directory: npm
140+
env:
141+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
142+
run: |
143+
npm publish --access public --provenance
144+
if ($LASTEXITCODE -ne 0) { throw "npm publish failed (exit $LASTEXITCODE)" }
145+
146+
# Extract the section for this version from CHANGELOG.md so the
147+
# GitHub Release body matches exactly what's in the repo.
148+
- name: Extract changelog entry
149+
id: changelog
150+
shell: pwsh
151+
run: |
152+
$version = '${{ github.ref_name }}'.TrimStart('v')
153+
$lines = Get-Content CHANGELOG.md
154+
$startPattern = "^##\s+\[$([regex]::Escape($version))\]"
155+
$startIdx = ($lines | Select-String -Pattern $startPattern | Select-Object -First 1).LineNumber
156+
if (-not $startIdx) { throw "No CHANGELOG section for v$version" }
157+
# Find the next "## [" heading; section ends one line above.
158+
$endMatch = $lines | Select-Object -Skip $startIdx | Select-String -Pattern '^##\s+\[' | Select-Object -First 1
159+
$endIdx = if ($endMatch) { $startIdx + $endMatch.LineNumber - 1 } else { $lines.Count }
160+
$section = $lines[($startIdx)..($endIdx - 1)] -join "`n"
161+
$section | Set-Content release-notes.md -NoNewline
162+
"Extracted $($endIdx - $startIdx) lines for v$version"
163+
164+
- name: Create GitHub Release
165+
env:
166+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167+
run: |
168+
gh release create ${{ github.ref_name }} `
169+
--title "ripple ${{ github.ref_name }}" `
170+
--notes-file release-notes.md `
171+
--verify-tag `
172+
dist\ripple.exe

0 commit comments

Comments
 (0)