Skip to content

Merge pull request #1022 from cipherstash/fix/go-context-owns-byte-parts #512

Merge pull request #1022 from cipherstash/fix/go-context-owns-byte-parts

Merge pull request #1022 from cipherstash/fix/go-context-owns-byte-parts #512

Workflow file for this run

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.
#
# FOUR RELEASE LINES, ONE FILE: the JS packages (changesets), the seven FFI
# tarballs, the seven @cipherstash/auth tarballs, and the EQL line ported here
# from packages/eql/.github/. They share a file because npm trusted publishing
# binds to a repository AND a workflow filename, so every npm publish in this
# repository has to happen here.
#
# The auth jobs run when the gate reports `auth=true`: an auth version in the
# tree that npm does not carry. Re-freezing the seven auth packages in
# FROZEN_PUBLISHERS makes that version a blocker, so `gate` fails first.
#
# `workflow_dispatch` came with the EQL port (its prerelease path is dispatched
# against a release branch). A dispatch reaches every job in the file, so
# `classify` computes the mode once and the publishing jobs key on it rather
# than assuming a push to `main`.
#
# The EQL jobs are INERT: they sit behind `eql-armed`
# (scripts/eql-pipeline-armed.mjs) as well as behind `gate`. See AGENTS.md,
# "Working on EQL".
permissions:
contents: read
on:
push:
branches:
- main
# The EQL prerelease path: dispatched against a release branch whose HEAD is
# an explicit `chore(release): …` marker commit. A dispatch against `main`
# classifies as `production` and behaves exactly like a push — `gate` makes it
# a no-op when there is nothing unpublished.
workflow_dispatch: {}
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
# Which release shape this run is, read by every publishing job below.
#
# production — a push to `main`, or a dispatch against it.
# prerelease — a dispatch against any other branch whose HEAD subject is
# `chore(release):`. Cuts an EQL alpha/rc without merging.
# skip — everything else, including an already-published identity.
#
# Safety on the prerelease path is the marker commit, the version regex and
# the tag short-circuit — not the branch name. Exactly `chore(release):`; a
# bare `release:` must not re-trigger a publish.
classify:
name: Classify release intent
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
mode: ${{ steps.classify.outputs.mode }}
version: ${{ steps.classify.outputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Depth 1 (the default): this reads `git log -1` and runs on every
# push to main. Upstream used `fetch-depth: 0`. The tag check below
# asks the API, not the remote, so no pushable credential either.
persist-credentials: false
- id: classify
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
branch="${GITHUB_REF_NAME}"
subject="$(git log -1 --format=%s)"
mode="skip"
version=""
if [[ "$branch" == "main" ]]; then
mode="production"
else
case "$subject" in
chore\(release\):*)
mode="prerelease"
;;
esac
fi
if [[ "$mode" == "prerelease" ]]; then
# Two levels down: the subtree root carries no package.json.
# Upstream this was `./packages/eql/package.json`.
version="$(node -p "require('./packages/eql/packages/eql/package.json').version")"
# prepare-bindings-assets.sh enforces the same shape with the
# suffix OPTIONAL; here it is required, because a version reaching
# this branch has already been classified a prerelease and a bare
# `3.0.6` would be one wearing the wrong identity. Checked here
# because this job runs first and that script runs last. Upstream
# checked only for a hyphen, so `3.0.6-beta` classified as a
# prerelease and got a public tag and GitHub release before the npm
# job died on it.
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo "::error::a prerelease release commit must pin an exact prerelease identity (X.Y.Z-(alpha|beta|rc).N) in packages/eql/packages/eql/package.json; found '${version}'" >&2
exit 1
fi
# Idempotency: if this identity's tag exists the release was cut,
# so a re-push of the marker must not publish it twice.
# `matching-refs`, not `git/ref` — see the note on `publish-ffi`.
tag="eql-typescript-v${version}"
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
echo "${tag} already exists; prerelease ${version} was already published — skipping"
mode="skip"
version=""
fi
fi
echo "classified as ${mode}${version:+ at ${version}}"
{
echo "mode=$mode"
echo "version=$version"
} >> "$GITHUB_OUTPUT"
# May this repository publish the EQL line at all? A checkout and one `node`
# call reading a Map — no registry lookup, so every EQL job in this run gets
# the same answer. See scripts/eql-pipeline-armed.mjs.
eql-armed:
name: Is the EQL release line armed?
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
armed: ${{ steps.armed.outputs.armed }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
package-manager-cache: false
# No pnpm install: the script imports node builtins only, as `gate` does.
- name: Read the publisher switch
id: armed
run: node scripts/eql-pipeline-armed.mjs
# 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` and `auth` 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 }}
auth: ${{ steps.gate.outputs.auth }}
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: [classify, gate]
# `mode == 'production'` is what workflow_dispatch cost: without it a
# dispatch against a feature branch could publish FFI from that branch.
if: needs.classify.outputs.mode == 'production' && needs.gate.outputs.ffi == 'true'
uses: ./.github/workflows/_build-ffi-artifacts.yml
with:
ref: ${{ github.sha }}
publish-ffi:
name: Publish FFI packages
needs: [classify, gate, ffi-artifacts]
if: needs.classify.outputs.mode == 'production' && 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
# `release` waits for npm to serve every one of these before it runs
# `changeset publish`. Empty when this job is skipped.
outputs:
published: ${{ steps.publish.outputs.published }}
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)"
echo "published=${published[*]}"
} >> "$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
auth-artifacts:
name: Build auth artifacts
needs: [classify, gate]
if: needs.classify.outputs.mode == 'production' && needs.gate.outputs.auth == 'true'
uses: ./.github/workflows/_build-auth-artifacts.yml
with:
ref: ${{ github.sha }}
# The publish-ffi pattern, for the seven @cipherstash/auth tarballs. BEFORE
# `changeset publish` for the same reason: changesets would pack the platform
# workspaces, which hold no .node binary. Once these are on npm, changesets
# skips all seven as already published.
publish-auth:
name: Publish auth packages
needs: [classify, gate, auth-artifacts]
if: needs.classify.outputs.mode == 'production' && needs.gate.outputs.auth == 'true'
# GitHub-hosted: npm rejects provenance from a self-hosted runner (E422).
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
# Waited for by `release`, as publish-ffi's is.
outputs:
published: ${{ steps.publish.outputs.published }}
steps:
- uses: actions/download-artifact@v4
with:
name: auth-tarballs
path: auth-dist
# No `registry-url:`: it writes an `_authToken` line that shadows OIDC.
- 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
# PLATFORM PACKAGES FIRST, WRAPPER LAST, so no install ever resolves a
# wrapper whose platform peers are missing. Idempotent per tarball.
- 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 auth-dist/*.tgz ; do
if [ "$(meta "$tgz" name)" = "@cipherstash/auth" ]; 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; see publish-ffi.
npm publish --access public --provenance "./$tgz"
fi
published+=("${name}@${version}")
done
printf '%s\n' "${published[@]}" > published.txt
{
echo "version=$(meta "$wrapper" version)"
echo "published=${published[*]}"
} >> "$GITHUB_OUTPUT"
# Changesets tags only what it published itself, so without this an auth
# release has no git tag and no GitHub release. The same idempotent tag
# check as publish-ffi.
- name: Tag and release
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
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
rel="@cipherstash/auth@${VERSION}"
if ! gh release view "$rel" --repo "$REPO" >/dev/null 2>&1; then
gh release create "$rel" --repo "$REPO" --verify-tag \
--title "auth v${VERSION}" \
--notes "@cipherstash/auth native bindings ${VERSION}. Published: $(tr '\n' ' ' < published.txt)"
fi
gh release upload "$rel" auth-dist/*.tgz --repo "$REPO" --clobber
release:
name: Release
needs: [classify, gate, publish-ffi, publish-auth]
# `always()` because `publish-ffi` and `publish-auth` are 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 outputs instead: if FFI or auth was in scope, its publish
# must have SUCCEEDED.
if: >-
always() &&
needs.classify.outputs.mode == 'production' &&
needs.gate.result == 'success' &&
(
needs.gate.outputs.ffi != 'true' ||
needs.publish-ffi.result == 'success'
) &&
(
needs.gate.outputs.auth != 'true' ||
needs.publish-auth.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
# Read by `eql-assets`. Straight from the step, with no step in between:
# changesets/action sets it for whatever it published even when
# `changeset publish` then fails, and a job output is evaluated when the
# job ends, whatever its result.
outputs:
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
steps:
- name: Checkout Repo
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6.1.0
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
# BEFORE `changeset publish`. npm accepts a publish minutes before its
# package document lists the version, and `changeset publish` publishes
# every version that document does not list — the native packages again,
# from the workspace, with `restricted` access, which npm refuses with
# E402. That failed this job for protect-ffi 0.33.0 and for
# @cipherstash/auth 0.44.1. See scripts/wait-for-npm-versions.mjs.
- name: Wait for npm to list the native packages
timeout-minutes: 20
env:
FFI_PUBLISHED: ${{ needs.publish-ffi.outputs.published }}
AUTH_PUBLISHED: ${{ needs.publish-auth.outputs.published }}
run: node scripts/wait-for-npm-versions.mjs
- 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
# languages/typescript/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 }}
# ---- The EQL release line: production ------------------------------------
#
# EQL ships as five artefacts at one version. Changesets publishes the npm
# package (above) and release-plz.yml the crate on the same push; the rest are
# built here.
#
# `eql-assets` decides from npm and the tags, not from this run, so a run
# whose `changeset publish` failed still builds them, and so does the next
# push to main if that run never got this far. See
# scripts/eql-release-assets.mjs.
#
# `!cancelled()`, NOT the implicit `success()`, on all four: `success()`
# is false when ANY job up the `needs:` chain was skipped or failed, and
# `publish-ffi` and `publish-auth` are skipped on most releases. Each job
# names the results it does need instead. `always()` would also do that, and
# would keep them running after somebody cancelled the run.
#
# The `needs:` chain is load-bearing: `eql-docs` attaches to the release
# `eql-sql` creates, and `eql-image` dispatches against the tag it produced.
eql-assets:
name: Does EQL still need its release assets?
needs: [classify, gate, release]
if: >-
!cancelled() &&
needs.classify.outputs.mode == 'production' &&
needs.gate.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
needed: ${{ steps.eql.outputs.needed }}
version: ${{ steps.eql.outputs.version }}
prerelease: ${{ steps.eql.outputs.prerelease }}
ref: ${{ steps.eql.outputs.ref }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
package-manager-cache: false
# No pnpm install: the script imports node builtins only, as `gate` does.
- name: Ask npm and the tags
id: eql
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PUBLISHED_PACKAGES: ${{ needs.release.outputs.published_packages }}
run: node scripts/eql-release-assets.mjs
eql-sql:
name: Build and attach the EQL SQL release
needs: [classify, eql-armed, eql-assets]
if: >-
!cancelled() &&
needs.classify.outputs.mode == 'production' &&
needs.eql-armed.outputs.armed == 'true' &&
needs.eql-assets.outputs.needed == 'true'
permissions:
contents: write # creates the eql-<version> tag and release
uses: ./.github/workflows/_build-eql-sql.yml
with:
# The commit npm's tarball was built from, which is this run's commit
# unless this run is repairing an older release.
ref: ${{ needs.eql-assets.outputs.ref }}
tag: eql-${{ needs.eql-assets.outputs.version }}
attach: true
target_commitish: ${{ needs.eql-assets.outputs.ref }}
prerelease: ${{ needs.eql-assets.outputs.prerelease == 'true' }}
eql-docs:
name: Build and attach the EQL docs bundle
needs: [classify, eql-armed, eql-assets, eql-sql]
if: >-
!cancelled() &&
needs.classify.outputs.mode == 'production' &&
needs.eql-armed.outputs.armed == 'true' &&
needs.eql-assets.outputs.needed == 'true' &&
needs.eql-sql.result == 'success'
permissions:
contents: write # attaches to the release eql-sql just created
uses: ./.github/workflows/_build-eql-docs.yml
with:
ref: ${{ needs.eql-assets.outputs.ref }}
tag: eql-${{ needs.eql-assets.outputs.version }}
eql-image:
name: Dispatch the Postgres + EQL image build
needs: [classify, eql-armed, eql-assets, eql-sql, eql-docs]
# Production finals only: the floating :latest / :<version> tags must not
# move for a prerelease. An alpha image is still buildable on demand.
if: >-
!cancelled() &&
needs.classify.outputs.mode == 'production' &&
needs.eql-armed.outputs.armed == 'true' &&
needs.eql-assets.outputs.needed == 'true' &&
needs.eql-assets.outputs.prerelease == 'false' &&
needs.eql-sql.result == 'success' &&
needs.eql-docs.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write # gh workflow run
steps:
- name: Dispatch release-postgres-eql-image.yml
# Dispatched, not `on: release`: a GITHUB_TOKEN-created release does not
# trigger that event, but a GITHUB_TOKEN dispatch does. Against the
# eql-<version> TAG that `eql-sql` created, so the image is built from
# the released source even if main has advanced.
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.eql-assets.outputs.version }}
run: |
set -euo pipefail
# `--repo` is required: no checkout, so gh cannot infer it.
gh workflow run release-postgres-eql-image.yml \
--repo "${GITHUB_REPOSITORY}" \
--ref "eql-${VERSION}" \
-f eql_version="$VERSION" \
-f update_floating_tags=true
# ---- The EQL release line: prerelease ------------------------------------
#
# Dispatched against a branch whose HEAD is a `chore(release):` marker, so an
# alpha/rc can be cut without merging to `main`. Changesets is not involved —
# the version is already pinned in the manifest.
#
# SQL and docs build first and the npm publish `needs:` both: the tarball
# embeds the SQL assets, so a build failure must not arrive after the publish.
prerelease-eql-sql:
name: Build and attach the prerelease EQL SQL
needs: [classify, gate, eql-armed]
if: >-
needs.classify.outputs.mode == 'prerelease' &&
needs.eql-armed.outputs.armed == 'true'
permissions:
contents: write
uses: ./.github/workflows/_build-eql-sql.yml
with:
ref: ${{ github.sha }}
tag: eql-${{ needs.classify.outputs.version }}
attach: true
target_commitish: ${{ github.sha }}
prerelease: true
prerelease-eql-docs:
name: Build and attach the prerelease EQL docs
needs: [classify, gate, eql-armed, prerelease-eql-sql]
if: >-
needs.classify.outputs.mode == 'prerelease' &&
needs.eql-armed.outputs.armed == 'true'
permissions:
contents: write
uses: ./.github/workflows/_build-eql-docs.yml
with:
ref: ${{ github.sha }}
tag: eql-${{ needs.classify.outputs.version }}
prerelease-eql-npm:
name: Publish the prerelease npm package
needs: [classify, gate, eql-armed, prerelease-eql-sql, prerelease-eql-docs]
if: >-
needs.classify.outputs.mode == 'prerelease' &&
needs.eql-armed.outputs.armed == 'true'
# GitHub-hosted for the same reason as `release` and `publish-ffi`: npm
# rejects provenance from a self-hosted runner with E422.
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write # npm OIDC trusted publishing
contents: write # creates the eql-typescript-v<version> tag
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
name: Install pnpm
with:
run_install: false
# Never cache the pnpm store in a credential-bearing workflow.
cache: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
package-manager-cache: false
# `add_shims_to_path: false` and `env: false` are load-bearing — see the
# `release` job above for both.
- name: Install mise (release:prepare_bindings_assets runs through 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
# AFTER mise-action, deliberately: see the `release` job.
- name: Upgrade npm for OIDC trusted publishing
run: npm install -g npm@^11.5.1
# node-pty's install hook falls back to `node-gyp rebuild` on Linux and
# pnpm/action-setup v6 does not ship node-gyp. Upstream had no node-pty,
# so no such step. Asserted by workflow-node-gyp.test.mjs.
- name: Install node-gyp
run: npm install -g node-gyp
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify the prerelease marker and version
env:
VERSION: ${{ needs.classify.outputs.version }}
run: |
set -euo pipefail
actual="$(node -p "require('./packages/eql/packages/eql/package.json').version")"
test "$actual" = "$VERSION" || {
echo "::error::package version ${actual} does not match the prerelease identity ${VERSION}" >&2
exit 1
}
- name: Prepare the exact SQL assets
# The task passes `--force` itself: `mise run build --version` does not
# treat `--version` as a cache-key input. See AGENTS.md.
working-directory: packages/eql
env:
VERSION: ${{ needs.classify.outputs.version }}
run: mise run release:prepare_bindings_assets --version "$VERSION"
# Through turbo, not `pnpm --filter … build` as upstream ran:
# `@cipherstash/eql#build` declares `dependsOn: ["^build"]`. Asserted by
# workflow-turbo-build-deps.test.mjs.
- name: Build the package
run: pnpm exec turbo run build --filter @cipherstash/eql
- name: Publish the package
# Two levels down — the npm package, not the subtree root.
working-directory: packages/eql/packages/eql
env:
VERSION: ${{ needs.classify.outputs.version }}
run: |
set -euo pipefail
if [ -n "$(npm view "@cipherstash/eql@${VERSION}" version 2>/dev/null)" ]; then
echo "@cipherstash/eql@${VERSION} is already published; skipping publish"
else
node scripts/npm-publish.mjs
fi
- name: Tag the TypeScript release
env:
VERSION: ${{ needs.classify.outputs.version }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Through the REST API, not git: the checkout has
# persist-credentials: false. `classify` reads this same tag.
tag="eql-typescript-v${VERSION}"
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
echo "tag ${tag} already exists at ${at} — skipping"
else
gh api -X POST "repos/${REPO}/git/refs" \
-f ref="refs/tags/${tag}" -f sha="$GITHUB_SHA" >/dev/null
echo "created ${tag} at ${GITHUB_SHA}"
fi
prerelease-eql-crate:
name: Dispatch the prerelease crate publish
needs: [classify, gate, eql-armed, prerelease-eql-sql, prerelease-eql-docs]
if: >-
needs.classify.outputs.mode == 'prerelease' &&
needs.eql-armed.outputs.armed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write # gh workflow run
contents: write # create/update the release/eql-<version> branch ref
steps:
- name: Dispatch release-plz.yml at the release commit
# release-plz refuses a detached HEAD, so a tag dispatch does not work.
# Pin a release/eql-<version> branch at this run's commit instead — a
# stable pointer per identity, force-updated on a retry of the same one.
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.classify.outputs.version }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
branch="release/eql-${VERSION}"
if ! gh api -X POST "repos/${REPO}/git/refs" \
-f ref="refs/heads/${branch}" -f sha="$SHA" >/dev/null 2>&1; then
gh api -X PATCH "repos/${REPO}/git/refs/heads/${branch}" \
-f sha="$SHA" -F force=true >/dev/null
fi
# `--repo` is required: no checkout.
gh workflow run release-plz.yml --repo "$REPO" --ref "$branch"
# What this run actually did. Worth a job because both EQL paths can
# legitimately do nothing — an already-tagged identity classifies as `skip`,
# and the run page renders that identically to "a dependency failed".
eql-summary:
name: EQL release summary
needs:
- classify
- eql-armed
# `gate` and `release` are not EQL jobs, and they are the two that most
# often decide an EQL run does nothing. `gate` exits non-zero for a frozen
# publisher — which is the NORMAL inert state — and skips `release` and
# `eql-assets`, whose `needed` output gates the production EQL chain.
# Without them here every job below reads `skipped` and the table cannot
# separate "correctly inert" from "the gate refused this release" from
# "changesets failed", which is the distinction this job exists to draw.
- gate
- release
- eql-assets
- eql-sql
- eql-docs
- eql-image
- prerelease-eql-sql
- prerelease-eql-docs
- prerelease-eql-npm
- prerelease-eql-crate
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Emit the run summary
env:
MODE: ${{ needs.classify.outputs.mode }}
VERSION: ${{ needs.classify.outputs.version }}
ARMED: ${{ needs.eql-armed.outputs.armed }}
GATE: ${{ needs.gate.result }}
RELEASE: ${{ needs.release.result }}
EQL_ASSETS: ${{ needs.eql-assets.result }}
EQL_NEEDED: ${{ needs.eql-assets.outputs.needed }}
EQL_SQL: ${{ needs.eql-sql.result }}
EQL_DOCS: ${{ needs.eql-docs.result }}
EQL_IMAGE: ${{ needs.eql-image.result }}
PRE_SQL: ${{ needs.prerelease-eql-sql.result }}
PRE_DOCS: ${{ needs.prerelease-eql-docs.result }}
PRE_NPM: ${{ needs.prerelease-eql-npm.result }}
PRE_CRATE: ${{ needs.prerelease-eql-crate.result }}
run: |
set -euo pipefail
{
echo "## EQL release"
echo ""
echo "- mode: \`${MODE}\`"
echo "- pipeline armed: \`${ARMED}\`"
echo "- classified version: \`${VERSION:-n/a}\`"
echo ""
echo "| job | result |"
echo "| --- | --- |"
echo "| gate | ${GATE} |"
echo "| release (changesets) | ${RELEASE} |"
echo "| eql-assets (needed: \`${EQL_NEEDED:-n/a}\`) | ${EQL_ASSETS} |"
echo "| eql-sql | ${EQL_SQL} |"
echo "| eql-docs | ${EQL_DOCS} |"
echo "| eql-image | ${EQL_IMAGE} |"
echo "| prerelease-eql-sql | ${PRE_SQL} |"
echo "| prerelease-eql-docs | ${PRE_DOCS} |"
echo "| prerelease-eql-npm | ${PRE_NPM} |"
echo "| prerelease-eql-crate | ${PRE_CRATE} |"
if [[ "${ARMED}" != "true" ]]; then
echo ""
echo "The EQL pipeline is INERT: \`@cipherstash/eql\` is a frozen publisher in \`scripts/release-gate.mjs\`, so every job above is expected to be \`skipped\`. Deleting that entry is what arms it — see the Phase 5 cutover in \`docs/plans/2026-08-13-eql-monorepo-absorption.md\`."
elif [[ "${GATE}" != "success" ]]; then
echo ""
echo "The pipeline is armed but \`gate\` did not succeed, so nothing downstream of it ran. Run \`node scripts/release-gate.mjs\` for what it is refusing."
fi
} >> "$GITHUB_STEP_SUMMARY"