Skip to content

Commit 4f11f41

Browse files
authored
Merge pull request #1002 from cipherstash/ci/stack-crates-release-pipelines
ci: add the release pipelines for the stack-* crates and @cipherstash/auth, inert
2 parents 62a972c + 2a38fb4 commit 4f11f41

33 files changed

Lines changed: 1364 additions & 57 deletions

‎.changeset/config.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
"@cipherstash/protect-ffi-linux-x64-gnu",
2020
"@cipherstash/protect-ffi-linux-arm64-gnu",
2121
"@cipherstash/protect-ffi-linux-x64-musl"
22+
],
23+
[
24+
"@cipherstash/auth",
25+
"@cipherstash/auth-darwin-x64",
26+
"@cipherstash/auth-darwin-arm64",
27+
"@cipherstash/auth-win32-x64-msvc",
28+
"@cipherstash/auth-linux-x64-gnu",
29+
"@cipherstash/auth-linux-arm64-gnu",
30+
"@cipherstash/auth-linux-x64-musl"
2231
]
2332
],
2433
"linked": [],
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
name: Build auth artifacts
2+
3+
# Reusable. Builds the six `@cipherstash/auth` napi binaries and the wasm
4+
# output, packs all seven npm tarballs, and uploads them as `auth-tarballs`.
5+
#
6+
# IT DOES NOT PUBLISH, for the reason `_build-ffi-artifacts.yml` gives: npm
7+
# validates a trusted publish against the entry-point workflow's filename, so
8+
# the publish stays in `release.yml`. Two callers: `release.yml`
9+
# (`auth-artifacts`, after the gate says an auth version is unpublished) and
10+
# `auth-preflight.yml` (the manual dry run).
11+
#
12+
# NO CACHING ANYWHERE IN HERE: the output is published with provenance, so
13+
# this file is on `scripts/lint-no-workflow-caching.mjs`'s target list. That
14+
# is also why no job restores `Swatinem/rust-cache`.
15+
16+
on:
17+
workflow_call:
18+
inputs:
19+
ref:
20+
description: Commit to build from
21+
required: true
22+
type: string
23+
24+
permissions:
25+
contents: read
26+
27+
defaults:
28+
run:
29+
shell: bash
30+
31+
jobs:
32+
binaries:
33+
name: ${{ matrix.platform }}
34+
strategy:
35+
# One platform failing must not cancel the other five.
36+
fail-fast: false
37+
matrix:
38+
include:
39+
- platform: darwin-x64
40+
target: x86_64-apple-darwin
41+
os: macos-latest
42+
- platform: darwin-arm64
43+
target: aarch64-apple-darwin
44+
os: macos-latest
45+
- platform: linux-x64-gnu
46+
target: x86_64-unknown-linux-gnu
47+
os: ubuntu-latest
48+
# A native arm64 runner rather than a cross-compile. The suite's
49+
# `blacksmith-8vcpu-ubuntu-2404-arm` label is not in
50+
# .github/actionlint.yaml; GitHub's own arm64 image is.
51+
- platform: linux-arm64-gnu
52+
target: aarch64-unknown-linux-gnu
53+
os: ubuntu-24.04-arm
54+
- platform: linux-x64-musl
55+
target: x86_64-unknown-linux-musl
56+
os: ubuntu-latest
57+
- platform: win32-x64-msvc
58+
target: x86_64-pc-windows-msvc
59+
os: windows-latest
60+
runs-on: ${{ matrix.os }}
61+
timeout-minutes: 60
62+
steps:
63+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
64+
with:
65+
ref: ${{ inputs.ref }}
66+
persist-credentials: false
67+
68+
- name: Install musl tools (linux-x64-musl)
69+
if: ${{ matrix.platform == 'linux-x64-musl' }}
70+
run: |
71+
set -euo pipefail
72+
sudo apt-get update
73+
sudo apt-get install -y musl-tools
74+
75+
# Rust 1.94.1 from the root mise.toml, the toolchain the crate is tested
76+
# with. `cache: false` is required here, not a default.
77+
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
78+
with:
79+
version: 2026.4.0
80+
install: true
81+
working_directory: .
82+
install_args: rust
83+
cache: false
84+
85+
# An explicit target is what keeps both Darwin legs apart:
86+
# `macos-latest` is arm64, so without it `darwin-x64` ships an arm64
87+
# binary.
88+
- name: Add the Rust target
89+
env:
90+
TARGET: ${{ matrix.target }}
91+
run: mise x -- rustup target add "$TARGET"
92+
93+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
94+
with:
95+
run_install: false
96+
cache: false
97+
98+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
99+
with:
100+
node-version: 22
101+
package-manager-cache: false
102+
103+
- name: Install node-gyp
104+
run: npm install -g node-gyp
105+
106+
- name: Install dependencies
107+
run: pnpm install --frozen-lockfile
108+
109+
# `--js false` is load-bearing. With `--platform`, napi v2 also writes its
110+
# own `index.js` loader over the committed one, which is a published,
111+
# frozen file (release-gate.mjs FROZEN_ARTEFACT_DIGESTS).
112+
- name: Build the native binding
113+
working-directory: languages/typescript/packages/auth
114+
env:
115+
TARGET: ${{ matrix.target }}
116+
run: mise x -- pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false
117+
118+
# The build must leave the tracked tree alone: `native.d.ts` is
119+
# regenerated and has to equal the committed copy, and nothing else may
120+
# change.
121+
- name: Verify the build changed no tracked file
122+
run: git diff --exit-code -- languages/typescript/packages/auth
123+
124+
- name: Place the binding in its platform package
125+
working-directory: languages/typescript/packages/auth
126+
env:
127+
PLATFORM: ${{ matrix.platform }}
128+
run: |
129+
set -euo pipefail
130+
mv "stack-auth-node.${PLATFORM}.node" "platforms/${PLATFORM}/"
131+
test -s "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node"
132+
133+
# `npm pack`, not `pnpm pack`: a platform package has no `workspace:`
134+
# dependency to rewrite. The wrapper does, and is packed with pnpm below.
135+
- name: Pack the platform package
136+
env:
137+
PLATFORM: ${{ matrix.platform }}
138+
run: |
139+
set -euo pipefail
140+
mkdir -p auth-dist
141+
(cd "languages/typescript/packages/auth/platforms/${PLATFORM}" && npm pack)
142+
mv "languages/typescript/packages/auth/platforms/${PLATFORM}"/*.tgz auth-dist/
143+
ls auth-dist
144+
145+
- name: Verify the tarball is the platform package, with its binary
146+
env:
147+
PLATFORM: ${{ matrix.platform }}
148+
run: |
149+
set -euo pipefail
150+
shopt -s nullglob
151+
tarballs=(auth-dist/*.tgz)
152+
test "${#tarballs[@]}" -eq 1 || {
153+
echo "::error::expected one tarball, found ${#tarballs[@]}"; exit 1; }
154+
tgz="${tarballs[0]}"
155+
name=$(tar xzOf "$tgz" package/package.json | node -p \
156+
'JSON.parse(require("node:fs").readFileSync(0,"utf8")).name')
157+
test "$name" = "@cipherstash/auth-${PLATFORM}" || {
158+
echo "::error::packed $name, expected the ${PLATFORM} platform package"
159+
exit 1; }
160+
# Into a variable, not `tar | grep -q`: SIGPIPE under pipefail. See
161+
# _build-ffi-artifacts.yml.
162+
listing=$(tar tzf "$tgz")
163+
grep -qx "package/stack-auth-node.${PLATFORM}.node" <<< "$listing" || {
164+
echo "::error::$tgz has no stack-auth-node.${PLATFORM}.node"; exit 1; }
165+
166+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
167+
with:
168+
name: auth-platform-${{ matrix.platform }}
169+
path: auth-dist/*.tgz
170+
if-no-files-found: error
171+
172+
wrapper:
173+
name: wasm + wrapper tarball
174+
# Collects the six platform artifacts, so a caller downloads
175+
# `auth-tarballs` and has all seven.
176+
needs: [binaries]
177+
runs-on: ubuntu-latest
178+
timeout-minutes: 45
179+
steps:
180+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
181+
with:
182+
ref: ${{ inputs.ref }}
183+
persist-credentials: false
184+
185+
# Rust with the wasm32-unknown-unknown target from the root mise.toml,
186+
# and wasm-pack, which `build:wasm` shells out to and the root mise.toml
187+
# does not pin.
188+
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
189+
with:
190+
version: 2026.4.0
191+
install: true
192+
working_directory: .
193+
install_args: rust aqua:wasm-bindgen/wasm-pack@0.13.1
194+
cache: false
195+
196+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
197+
with:
198+
run_install: false
199+
cache: false
200+
201+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
202+
with:
203+
node-version: 22
204+
package-manager-cache: false
205+
206+
- name: Install node-gyp
207+
run: npm install -g node-gyp
208+
209+
- name: Install dependencies
210+
run: pnpm install --frozen-lockfile
211+
212+
# `wasm/` is in the wrapper's `files` and is not tracked.
213+
- name: Build wasm
214+
working-directory: languages/typescript/packages/auth
215+
run: mise x aqua:wasm-bindgen/wasm-pack@0.13.1 -- pnpm run build:wasm
216+
217+
# `pnpm pack`, not `npm pack`: the six platform peers are `workspace:*`,
218+
# which only pnpm rewrites to the exact version. An npm-packed wrapper
219+
# would publish `workspace:*` ranges that no registry can resolve.
220+
- name: Pack the wrapper
221+
run: |
222+
set -euo pipefail
223+
mkdir -p auth-dist
224+
pnpm --dir languages/typescript/packages/auth pack
225+
mv languages/typescript/packages/auth/cipherstash-auth-[0-9]*.tgz auth-dist/
226+
227+
- name: Verify the wrapper tarball
228+
run: |
229+
set -euo pipefail
230+
tgz=$(ls auth-dist/cipherstash-auth-[0-9]*.tgz)
231+
tar tzf "$tgz" > listing.txt
232+
# Every path the packed manifest's `exports` resolve to, plus every
233+
# plain `files` entry. `wasm/` is a folder entry, covered by the
234+
# wasm export paths.
235+
tar xzOf "$tgz" package/package.json | node -e '
236+
const j = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
237+
const paths = new Set()
238+
const walk = (node) => {
239+
if (typeof node === "string") { if (node.startsWith("./")) paths.add("package/" + node.slice(2)) }
240+
else if (node && typeof node === "object") { for (const v of Object.values(node)) walk(v) }
241+
}
242+
walk(j.exports)
243+
for (const f of j.files ?? []) { if (!f.endsWith("/")) paths.add("package/" + f) }
244+
paths.add("package/wasm/stack_auth_wasm_bg.wasm")
245+
console.log([...paths].sort().join("\n"))
246+
' > required.txt
247+
cat required.txt
248+
while read -r required ; do
249+
grep -qx "$required" listing.txt || {
250+
echo "::error::$required missing from $tgz"; exit 1; }
251+
done < required.txt
252+
# The six platform peers must be concrete versions equal to the
253+
# wrapper's own.
254+
tar xzOf "$tgz" package/package.json | node -e '
255+
const j = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
256+
const peers = Object.entries(j.peerDependencies ?? {}).filter(([n]) => n.startsWith("@cipherstash/auth-"))
257+
if (peers.length !== 6) { console.error("expected 6 platform peers, found " + peers.length); process.exit(1) }
258+
for (const [n, v] of peers) {
259+
if (v !== j.version) { console.error(n + " is " + v + ", expected " + j.version); process.exit(1) }
260+
}
261+
console.log("platform peers OK")
262+
'
263+
264+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
265+
with:
266+
pattern: auth-platform-*
267+
path: auth-dist
268+
merge-multiple: true
269+
270+
- name: Verify all seven tarballs are present and distinct
271+
run: |
272+
set -euo pipefail
273+
shopt -s nullglob
274+
tarballs=(auth-dist/*.tgz)
275+
count=${#tarballs[@]}
276+
test "$count" -eq 7 || {
277+
echo "::error::expected 7 tarballs, found $count"; ls auth-dist; exit 1; }
278+
names=$(for t in "${tarballs[@]}" ; do
279+
tar xzOf "$t" package/package.json | node -p \
280+
'JSON.parse(require("node:fs").readFileSync(0,"utf8")).name'
281+
done | sort -u | wc -l)
282+
test "$names" -eq 7 || {
283+
echo "::error::expected 7 distinct package names, found $names"; exit 1; }
284+
285+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
286+
with:
287+
name: auth-tarballs
288+
path: auth-dist/*.tgz
289+
if-no-files-found: error

0 commit comments

Comments
 (0)