Skip to content

Commit 33f3dc1

Browse files
committed
feat(release): v0.11.0 — multi-platform npm distribution
Ship Linux x64 and macOS Apple Silicon native binaries alongside Windows via esbuild-style optionalDependencies. The meta-package @ytsuda/ripple carries bin/cli.mjs (a Node launcher that resolves the platform-matched subpackage and spawns its native binary with stdio inherited + signal forwarding) and optionalDependencies pinning @ytsuda/ripple-{win32-x64, linux-x64,darwin-arm64}. npm's os/cpu filters mean only the matching subpackage is downloaded per install. Workflow reshaped into a two-job matrix: build on {windows-latest, ubuntu-latest, macos-latest} with dotnet publish -r matching the RID, runs --test suite as runtime gate, uploads artifact; publish on ubuntu-latest (environment: release) downloads all three, Authenticode- signs the Windows binary via AzureSignTool (cross-platform on Linux), publishes subpackages sequentially then meta, attaches all three binaries to the GitHub Release. Version cross-check now verifies five fields: csproj <Version>, meta package.json, and each of three platform subpackages — any mismatch aborts publish fast. *.mjs added to .gitattributes (eol=lf) so the Node shebang stays LF-terminated for Linux/macOS consumers.
1 parent 48b175c commit 33f3dc1

12 files changed

Lines changed: 316 additions & 139 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
# should be stable across platforms).
2828
*.py text eol=lf
2929
*.js text eol=lf
30+
*.mjs text eol=lf
3031
*.bash text eol=lf
3132
*.zsh text eol=lf
3233
*.sh text eol=lf

.github/workflows/release.yml

Lines changed: 162 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
79
on:
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.
1414
concurrency:
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

2222
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
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

Comments
 (0)