Merge pull request #1022 from cipherstash/fix/go-context-owns-byte-parts #512
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |