@@ -3,14 +3,14 @@ name: Release
33# Triggered only by tag pushes matching v*. The release environment's
44# deployment_branch_policy further restricts execution to tag refs
55# matching v*; subscribed environment reviewers must approve before
6- # the publish job runs.
6+ # the publish job runs. Build jobs do NOT require approval (they
7+ # produce unsigned artifacts that are useless without the publish
8+ # step's signing + npm push), so review friction is one-time.
79on :
810 push :
911 tags :
1012 - ' v*'
1113
12- # Concurrency: serialize releases so a quick double-tag doesn't race
13- # two npm publish jobs against each other.
1414concurrency :
1515 group : release-${{ github.ref }}
1616 cancel-in-progress : false
@@ -20,16 +20,72 @@ permissions:
2020 id-token : write # OIDC for Azure + npm provenance
2121
2222jobs :
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
23+ build :
24+ name : Build ${{ matrix.rid }}
25+ runs-on : ${{ matrix.runner }}
26+ strategy :
27+ fail-fast : true
28+ matrix :
29+ include :
30+ - rid : win-x64
31+ runner : windows-latest
32+ platform : win32-x64
33+ exe : ripple.exe
34+ - rid : linux-x64
35+ runner : ubuntu-latest
36+ platform : linux-x64
37+ exe : ripple
38+ - rid : osx-arm64
39+ runner : macos-latest
40+ platform : darwin-arm64
41+ exe : ripple
2742
2843 steps :
2944 - name : Checkout
3045 uses : actions/checkout@v4
46+
47+ - name : Setup .NET 9
48+ uses : actions/setup-dotnet@v4
49+ with :
50+ dotnet-version : ' 9.0.x'
51+
52+ - name : dotnet restore
53+ run : dotnet restore ripple.csproj -r ${{ matrix.rid }}
54+
55+ - name : dotnet publish (NativeAOT)
56+ run : dotnet publish ripple.csproj -c Release -r ${{ matrix.rid }} -o dist --no-restore
57+
58+ # Run the full --test suite on the produced binary. This is the
59+ # real gate — a build that compiles but breaks at runtime on
60+ # this platform should not be published. No special
61+ # adapter-test setup; the tests that exercise cross-cutting
62+ # concerns (capture, render, OSC parsing, etc.) don't need
63+ # external shells / REPLs to be installed.
64+ - name : Run unit tests
65+ shell : bash
66+ run : |
67+ chmod +x "dist/${{ matrix.exe }}" || true
68+ "./dist/${{ matrix.exe }}" --test
69+
70+ - name : Upload binary artifact
71+ uses : actions/upload-artifact@v4
3172 with :
32- fetch-depth : 0 # tags + history for changelog/notes extraction
73+ name : ripple-${{ matrix.platform }}
74+ path : dist/${{ matrix.exe }}
75+ if-no-files-found : error
76+ retention-days : 7
77+
78+ publish :
79+ name : Sign, publish, release
80+ needs : build
81+ runs-on : ubuntu-latest
82+ environment : release # required-reviewer gate + NPM_TOKEN scope
83+
84+ steps :
85+ - name : Checkout
86+ uses : actions/checkout@v4
87+ with :
88+ fetch-depth : 0
3389
3490 - name : Setup .NET 9
3591 uses : actions/setup-dotnet@v4
@@ -42,143 +98,131 @@ jobs:
4298 node-version : ' 20'
4399 registry-url : ' https://registry.npmjs.org'
44100
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
101+ - name : Verify tag matches all version fields
102+ shell : bash
60103 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)" }
104+ set -euo pipefail
105+ tag='${{ github.ref_name }}'
106+ version="${tag#v}"
107+ csproj_version=$(grep -oE '<Version>[^<]+</Version>' ripple.csproj | head -n1 | sed -E 's/<Version>([^<]+)<\/Version>/\1/')
108+ meta_version=$(node -p "require('./npm/package.json').version")
109+ echo "Tag: $version"
110+ echo "csproj: $csproj_version"
111+ echo "meta: $meta_version"
112+ [[ "$version" == "$csproj_version" ]] || { echo "csproj version mismatch" >&2; exit 1; }
113+ [[ "$version" == "$meta_version" ]] || { echo "meta package.json version mismatch" >&2; exit 1; }
114+ for plat in win32-x64 linux-x64 darwin-arm64; do
115+ pv=$(node -p "require('./npm/platforms/${plat}/package.json').version")
116+ echo "$plat: $pv"
117+ [[ "$version" == "$pv" ]] || { echo "$plat subpackage version mismatch" >&2; exit 1; }
118+ done
119+
120+ - name : Download all platform binaries
121+ uses : actions/download-artifact@v4
122+ with :
123+ path : artifacts
64124
65- - name : Verify tag matches csproj version
66- shell : pwsh
125+ - name : Place binaries into subpackages + fix executable bits
126+ shell : bash
67127 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.
128+ set -euo pipefail
129+ cp artifacts/ripple-win32-x64/ripple.exe npm/platforms/win32-x64/bin/ripple.exe
130+ cp artifacts/ripple-linux-x64/ripple npm/platforms/linux-x64/bin/ripple
131+ cp artifacts/ripple-darwin-arm64/ripple npm/platforms/darwin-arm64/bin/ripple
132+ chmod +x npm/platforms/linux-x64/bin/ripple
133+ chmod +x npm/platforms/darwin-arm64/bin/ripple
134+ # Remove placeholder .gitkeep files so they don't ship in the tarball.
135+ rm -f npm/platforms/*/bin/.gitkeep
136+ ls -la npm/platforms/*/bin/
137+
79138 - name : Azure OIDC login
80139 uses : azure/login@v2
81140 with :
82141 client-id : ${{ secrets.AZURE_CLIENT_ID }}
83142 tenant-id : ${{ secrets.AZURE_TENANT_ID }}
84143 subscription-id : ${{ secrets.AZURE_SUBSCRIPTION_ID }}
85144
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).
90145 - name : Install AzureSignTool
91- shell : pwsh
92146 run : dotnet tool install --global AzureSignTool
93147
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
148+ # AzureSignTool is a cross-platform .NET tool; Authenticode
149+ # signing works from Linux as long as we have the Key Vault
150+ # access token. If this step ever starts failing, fall back to
151+ # a dedicated windows-latest signing job that uploads a signed
152+ # artifact for this job to consume.
153+ - name : Sign Windows binary via Azure Key Vault
103154 env :
104155 AZURE_KV_URL : ${{ vars.KEY_VAULT_URL }}
105156 AZURE_KV_CERT : ${{ vars.SIGNING_CERT_NAME }}
157+ EXPECTED_THUMBPRINT : ' 74E5208228DFB12A067747D536BF497B6E98C73C'
158+ shell : bash
106159 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. Get-AuthenticodeSignature's
120- # Status reports UnknownError for self-signed certs because
121- # the GHA runner's machine trust store doesn't contain the
122- # yotsuda root — the chain build fails even though the
123- # signature itself is cryptographically valid. Verify by
124- # thumbprint match against the expected yotsuda cert
125- # instead; that's the actual question we care about
126- # ("was the binary signed with our cert?"), independent of
127- # whether the runner's OS happens to trust the root.
128- $sig = Get-AuthenticodeSignature 'dist\ripple.exe'
129- if (-not $sig.SignerCertificate) { throw "No signature on dist\ripple.exe" }
130- $expectedThumb = '74E5208228DFB12A067747D536BF497B6E98C73C'
131- if ($sig.SignerCertificate.Thumbprint -ne $expectedThumb) {
132- throw "Wrong cert thumbprint: got $($sig.SignerCertificate.Thumbprint), expected $expectedThumb"
133- }
134- "Signed: $($sig.SignerCertificate.Subject) | thumb=$($sig.SignerCertificate.Thumbprint) | runner-status=$($sig.Status)"
135-
136- # Mirror to npm/dist so the npm publish step packages the signed
137- # binary, matching Build.ps1's local layout.
138- - name : Mirror signed binary to npm/dist
139- shell : pwsh
160+ set -euo pipefail
161+ token=$(az account get-access-token --resource https://vault.azure.net --query accessToken -o tsv)
162+ [[ -n "$token" ]] || { echo "Failed to acquire Key Vault access token" >&2; exit 1; }
163+ AzureSignTool sign \
164+ --azure-key-vault-url "$AZURE_KV_URL" \
165+ --azure-key-vault-certificate "$AZURE_KV_CERT" \
166+ --azure-key-vault-accesstoken "$token" \
167+ --file-digest sha256 \
168+ --timestamp-rfc3161 'http://timestamp.digicert.com' \
169+ --description 'ripple - declarative shell adapter framework' \
170+ --description-url 'https://github.com/yotsuda/ripple' \
171+ npm/platforms/win32-x64/bin/ripple.exe
172+
173+ # Publish subpackages BEFORE meta. Meta's optionalDependencies
174+ # reference these exact versions; if meta published first and a
175+ # subpackage publish later failed, users installing the meta
176+ # would see missing optional deps and degraded UX. Sequential
177+ # ordering also lets a failure halt before meta is published
178+ # (avoids broken meta pointing at a missing subpackage).
179+ - name : Publish subpackages with provenance
180+ env :
181+ NODE_AUTH_TOKEN : ${{ secrets.NPM_TOKEN }}
182+ shell : bash
140183 run : |
141- New-Item -ItemType Directory -Force -Path npm\dist | Out-Null
142- Copy-Item dist\ripple.exe npm\dist\ripple.exe -Force
143- $size = [Math]::Round((Get-Item npm\dist\ripple.exe).Length / 1MB, 2)
144- "Mirrored to npm\dist\ripple.exe ($size MB)"
145-
146- # Publish with provenance. --provenance + id-token: write +
147- # GitHub-hosted runner = npm records the SLSA build attestation
148- # linking @ytsuda/ripple@VERSION to this exact workflow run.
149- - name : npm publish (with provenance)
150- shell : pwsh
151- working-directory : npm
184+ set -euo pipefail
185+ for plat in win32-x64 linux-x64 darwin-arm64; do
186+ echo "=== publishing @ytsuda/ripple-${plat} ==="
187+ (cd "npm/platforms/${plat}" && npm publish --access public --provenance)
188+ done
189+
190+ - name : Publish meta package with provenance
152191 env :
153192 NODE_AUTH_TOKEN : ${{ secrets.NPM_TOKEN }}
193+ shell : bash
154194 run : |
195+ set -euo pipefail
196+ cd npm
155197 npm publish --access public --provenance
156- if ($LASTEXITCODE -ne 0) { throw "npm publish failed (exit $LASTEXITCODE)" }
157198
158- # Extract the section for this version from CHANGELOG.md so the
159- # GitHub Release body matches exactly what's in the repo.
160199 - name : Extract changelog entry
161- id : changelog
162- shell : pwsh
200+ shell : bash
163201 run : |
164- $version = '${{ github.ref_name }}'.TrimStart('v')
165- $lines = Get-Content CHANGELOG.md
166- $startPattern = "^##\s+\[$([regex]::Escape($version))\]"
167- $startIdx = ($lines | Select-String -Pattern $startPattern | Select-Object -First 1).LineNumber
168- if (-not $startIdx) { throw "No CHANGELOG section for v$version" }
169- # Find the next "## [" heading; section ends one line above.
170- $endMatch = $lines | Select-Object -Skip $startIdx | Select-String -Pattern '^##\s+\[' | Select-Object -First 1
171- $endIdx = if ($endMatch) { $startIdx + $endMatch.LineNumber - 1 } else { $lines.Count }
172- $section = $lines[($startIdx)..($endIdx - 1)] -join "`n"
173- $section | Set-Content release-notes.md -NoNewline
174- "Extracted $($endIdx - $startIdx) lines for v$version"
202+ set -euo pipefail
203+ version='${{ github.ref_name }}'
204+ version="${version#v}"
205+ awk -v v="$version" '
206+ /^## +\[/ {
207+ if (capture) exit
208+ if ($0 ~ "^## +\\[" v "\\]") { capture = 1; next }
209+ }
210+ capture { print }
211+ ' CHANGELOG.md > release-notes.md
212+ if [[ ! -s release-notes.md ]]; then
213+ echo "No CHANGELOG section for v$version" >&2
214+ exit 1
215+ fi
216+ wc -l release-notes.md
175217
176218 - name : Create GitHub Release
177219 env :
178220 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
179221 run : |
180- gh release create ${{ github.ref_name }} `
181- --title "ripple ${{ github.ref_name }}" `
182- --notes-file release-notes.md `
183- --verify-tag `
184- dist\ripple.exe
222+ gh release create '${{ github.ref_name }}' \
223+ --title "ripple ${{ github.ref_name }}" \
224+ --notes-file release-notes.md \
225+ --verify-tag \
226+ 'npm/platforms/win32-x64/bin/ripple.exe#ripple-${{ github.ref_name }}-win32-x64.exe' \
227+ 'npm/platforms/linux-x64/bin/ripple#ripple-${{ github.ref_name }}-linux-x64' \
228+ 'npm/platforms/darwin-arm64/bin/ripple#ripple-${{ github.ref_name }}-darwin-arm64'
0 commit comments