-
Notifications
You must be signed in to change notification settings - Fork 7
369 lines (346 loc) · 17.8 KB
/
Copy pathrelease.yml
File metadata and controls
369 lines (346 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
name: Release JS
# READ-ONLY BY DEFAULT; the two jobs that publish escalate for themselves.
#
# npm trusted publishing is bound to a repository AND A WORKFLOW FILENAME, so
# once this file is the registered publisher, an OIDC token minted by ANY job in
# it is one npm accepts for a publish — the registry cannot tell `gate` apart
# from `publish-ffi`. These three scopes were declared here, where they are a
# default rather than a ceiling, and `gate` (a checkout and one `node` call) and
# `ffi-artifacts` inherited all of them.
#
# Granting them per job is not the same fix as overriding the two that had them
# wrongly: it makes omission the safe answer, so the next job added to this file
# has to ASK for the publish credential in its own diff.
#
# See https://docs.npmjs.com/trusted-publishers#supported-cicd-providers
# Enforced by scripts/__tests__/workflow-publish-permissions.test.mjs.
permissions:
contents: read
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
# WHAT STILL HAS TO BE PUBLISHED, asked of the registry rather than of the
# `.changeset/` directory. "No unconsumed changesets" is also true of an
# ordinary docs commit and of the commit right after a release, so gating the
# native matrix on that would fire it routinely; asking npm which committed
# versions are missing is exact.
#
# This gate is load-bearing. A false negative skips the FFI branch below, and
# `changeset publish` then packs the six platform workspaces — where
# `index.node` is a build output nobody produced — and publishes them. Every
# failure mode in scripts/release-gate.mjs therefore throws rather than
# reporting "nothing to publish".
gate:
name: What needs publishing?
runs-on: ubuntu-latest
timeout-minutes: 10
# `ffi` only. The gate also computes `js`, and it is in the job log, but no
# job can be keyed on it: `release` below has to run on every push to main
# to open and update the Version Packages PR, published or not. Declaring
# it as an output read nothing and implied a gate that does not exist.
outputs:
ffi: ${{ steps.gate.outputs.ffi }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6.5.0
with:
node-version: 22
package-manager-cache: false
# No pnpm, no install. scripts/release-gate.mjs imports node builtins
# only and shells out to the runner image's npm — deliberately, because
# this job runs on EVERY push to main and no caching is permitted in a
# publishing workflow, so an install here is a cold full-workspace one
# (~1GB, node-pty's node-gyp rebuild included) to answer a question the
# tree already holds. See the header of that script.
- name: Compute the gate
id: gate
run: node scripts/release-gate.mjs
ffi-artifacts:
name: Build FFI artifacts
needs: [gate]
if: needs.gate.outputs.ffi == 'true'
uses: ./.github/workflows/_build-ffi-artifacts.yml
with:
ref: ${{ github.sha }}
publish-ffi:
name: Publish FFI packages
needs: [gate, ffi-artifacts]
if: needs.gate.outputs.ffi == 'true'
# GitHub-hosted for the same reason the release job is: npm rejects
# provenance from a self-hosted runner with E422. This job only uploads
# prebuilt tarballs, so it needs no toolchain beyond node and npm.
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write # the seven git tags and the GitHub release
id-token: write # npm OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: ffi-tarballs
path: ffi-dist
# No `registry-url:`. setup-node with one writes a
# `//registry.npmjs.org/:_authToken` line into .npmrc, which shadows OIDC
# and fails every publish with E404.
- uses: actions/setup-node@v6.5.0
with:
node-version: 22
package-manager-cache: false
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@^11.5.1
# BEFORE `changeset publish`, deliberately: changesets packs from the
# workspace, where the platform packages have no index.node, so running it
# first would publish six broken tarballs. Once these are on npm,
# changesets skips them ("is not being published because version X is
# already published on npm") and the ordering needs no extra condition.
#
# PLATFORM PACKAGES FIRST, WRAPPER LAST. A plain `*.tgz` glob is
# lexicographic and puts `cipherstash-protect-ffi-0.32.0.tgz` ahead of
# `cipherstash-protect-ffi-darwin-arm64-0.32.0.tgz` ('0' < 'd'), which
# would briefly publish a wrapper whose six optionalDependencies do not
# exist yet — and `npm install` during that window resolves no binding.
#
# Idempotent per tarball, so a re-run after a partial failure completes
# the set instead of aborting on the first already-published package.
- name: Publish the tarballs
id: publish
run: |
set -euo pipefail
meta () { tar xzOf "$1" package/package.json | node -p \
"JSON.parse(require('node:fs').readFileSync(0,'utf8')).$2"; }
shopt -s nullglob
wrapper=""
platforms=()
for tgz in ffi-dist/*.tgz ; do
if [ "$(meta "$tgz" name)" = "@cipherstash/protect-ffi" ]; then
wrapper="$tgz"
else
platforms+=("$tgz")
fi
done
test -n "$wrapper" || { echo "::error::no wrapper tarball"; exit 1; }
test "${#platforms[@]}" -eq 6 || {
echo "::error::expected 6 platform tarballs, got ${#platforms[@]}"; exit 1; }
published=()
for tgz in "${platforms[@]}" "$wrapper" ; do
name=$(meta "$tgz" name)
version=$(meta "$tgz" version)
if npm view "${name}@${version}" version >/dev/null 2>&1; then
echo "${name}@${version} already published — skipping"
else
# "./" is load-bearing: npm classifies a bare `dir/file.tgz`
# argument as a GitHub `owner/repo` shorthand before it considers
# it a file, then dies in `git ls-remote` — which is exactly how
# the first 2.0.0 release attempt failed. A path-ish spec is only
# treated as a tarball when it starts with ./, ../, / or file:.
npm publish --access public --provenance "./$tgz"
fi
published+=("${name}@${version}")
done
printf '%s\n' "${published[@]}" > published.txt
echo "version=$(meta "$wrapper" version)" >> "$GITHUB_OUTPUT"
# Changesets tags only what IT published — `tagPublish` receives
# `publishedPackages.filter(p => p.result === "published")` — and it skips
# these seven as already-published. Without this step an FFI release has
# no git tag and no GitHub release at all.
- name: Tag and release
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
# Existence is not the question — where the tag POINTS is. A re-run
# after a partial failure must find its own tags and move on; a tag at
# a different commit means this version was released from another
# tree, and skipping silently would leave the published artifacts and
# the tagged source disagreeing with nothing in the log to say so.
#
# `git/matching-refs`, not `git/ref`. These tag names carry a slash
# (`@cipherstash/protect-ffi@0.32.0`), and `git/ref/{ref}` answers a
# non-exact match with an ARRAY of refs rather than an object — so
# `--jq .object.sha` yields nothing, the branch below reads "no tag",
# and the create then fails with 422 "Reference already exists" on a
# re-run that should have been a no-op. matching-refs always returns
# an array and an empty one for no match, so filtering it for the
# exact ref is well-defined in every case.
while read -r tag ; do
at=$(gh api "repos/${REPO}/git/matching-refs/tags/${tag}" \
--jq ".[] | select(.ref == \"refs/tags/${tag}\") | .object.sha" 2>/dev/null || true)
if [ -n "$at" ]; then
test "$at" = "$GITHUB_SHA" || {
echo "::error::tag ${tag} points at ${at}, not ${GITHUB_SHA}"; exit 1; }
echo "tag ${tag} already at this commit — skipping"
else
gh api -X POST "repos/${REPO}/git/refs" \
-f ref="refs/tags/${tag}" -f sha="$GITHUB_SHA" >/dev/null
echo "created ${tag}"
fi
done < published.txt
# Attached to the wrapper's own tag, which the loop above just
# created. A `protect-ffi-v<version>` release name would make
# `gh release create` mint an EIGHTH tag for the same commit;
# `--verify-tag` refuses to create a tag that does not already exist.
# The name matches what changesets produces for the JS packages.
rel="@cipherstash/protect-ffi@${VERSION}"
if ! gh release view "$rel" --repo "$REPO" >/dev/null 2>&1; then
gh release create "$rel" --repo "$REPO" --verify-tag \
--title "protect-ffi v${VERSION}" \
--notes "Native FFI bindings ${VERSION}. Published: $(tr '\n' ' ' < published.txt)"
fi
# Unconditional, and separate from creation: a release that exists
# with a partial asset set is what a failed re-run leaves behind, so
# skipping on existence is not idempotence. `--clobber` makes the
# complete case a no-op.
gh release upload "$rel" ffi-dist/*.tgz --repo "$REPO" --clobber
release:
name: Release
needs: [gate, publish-ffi]
# `always()` because `publish-ffi` is SKIPPED for an ordinary JS release,
# and a skipped dependency would otherwise skip this job too.
#
# The condition has to tell "skipped because FFI was unnecessary" apart from
# "skipped because its prerequisite failed". If `ffi-artifacts` fails,
# `publish-ffi` is SKIPPED rather than failed — so the obvious
# `result != 'failure'` check passes, and `changeset publish` goes on to
# pack and publish the platform workspaces without their binaries. Keyed on
# the gate's own output instead: if FFI was in scope, its publish must have
# SUCCEEDED.
if: >-
always() &&
needs.gate.result == 'success' &&
(
needs.gate.outputs.ffi != 'true' ||
needs.publish-ffi.result == 'success'
)
# GitHub-hosted (not Blacksmith): npm provenance attestations, which are
# generated automatically by OIDC trusted publishing, are only accepted
# from github-hosted runners — self-hosted runners are rejected with E422.
runs-on: ubuntu-latest
permissions:
id-token: write # npm OIDC trusted publishing
contents: write # changesets commits and pushes the Version Packages branch
pull-requests: write # …and opens/updates the PR for it
steps:
- name: Checkout Repo
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6.0.10
name: Install pnpm
with:
run_install: false
# Supply-chain hardening — never cache the pnpm store; a poisoned
# cache entry would execute in this credential-bearing workflow.
cache: false
- name: Install Node.js
uses: actions/setup-node@v6.5.0
with:
node-version: 22
# No `cache:`, and package-manager-cache disabled. release.yml
# publishes to npm (OIDC trusted publishing) and must not restore the
# GitHub Actions cache — a cache-poisoning / supply-chain vector.
# Enforced by .github/workflows/tests-supply-chain.yml.
package-manager-cache: false
# node-pty's install hook falls back to `node-gyp rebuild` when no
# linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships
# node-gyp on PATH, so install it explicitly.
- name: Install node-gyp
run: npm install -g node-gyp
# npm OIDC trusted publishing requires npm >= 11.5.1; Node 22 ships
# npm 10.x. `changeset publish` shells out to this npm to publish.
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@^11.5.1
- name: Install dependencies
run: pnpm install --frozen-lockfile
# REQUIRED BY `version:` BELOW, and nothing in this file says so without
# this comment — which is why there is also a test. `pnpm run version` is
# `changeset version && node scripts/sync-lockstep-versions.mjs`, and that
# script ends in `execFileSync('mise', ['run',
# 'release:prepare_bindings_assets', …])`, which reaches
# `packages/eql/tasks/build.sh` and two `cargo run -p eql-codegen` calls.
# mise is NOT preinstalled on GitHub's ubuntu images, so without this step
# the hook dies with ENOENT — AFTER `changeset version` has already
# rewritten every manifest and changelog, in a job holding
# `contents: write`. Asserted by
# scripts/__tests__/workflow-mise-setup.test.mjs.
#
# It only fires on the branch where `.changeset/` is non-empty (the
# Version Packages branch), which is why a publish rehearsal never
# exercised it.
#
# `working_directory: packages/eql` for the reason test-eql.yml records at
# length: mise reads config from the current directory and its PARENTS, so
# an action running at the repo root never sees `packages/eql/mise.toml`.
# It would install nothing and leave the config untrusted, and the first
# `mise run` fails with "Config files … are not trusted" — which reads as
# a toolchain problem rather than a path one. That file is also where the
# Rust toolchain comes from (`[tools] rust`), so this step is the cargo
# setup as well; there is deliberately no second one.
#
# `cache: false` IS NOT THE DEFAULT — jdx/mise-action caches by default,
# and scripts/lint-no-workflow-caching.mjs forbids a GitHub Actions cache
# restore anywhere an artifact gets published. A poisoned entry here would
# execute in the job that holds the npm publishing credential.
#
# `add_shims_to_path: false` IS LOAD-BEARING, and it is the input a
# copy-paste from test-eql.yml would not carry. `packages/eql/mise.toml`
# pins `node = "22"` under `[tools]`, and mise's shim directory is
# PREPENDED to PATH for every later step — so with the default `true`,
# mise's own Node would shadow the one `actions/setup-node` installed, and
# `changeset publish` would shell out to that Node's bundled npm 10.x
# instead of the `npm@^11.5.1` installed above. OIDC trusted publishing
# requires >= 11.5.1 and fails with E404 below it, which is the exact
# failure the two comments above this step exist to prevent. `mise run`
# resolves its own toolchain internally, so nothing here needs the shims.
#
# `env: false` for a smaller version of the same argument: the default
# exports that file's `[env]` block — `DATABASE_URL`, `POSTGRES_PASSWORD`
# and friends, all pointed at a Postgres this job does not have — into
# GITHUB_ENV for every subsequent step.
#
# SHA-pinned, matching .github/actions/build-ffi-binding/action.yml:
# mise-action executes third-party code in the job that publishes, so a
# mutable `@v4` would let that code change with no commit here. The pin is
# the same v4 commit test-eql.yml uses. Dependabot moves the pin and the
# trailing comment together (.github/dependabot.yml covers github-actions).
- name: Install mise (the lockstep version hook shells out to it)
uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
with:
version: 2026.4.0
install: true
cache: false
working_directory: packages/eql
add_shims_to_path: false
env: false
- name: Publish to npm
id: changesets
uses: changesets/action@v1.9.0
with:
publish: pnpm run release
# LOAD-BEARING, and it fails OPEN if removed. Without `version:` the
# action runs its own built-in `changeset version` and never invokes
# the root `version` script — so `scripts/sync-lockstep-versions.mjs`
# would not run, npm would bump while
# packages/eql/crates/eql-bindings/Cargo.toml and the bundled SQL
# assets kept the old version, and the first symptom would be a
# published crate disagreeing with the SQL bundle it ships.
# Asserted by scripts/__tests__/release-version-hook.test.mjs.
version: pnpm run version
commitMode: 'github-api'
env:
# No NPM_TOKEN — publishing authenticates via npm OIDC trusted
# publishing (id-token: write above). If NPM_TOKEN is set,
# changesets/action writes a token .npmrc that shadows OIDC and
# every publish fails with E404 (see npm/cli#8976).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Embeds the CLI's PostHog project key at build time (see
# packages/cli/tsup.config.ts). A repo *variable*, not a secret: the
# key is public and write-only (like a web SDK key). Unset until GA, so
# every release before it is flipped ships telemetry-dormant. This is
# the single go-live switch — set it with:
# gh variable set STASH_POSTHOG_KEY --repo cipherstash/stack --body '<phc_...>'
STASH_POSTHOG_KEY: ${{ vars.STASH_POSTHOG_KEY }}