From d9af16df136041dce27998bf89fc6e46fd8d1549 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 09:49:19 +1000 Subject: [PATCH 1/3] feat(release-gate): classify the @cipherstash/auth line classify() gains an `auth` output keyed on the `@cipherstash/auth` prefix, and `js` no longer counts the seven auth packages. Without the branch an unpublished auth version reads as `js`, and `changeset publish` would pack the platform workspaces with no binary in them. The gate prints and writes `auth=` beside `ffi=` and `js=`. A process test pins the three flags in GITHUB_OUTPUT, because a flag the gate never writes reads as "not in scope" in the workflow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- scripts/__tests__/release-gate.test.mjs | 59 +++++++++++++++++++++++-- scripts/release-gate.mjs | 28 +++++++++--- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index 1c98e45be..f3a5741d2 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process' import { chmodSync, + existsSync, mkdtempSync, readFileSync, rmSync, @@ -39,6 +40,7 @@ import { readWorkflow } from './lib/workflows.mjs' const FFI = '@cipherstash/protect-ffi' const PLATFORM = '@cipherstash/protect-ffi-darwin-arm64' +const AUTH_PLATFORM = '@cipherstash/auth-linux-x64-musl' const AUTH = '@cipherstash/auth' describe('unpublished', () => { @@ -167,18 +169,23 @@ describe('workspacePackagePatterns', () => { describe('classify', () => { it('flags ffi when the wrapper is unpublished', () => { - expect(classify([FFI])).toEqual({ ffi: true, js: false }) + expect(classify([FFI])).toEqual({ ffi: true, auth: false, js: false }) }) it('flags ffi when only a platform package is unpublished', () => { // The fixed group moves all seven together, but a partially-failed publish // can leave one behind — that still needs the matrix. - expect(classify([PLATFORM])).toEqual({ ffi: true, js: false }) + expect(classify([PLATFORM])).toEqual({ + ffi: true, + auth: false, + js: false, + }) }) it('flags js for an ordinary Stack release', () => { expect(classify(['@cipherstash/stack', 'stash'])).toEqual({ ffi: false, + auth: false, js: true, }) }) @@ -186,13 +193,44 @@ describe('classify', () => { it('flags both when a release spans them', () => { expect(classify([FFI, '@cipherstash/stack'])).toEqual({ ffi: true, + auth: false, js: true, }) }) it('flags neither when nothing is unpublished', () => { // The common case: any push to main that is not a merged Version PR. - expect(classify([])).toEqual({ ffi: false, js: false }) + expect(classify([])).toEqual({ ffi: false, auth: false, js: false }) + }) + + it('flags auth, and not js, when the auth wrapper is unpublished', () => { + // The defect the branch exists for: read as `js`, the auth platform + // packages would be packed from the workspace with no binary in them. + expect(classify([AUTH])).toEqual({ ffi: false, auth: true, js: false }) + }) + + it('flags auth when only an auth platform package is unpublished', () => { + expect(classify([AUTH_PLATFORM])).toEqual({ + ffi: false, + auth: true, + js: false, + }) + }) + + it('does not read the private stack-auth-wasm package as auth', () => { + expect(classify(['@cipherstash/stack-auth-wasm'])).toEqual({ + ffi: false, + auth: false, + js: true, + }) + }) + + it('flags all three when a release spans every line', () => { + expect(classify([FFI, AUTH, '@cipherstash/stack'])).toEqual({ + ffi: true, + auth: true, + js: true, + }) }) }) @@ -886,10 +924,25 @@ describe('the gate exits non-zero when a blocker is found', () => { GITHUB_OUTPUT: join(dir, 'github-output.txt'), }, }) + const outputFile = join(dir, 'github-output.txt') + result.githubOutput = existsSync(outputFile) + ? readFileSync(outputFile, 'utf8') + : '' rmSync(dir, { recursive: true, force: true }) return result } + it('writes all three publisher flags to the job outputs', () => { + // `release.yml` keys `auth-artifacts`, `publish-auth` and the `release` + // job's wait on `gate.outputs.auth`. A flag the gate never writes reads as + // '' in the workflow, which is "auth not in scope": the auth jobs skip and + // `changeset publish` packs the auth platform workspaces with no binary. + const result = runGate(allPublished) + expect(result.status).toBe(0) + expect(result.stdout).toContain('ffi=false auth=false js=false') + expect(result.githubOutput).toBe('ffi=false\nauth=false\njs=false\n') + }) + it('fails the job, and says how to clear it', () => { // THE REAL TREE, EQL frozen by fixture, against a registry held behind // it: @cipherstash/eql at 3.0.4, everything else at its committed version. diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index b00e89d2b..edae759f0 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -56,6 +56,18 @@ const REPO_ROOT = resolve(import.meta.dirname, '..') */ export const FFI_PREFIX = '@cipherstash/protect-ffi' +/** + * The second native-binary line: the `@cipherstash/auth` napi wrapper and its + * six `@cipherstash/auth-*` platform packages, imported from cipherstash-suite + * with the stack-* crates. Same reasoning as `FFI_PREFIX`: without its own + * branch an unpublished auth version reads as `js`, and `changeset publish` + * then packs the platform workspaces, which have no `.node` binary in them. + * + * A prefix, not a name: no other workspace package starts with it — + * `@cipherstash/stack-auth-wasm` does not. + */ +export const AUTH_PREFIX = '@cipherstash/auth' + /** * Names whose committed version is absent from the registry. * @@ -77,9 +89,12 @@ export function unpublished(manifests, lookup) { /** Which publisher branches the unpublished set requires. */ export function classify(names) { + const native = (name) => + name.startsWith(FFI_PREFIX) || name.startsWith(AUTH_PREFIX) return { ffi: names.some((name) => name.startsWith(FFI_PREFIX)), - js: names.some((name) => !name.startsWith(FFI_PREFIX)), + auth: names.some((name) => name.startsWith(AUTH_PREFIX)), + js: names.some((name) => !native(name)), } } @@ -1011,20 +1026,23 @@ export function main({ } const missing = unpublished(manifests, lookup) - const { ffi, js } = classify(missing) + const { ffi, auth, js } = classify(missing) console.log( missing.length ? `unpublished: ${missing.join(', ')}` : 'nothing to publish — every committed version is on the registry', ) - console.log(`ffi=${ffi} js=${js}`) + console.log(`ffi=${ffi} auth=${auth} js=${js}`) - // `ffi` and `js` only: the unpublished list was written here too and no job + // The three flags only: the unpublished list was written here too and no job // ever declared it as an output, so it was reachable by nothing. The // `console.log` above is where that list is actually read, in the job log. if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `ffi=${ffi}\njs=${js}\n`) + appendFileSync( + process.env.GITHUB_OUTPUT, + `ffi=${ffi}\nauth=${auth}\njs=${js}\n`, + ) } // AFTER the outputs are written, and before anything acts on them. The From 4c6a49c003bacbbc3a9558d610758211733ca657 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 09:51:53 +1000 Subject: [PATCH 2/3] ci(release-plz): add the stack-auth and stack-profile line, inert release-plz.yml gains a `crates-armed` switch and a `release-crates` job that runs `release-plz release` on the root workspace, with `manifest_path: Cargo.toml` and `config: release-plz.toml`. It is its own job because a step in `release` would inherit the EQL gate. A crates.io preflight decides whether the GPG key is imported, as the EQL job does. The push filter adds the two crates and the root release-plz inputs. scripts/eql-pipeline-armed.mjs learns a `crates` line, keyed on the `@cipherstash/auth` entry of FROZEN_PUBLISHERS: no crate is in that npm map, and PR E deletes the auth entries when it moves both registries. While the entry is there the switch prints armed=false and the job is skipped. The root release-plz.toml puts stack-auth and stack-profile in one version group and sets `release = false` on the seven other members. cliff.toml is cipherstash-suite main's, unchanged. A new test holds the config to the workspace members and to the job's inputs. Registered in OIDC_JOBS and PERMISSIVE_NEEDS. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/release-plz.yml | 107 ++++++++++++++++- cliff.toml | 54 +++++++++ release-plz.toml | 59 ++++++++++ scripts/__tests__/eql-pipeline-armed.test.mjs | 72 +++++++++++- .../release-plz-root-config.test.mjs | 110 ++++++++++++++++++ .../workflow-dispatch-job-conditions.test.mjs | 1 + .../workflow-paths-filter-parity.test.mjs | 4 +- .../workflow-publish-permissions.test.mjs | 3 + scripts/eql-pipeline-armed.mjs | 58 ++++++++- 9 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 cliff.toml create mode 100644 release-plz.toml create mode 100644 scripts/__tests__/release-plz-root-config.test.mjs diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 93b5f977f..134d43f9d 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -1,7 +1,10 @@ -name: "Release eql-bindings (crates.io)" +name: "Release crates (crates.io)" -# Publishes the `eql-bindings` crate via release-plz (crates.io Trusted -# Publishing over OIDC — no CARGO_REGISTRY_TOKEN). +# Publishes two crates.io release lines via release-plz (crates.io Trusted +# Publishing over OIDC — no CARGO_REGISTRY_TOKEN): `eql-bindings` from +# packages/eql (`release`), and `stack-auth` + `stack-profile` from the root +# workspace (`release-crates`, described above its own jobs at the end). +# Everything from here to `permissions:` is about the EQL line. # # THE FILENAME CANNOT CHANGE: crates.io binds the publisher to it. # @@ -38,9 +41,17 @@ on: # packages/eql/crates/eql-bindings/Cargo.toml and the SQL assets beside it. # Kept in step with the other EQL filters by # scripts/__tests__/eql-workflow-filters.test.mjs. + # The stack-* crates line adds the two published crates and the root + # workspace and release-plz inputs. paths: - "packages/eql/**" - ".github/workflows/release-plz.yml" + - "packages/stack-auth/**" + - "packages/stack-profile/**" + - "Cargo.toml" + - "Cargo.lock" + - "release-plz.toml" + - "cliff.toml" workflow_dispatch: {} # Never cancel — a cancelled release can leave a half-published state. @@ -160,3 +171,93 @@ jobs: config: packages/eql/release-plz.toml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ---- The stack-* crates line (stack-auth, stack-profile) ----------------- + # + # Its own job, not a step in `release`: a step there would inherit the EQL + # gate, and the two lines arm at different times. + # + # INERT until the arming PR of the stack-* crates import (PR E). No crate is + # in FROZEN_PUBLISHERS (an npm map), so the switch keys on the + # `@cipherstash/auth` entry, which PR E deletes when it repoints crates.io and + # npm trusted publishing together. crates.io also needs a Trusted Publishing + # entry for both crates naming cipherstash/stack + release-plz.yml. + # + # Publish-only: nothing here opens a release PR, so a version bump of the two + # crates is a hand-made pull request. `release-plz update` cannot version + # this workspace yet: it walks back to the commit that matches crates.io + # 0.42.3, and the imported history has no root Cargo.toml there. A + # `release-pr` job comes after the first release from this repository. + crates-armed: + name: Is the stack-* crates 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 + + - name: Read the publisher switch + id: armed + run: node scripts/eql-pipeline-armed.mjs crates + + release-crates: + name: "Release stack-auth and stack-profile" + needs: [crates-armed] + if: needs.crates-armed.outputs.armed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # the stack-auth-v / stack-profile-v tags and releases + pull-requests: write # release-plz links PRs in release notes + id-token: write # crates.io Trusted Publishing + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Is either crate version unpublished? + # The EQL preflight's crates.io question, per crate of the version + # group. It decides whether the GPG import runs; an API failure reads + # as "unpublished", which fails towards importing the key. + id: preflight + run: | + set -euo pipefail + publish_needed=false + for crate in stack-auth stack-profile; do + version="$(grep -m1 '^version = ' "packages/${crate}/Cargo.toml" | cut -d'"' -f2)" + # crates.io 403s curl's default User-Agent. + published="$(curl -fsSL --retry 3 -H "User-Agent: cipherstash-stack-release (https://github.com/cipherstash/stack)" "https://crates.io/api/v1/crates/${crate}/versions" | jq -r '.versions[].num' | grep -Fx "$version" || true)" + if [[ -n "$published" ]]; then + echo "${crate}@${version} is already on crates.io" + else + echo "${crate}@${version} is not on crates.io; it will be published" + publish_needed=true + fi + done + echo "publish_needed=${publish_needed}" >> "$GITHUB_OUTPUT" + + - name: Import GPG key + if: steps.preflight.outputs.publish_needed == 'true' + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + git_user_signingkey: true + git_commit_gpgsign: true + git_tag_gpgsign: true + + - name: Run release-plz release (root workspace) + uses: release-plz/action@8e61445f0b34e7c6d985255f2f1f05a36eb350dc # v0.5 + with: + command: release + manifest_path: Cargo.toml + config: release-plz.toml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 000000000..b1ce27b36 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,54 @@ +[changelog] +header = "" +body = """ +{% if version %}## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}## [Unreleased] +{% endif %} +{#- A `!` or `BREAKING CHANGE:` commit is otherwise indistinguishable from any + other feat/refactor in the grouped sections below, so the one release that + needs an upgrade note ships without one. Emit those commits again, first. + + Each bullet is the footer's *first paragraph only*, collapsed onto one + line: `split("\n\n") | first` drops trailing issue trailers (e.g. + `CIP-1234`) and any elaboration after the opening paragraph, then + `replace` folds the remaining hard wraps into spaces. So write the upgrade + instruction as the footer's opening paragraph — anything below it will not + reach the changelog. + + For a `!` commit with no `BREAKING CHANGE:` footer, git-cliff sets + `breaking_description` to the commit subject, so the bullet repeats the + subject line. Breaking commits also still appear in their own group. -#} +{%- set breaking = commits | filter(attribute="breaking", value=true) -%} +{% if breaking | length > 0 %} +### ⚠ Breaking changes +{% for commit in breaking %} +- {{ commit.breaking_description | split(pat="\n\n") | first | trim | replace(from="\n", to=" ") }}\ +{% endfor %} +{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | upper_first }} +{% for commit in commits %} +- {{ commit.message | split(pat="\n") | first | trim }}\ +{% endfor %} +{% endfor %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = false +# `review(...)` commits are PR-feedback housekeeping (addressing reviewer +# comments). They don't represent user-facing changes — the underlying +# feat/fix/refactor commits already capture those — so skip them rather than +# letting the raw `review` type become a stray "### Review" changelog section. +commit_parsers = [ + { message = "^review", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^test", group = "Testing" }, + { message = "^chore", group = "Miscellaneous" }, + { message = "^ci", group = "CI" }, +] diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 000000000..37f233226 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,59 @@ +# release-plz configuration for the ROOT Cargo workspace: the stack-* crates. +# +# `.github/workflows/release-plz.yml`'s `release-crates` job passes +# `manifest_path: Cargo.toml` and `config: release-plz.toml`. The EQL workspace +# has its own file at packages/eql/release-plz.toml. +# +# Two crates publish from here, `stack-auth` and `stack-profile`, in one +# version group of their own. In cipherstash-suite they shared a group with +# `cipherstash-client`, which stays there. Every other workspace member is +# `publish = false` in its Cargo.toml and is also listed below with +# `release = false`, so `release-plz update` does not bump its version or +# write its changelog either. +# +# Tags are release-plz's default, `stack-auth-v` and +# `stack-profile-v`. No workflow triggers on either. + +[workspace] +changelog_config = "cliff.toml" +git_tag_enable = true +git_release_enable = true +publish = true +# Matches cipherstash-suite and packages/eql/release-plz.toml. +semver_check = false + +[[package]] +name = "stack-auth" +version_group = "stack-auth" + +[[package]] +name = "stack-profile" +version_group = "stack-auth" + +[[package]] +name = "stack-kms" +release = false + +[[package]] +name = "stack-encrypt" +release = false + +[[package]] +name = "stack-encrypt-derive" +release = false + +[[package]] +name = "stack-guest-abi" +release = false + +[[package]] +name = "stack-auth-node" +release = false + +[[package]] +name = "stack-profile-node" +release = false + +[[package]] +name = "stack-auth-wasm" +release = false diff --git a/scripts/__tests__/eql-pipeline-armed.test.mjs b/scripts/__tests__/eql-pipeline-armed.test.mjs index 53f43c515..101c9125e 100644 --- a/scripts/__tests__/eql-pipeline-armed.test.mjs +++ b/scripts/__tests__/eql-pipeline-armed.test.mjs @@ -7,6 +7,9 @@ import { EQL_PACKAGE, eqlPipelineArmed, frozenReason, + LINES, + lineFrozenReason, + pipelineArmed, } from '../eql-pipeline-armed.mjs' import { FROZEN_PUBLISHERS } from '../release-gate.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' @@ -58,8 +61,8 @@ function jobConditions(relPath) { ]) } -function run(env = {}) { - return execFileSync('node', [SCRIPT], { +function run(env = {}, args = []) { + return execFileSync('node', [SCRIPT, ...args], { cwd: REPO_ROOT, encoding: 'utf8', env: { ...process.env, ...env }, @@ -152,3 +155,68 @@ describe('every EQL publish job reads it', () => { ).toEqual(GATED_JOBS) }) }) + +/** + * The stack-* crates line in `release-plz.yml`. It keys on the + * `@cipherstash/auth` entry, because no crate is in the npm map and the arming + * PR of the stack-* crates import repoints both registries together. + */ +const CRATES_PACKAGE = '@cipherstash/auth' +const CRATES_WORKFLOW = '.github/workflows/release-plz.yml' +const CRATES_GATED_JOBS = [`${CRATES_WORKFLOW} / release-crates`] + +describe('the stack-* crates line', () => { + it('keys on the @cipherstash/auth entry', () => { + expect(LINES.get('crates')?.pkg).toBe(CRATES_PACKAGE) + }) + + it('is inert while @cipherstash/auth is frozen, and armed once it is not', () => { + expect(pipelineArmed('crates', new Map([[CRATES_PACKAGE, 'x']]))).toBe( + false, + ) + expect(pipelineArmed('crates', new Map())).toBe(true) + // The lines are independent: the EQL entry does not hold the crates back. + expect(pipelineArmed('crates', new Map([[EQL_PACKAGE, 'x']]))).toBe(true) + expect(lineFrozenReason('crates', new Map([[CRATES_PACKAGE, 'why']]))).toBe( + 'why', + ) + }) + + it('matches the live map, whichever state that is in', () => { + expect(pipelineArmed('crates')).toBe(!FROZEN_PUBLISHERS.has(CRATES_PACKAGE)) + }) + + it('refuses a line it does not know', () => { + expect(() => pipelineArmed('bogus')).toThrow(/unknown release line/) + }) + + it('writes `armed=` for the named line', () => { + const dir = mkdtempSync(join(tmpdir(), 'crates-armed-')) + const outputFile = join(dir, 'output') + try { + execFileSync('sh', ['-c', `: > "${outputFile}"`]) + const stdout = run({ GITHUB_OUTPUT: outputFile }, ['crates']) + expect(stdout).toContain(CRATES_PACKAGE) + expect(readFileSync(outputFile, 'utf8')).toBe( + `armed=${pipelineArmed('crates')}\n`, + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('is computed by release-plz.yml and gates exactly the crates publish job', () => { + const job = readWorkflow(CRATES_WORKFLOW)?.jobs?.['crates-armed'] + const runsIt = (job?.steps ?? []).some((step) => + String(step?.run ?? '').includes('scripts/eql-pipeline-armed.mjs crates'), + ) + expect(runsIt && Boolean(job?.outputs?.armed)).toBe(true) + + const gated = jobConditions(CRATES_WORKFLOW) + .filter(([, condition]) => + condition.includes("needs.crates-armed.outputs.armed == 'true'"), + ) + .map(([id]) => id) + expect(gated).toEqual(CRATES_GATED_JOBS) + }) +}) diff --git a/scripts/__tests__/release-plz-root-config.test.mjs b/scripts/__tests__/release-plz-root-config.test.mjs new file mode 100644 index 000000000..949c48889 --- /dev/null +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -0,0 +1,110 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * The root `release-plz.toml` must name every member of the root Cargo + * workspace, and say which of them release. + * + * `publish = false` in a crate's manifest stops `release-plz release` from + * uploading it, but not `release-plz update` from bumping its version and + * writing its changelog. So the two published crates, `stack-auth` and + * `stack-profile`, share one `version_group`, and every other member carries + * `release = false`. A member added to the workspace without a line here would + * be versioned on the next release-plz run. + * + * `cargo-publish-opt-out.test.mjs` pins which members may publish; this pins + * that the release-plz configuration agrees with it. + */ + +const WORKFLOW = '.github/workflows/release-plz.yml' +const CONFIG = 'release-plz.toml' +const PUBLISHED = ['stack-auth', 'stack-profile'] + +/** The root workspace's members, as `{ path, name, publish }`. */ +function rootMembers() { + const manifest = readFileSync(join(REPO_ROOT, 'Cargo.toml'), 'utf8') + const block = /^members\s*=\s*\[([^\]]*)\]/m.exec(manifest)?.[1] ?? '' + return [...block.matchAll(/"([^"]+)"/g)].map(([, path]) => { + const crate = readFileSync(join(REPO_ROOT, path, 'Cargo.toml'), 'utf8') + return { + path, + name: /^name\s*=\s*"([^"]+)"/m.exec(crate)?.[1], + publish: !/^publish\s*=\s*false$/m.test(crate), + } + }) +} + +/** `[[package]]` tables of release-plz.toml, keyed by name. */ +function configuredPackages(source) { + const packages = new Map() + for (const table of source.split(/^\[\[package\]\]$/m).slice(1)) { + const body = table.split(/^\[/m)[0] + const name = /^name\s*=\s*"([^"]+)"/m.exec(body)?.[1] + packages.set(name, { + versionGroup: /^version_group\s*=\s*"([^"]+)"/m.exec(body)?.[1] ?? null, + release: !/^release\s*=\s*false$/m.test(body), + }) + } + return packages +} + +const source = readFileSync(join(REPO_ROOT, CONFIG), 'utf8') +const members = rootMembers() +const packages = configuredPackages(source) + +describe('root release-plz.toml', () => { + it('finds the workspace members it means to check', () => { + // A members list this cannot read must fail, not pass on nothing. + expect(members.map((member) => member.name)).toEqual( + expect.arrayContaining(PUBLISHED), + ) + expect(members.every((member) => member.name)).toBe(true) + }) + + it('names every root workspace member, and nothing else', () => { + expect([...packages.keys()].sort()).toEqual( + members.map((member) => member.name).sort(), + ) + }) + + it('releases exactly the crates that may publish, in one version group', () => { + const publishable = members + .filter((member) => member.publish) + .map((member) => member.name) + .sort() + expect(publishable).toEqual(PUBLISHED) + + const released = [...packages] + .filter(([, config]) => config.release) + .map(([name]) => name) + .sort() + expect(released).toEqual(PUBLISHED) + + const groups = new Set( + PUBLISHED.map((name) => packages.get(name)?.versionGroup), + ) + expect([...groups]).toHaveLength(1) + expect([...groups][0]).toBeTruthy() + }) + + it('points the changelog at a file that exists', () => { + const changelog = /^changelog_config\s*=\s*"([^"]+)"/m.exec(source)?.[1] + expect(changelog).toBeTruthy() + expect(existsSync(join(REPO_ROOT, changelog))).toBe(true) + }) + + it('is the configuration the release-crates job runs with', () => { + const steps = readWorkflow(WORKFLOW)?.jobs?.['release-crates']?.steps ?? [] + const release = steps.find((step) => + String(step?.uses ?? '').startsWith('release-plz/action@'), + ) + expect(release?.with).toMatchObject({ + command: 'release', + manifest_path: 'Cargo.toml', + config: CONFIG, + }) + }) +}) diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index 065b67a80..889a37369 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -411,6 +411,7 @@ const PERMISSIVE_NEEDS = { // be indistinguishable from "skips on a dispatch". classify: { outputs: { mode: 'production', version: '3.0.6' } }, 'eql-armed': { outputs: { armed: 'true' } }, + 'crates-armed': { outputs: { armed: 'true' } }, gate: { result: 'success', outputs: { ffi: 'true' } }, 'publish-ffi': { result: 'success' }, release: { diff --git a/scripts/__tests__/workflow-paths-filter-parity.test.mjs b/scripts/__tests__/workflow-paths-filter-parity.test.mjs index da0fe49f9..9f1f54191 100644 --- a/scripts/__tests__/workflow-paths-filter-parity.test.mjs +++ b/scripts/__tests__/workflow-paths-filter-parity.test.mjs @@ -108,7 +108,9 @@ const EXPECTED_ASYMMETRIES = new Map([ // `changeset version` runs scripts/sync-lockstep-versions.mjs, which // rewrites packages/eql/crates/eql-bindings/Cargo.toml and the SQL assets // beside it, so every version-moving commit touches `packages/eql/**`. - // The list itself is kept honest by eql-workflow-filters.test.mjs. + // The list itself is kept honest by eql-workflow-filters.test.mjs. The + // stack-* crates line holds the same way: a bump of either crate edits its + // own `packages/stack-*/Cargo.toml`, which the filter names. 'push is the only trigger; a pull_request copy would make a crates.io publisher reachable from a fork', ], [ diff --git a/scripts/__tests__/workflow-publish-permissions.test.mjs b/scripts/__tests__/workflow-publish-permissions.test.mjs index d09353c81..bf4eb783a 100644 --- a/scripts/__tests__/workflow-publish-permissions.test.mjs +++ b/scripts/__tests__/workflow-publish-permissions.test.mjs @@ -54,6 +54,9 @@ const PUBLISH_OIDC_JOBS = [ // could not stay a two-line one: the workflow filename is bound at crates.io // rather than npm, but the scope is the same scope. '.github/workflows/release-plz.yml / release', + // release-plz publishes stack-auth and stack-profile from the root Cargo + // workspace: the same crates.io token exchange, a second release line. + '.github/workflows/release-plz.yml / release-crates', ] /** diff --git a/scripts/eql-pipeline-armed.mjs b/scripts/eql-pipeline-armed.mjs index a2649f695..e917afdd6 100644 --- a/scripts/eql-pipeline-armed.mjs +++ b/scripts/eql-pipeline-armed.mjs @@ -17,6 +17,17 @@ * `release-plz.yml` publishes a CRATE on its own trigger. Keying that on the npm * answer would also race `release.yml` on the very push that releases a version. * This asks a question with no registry and no clock in it. + * + * ## A second line: the stack-* crates + * + * `release-plz.yml` also publishes `stack-auth` and `stack-profile` from the + * root Cargo workspace. They are imported from cipherstash-suite with + * `@cipherstash/auth`, and all three lines move here together in the arming PR + * of that import (PR E), which repoints crates.io and npm trusted publishing in + * one step. No crate is in `FROZEN_PUBLISHERS` — it is an npm map — so the + * crates line keys on `@cipherstash/auth`, whose entries that PR deletes. + * `node scripts/eql-pipeline-armed.mjs crates` answers for it. The file keeps + * its EQL name because the EQL workflows and tests name it. */ import { appendFileSync } from 'node:fs' import process from 'node:process' @@ -26,6 +37,41 @@ import { FROZEN_PUBLISHERS } from './release-gate.mjs' /** The package whose publisher decides whether the whole line is armed. */ export const EQL_PACKAGE = '@cipherstash/eql' +/** + * Each release line, and the frozen package whose entry arms it. The first + * command-line argument names the line; with none, it is `eql`. + */ +export const LINES = new Map([ + ['eql', { pkg: EQL_PACKAGE, pipeline: 'the EQL release pipeline' }], + [ + 'crates', + { + pkg: '@cipherstash/auth', + pipeline: 'the stack-auth / stack-profile crates.io pipeline', + }, + ], +]) + +function lineOf(name) { + const line = LINES.get(name) + if (!line) { + throw new Error( + `unknown release line \`${name}\`; expected one of ${[...LINES.keys()].join(', ')}`, + ) + } + return line +} + +/** `true` when this repository may publish the named release line. */ +export function pipelineArmed(name, frozen = FROZEN_PUBLISHERS) { + return !frozen.has(lineOf(name).pkg) +} + +/** Why the named line is not armed, or `null`. */ +export function lineFrozenReason(name, frozen = FROZEN_PUBLISHERS) { + return frozen.get(lineOf(name).pkg) ?? null +} + /** * `true` when this repository may publish the EQL release line. * @@ -33,21 +79,23 @@ export const EQL_PACKAGE = '@cipherstash/eql' * included, rather than exercising it for the first time at the cutover. */ export function eqlPipelineArmed(frozen = FROZEN_PUBLISHERS) { - return !frozen.has(EQL_PACKAGE) + return pipelineArmed('eql', frozen) } /** Why it is not armed, or `null`. Taken from the map, so it cannot drift. */ export function frozenReason(frozen = FROZEN_PUBLISHERS) { - return frozen.get(EQL_PACKAGE) ?? null + return lineFrozenReason('eql', frozen) } function main() { - const armed = eqlPipelineArmed() + const name = process.argv[2] ?? 'eql' + const { pkg, pipeline } = lineOf(name) + const armed = pipelineArmed(name) console.log( armed - ? `${EQL_PACKAGE} is published from this repository — the EQL release pipeline is ARMED.` - : `${EQL_PACKAGE} is a frozen publisher — the EQL release pipeline is INERT.\n ${frozenReason()}`, + ? `${pkg} is published from this repository — ${pipeline} is ARMED.` + : `${pkg} is a frozen publisher — ${pipeline} is INERT.\n ${lineFrozenReason(name)}`, ) if (process.env.GITHUB_OUTPUT) { From 2a38fb4e0de22309a30b47ac679a3373d54f6bc4 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 09:57:33 +1000 Subject: [PATCH 3/3] ci(release): build and publish @cipherstash/auth, inert - _build-auth-artifacts.yml: a reusable six-target napi matrix and a wasm job. Each target runs `napi build --platform --release --target ... --strip --dts native.d.ts --js false` (without `--js false` napi writes its own loader over the frozen index.js), then `git diff --exit-code` on the package, and `npm pack`s its platform package. linux-arm64-gnu runs on GitHub's ubuntu-24.04-arm. The wrapper is packed with pnpm, which rewrites its `workspace:*` platform peers, and the job checks the six peers equal the wrapper's version. Rust is the root mise 1.94.1; nothing restores a cache. - auth-preflight.yml: a workflow_dispatch dry run that builds the seven tarballs, checks each non-host binary with `file` and `readelf`, and smoke-installs the linux-x64-gnu pair. No id-token, no secret, no registry-url. - release.yml: `auth-artifacts` and `publish-auth` between `gate` and `release`, on the gate's new `auth` output. They publish platforms first and the wrapper last with --provenance, then tag and make a GitHub release; `release` waits for publish-auth to succeed whenever auth was in scope. - .changeset/config.json: a fixed group of the seven auth packages. - The seven auth manifests get `repository` with `directory`, which npm requires for a provenance publish. The gate hashes the 15 listed files, not package.json, so the freeze still passes. Inert: the auth packages are in FROZEN_PUBLISHERS, so any unpublished auth version fails the gate before `auth-artifacts` can run. Guards: auth-repository-urls and auth-build-artifacts tests; a derived lint-release-scope check that every reusable workflow release.yml calls, and every other caller of one, is linted. Registered in lint-no-workflow-caching TARGETS, tests-supply-chain paths, OIDC_JOBS, PERMISSIVE_NEEDS and EXPECTED_DISPATCHABLE. The frozen reason and the lint-no-auth-changeset message no longer say release.yml cannot build the binaries. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .changeset/config.json | 9 + .github/workflows/_build-auth-artifacts.yml | 289 ++++++++++++++++++ .github/workflows/auth-preflight.yml | 132 ++++++++ .github/workflows/lint-release.yml | 4 + .github/workflows/release.yml | 145 ++++++++- .github/workflows/tests-supply-chain.yml | 16 +- AGENTS.md | 6 +- CONTRIBUTING.md | 4 +- .../typescript/packages/auth/package.json | 5 + .../auth/platforms/darwin-arm64/package.json | 7 +- .../auth/platforms/darwin-x64/package.json | 7 +- .../platforms/linux-arm64-gnu/package.json | 7 +- .../auth/platforms/linux-x64-gnu/package.json | 7 +- .../platforms/linux-x64-musl/package.json | 7 +- .../platforms/win32-x64-msvc/package.json | 7 +- .../__tests__/auth-build-artifacts.test.mjs | 81 +++++ .../__tests__/auth-repository-urls.test.mjs | 55 ++++ .../__tests__/lint-no-auth-changeset.test.mjs | 14 +- .../lint-no-workflow-caching.test.mjs | 2 + scripts/__tests__/lint-release-scope.test.mjs | 20 ++ scripts/__tests__/release-gate.test.mjs | 17 ++ .../workflow-dispatch-job-conditions.test.mjs | 5 +- .../workflow-publish-permissions.test.mjs | 2 + scripts/lint-no-auth-changeset.mjs | 6 +- scripts/lint-no-workflow-caching.mjs | 5 + scripts/release-gate.mjs | 7 +- 26 files changed, 828 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/_build-auth-artifacts.yml create mode 100644 .github/workflows/auth-preflight.yml create mode 100644 scripts/__tests__/auth-build-artifacts.test.mjs create mode 100644 scripts/__tests__/auth-repository-urls.test.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 00e1e6a33..96b1ef95f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -19,6 +19,15 @@ "@cipherstash/protect-ffi-linux-x64-gnu", "@cipherstash/protect-ffi-linux-arm64-gnu", "@cipherstash/protect-ffi-linux-x64-musl" + ], + [ + "@cipherstash/auth", + "@cipherstash/auth-darwin-x64", + "@cipherstash/auth-darwin-arm64", + "@cipherstash/auth-win32-x64-msvc", + "@cipherstash/auth-linux-x64-gnu", + "@cipherstash/auth-linux-arm64-gnu", + "@cipherstash/auth-linux-x64-musl" ] ], "linked": [], diff --git a/.github/workflows/_build-auth-artifacts.yml b/.github/workflows/_build-auth-artifacts.yml new file mode 100644 index 000000000..4f408b31f --- /dev/null +++ b/.github/workflows/_build-auth-artifacts.yml @@ -0,0 +1,289 @@ +name: Build auth artifacts + +# Reusable. Builds the six `@cipherstash/auth` napi binaries and the wasm +# output, packs all seven npm tarballs, and uploads them as `auth-tarballs`. +# +# IT DOES NOT PUBLISH, for the reason `_build-ffi-artifacts.yml` gives: npm +# validates a trusted publish against the entry-point workflow's filename, so +# the publish stays in `release.yml`. Two callers: `release.yml` +# (`auth-artifacts`, after the gate says an auth version is unpublished) and +# `auth-preflight.yml` (the manual dry run). +# +# NO CACHING ANYWHERE IN HERE: the output is published with provenance, so +# this file is on `scripts/lint-no-workflow-caching.mjs`'s target list. That +# is also why no job restores `Swatinem/rust-cache`. + +on: + workflow_call: + inputs: + ref: + description: Commit to build from + required: true + type: string + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + binaries: + name: ${{ matrix.platform }} + strategy: + # One platform failing must not cancel the other five. + fail-fast: false + matrix: + include: + - platform: darwin-x64 + target: x86_64-apple-darwin + os: macos-latest + - platform: darwin-arm64 + target: aarch64-apple-darwin + os: macos-latest + - platform: linux-x64-gnu + target: x86_64-unknown-linux-gnu + os: ubuntu-latest + # A native arm64 runner rather than a cross-compile. The suite's + # `blacksmith-8vcpu-ubuntu-2404-arm` label is not in + # .github/actionlint.yaml; GitHub's own arm64 image is. + - platform: linux-arm64-gnu + target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + - platform: linux-x64-musl + target: x86_64-unknown-linux-musl + os: ubuntu-latest + - platform: win32-x64-msvc + target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Install musl tools (linux-x64-musl) + if: ${{ matrix.platform == 'linux-x64-musl' }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y musl-tools + + # Rust 1.94.1 from the root mise.toml, the toolchain the crate is tested + # with. `cache: false` is required here, not a default. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust + cache: false + + # An explicit target is what keeps both Darwin legs apart: + # `macos-latest` is arm64, so without it `darwin-x64` ships an arm64 + # binary. + - name: Add the Rust target + env: + TARGET: ${{ matrix.target }} + run: mise x -- rustup target add "$TARGET" + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + run_install: false + cache: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + package-manager-cache: false + + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # `--js false` is load-bearing. With `--platform`, napi v2 also writes its + # own `index.js` loader over the committed one, which is a published, + # frozen file (release-gate.mjs FROZEN_ARTEFACT_DIGESTS). + - name: Build the native binding + working-directory: languages/typescript/packages/auth + env: + TARGET: ${{ matrix.target }} + run: mise x -- pnpm exec napi build --platform --release --target "$TARGET" --strip --dts native.d.ts --js false + + # The build must leave the tracked tree alone: `native.d.ts` is + # regenerated and has to equal the committed copy, and nothing else may + # change. + - name: Verify the build changed no tracked file + run: git diff --exit-code -- languages/typescript/packages/auth + + - name: Place the binding in its platform package + working-directory: languages/typescript/packages/auth + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + mv "stack-auth-node.${PLATFORM}.node" "platforms/${PLATFORM}/" + test -s "platforms/${PLATFORM}/stack-auth-node.${PLATFORM}.node" + + # `npm pack`, not `pnpm pack`: a platform package has no `workspace:` + # dependency to rewrite. The wrapper does, and is packed with pnpm below. + - name: Pack the platform package + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + mkdir -p auth-dist + (cd "languages/typescript/packages/auth/platforms/${PLATFORM}" && npm pack) + mv "languages/typescript/packages/auth/platforms/${PLATFORM}"/*.tgz auth-dist/ + ls auth-dist + + - name: Verify the tarball is the platform package, with its binary + env: + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + shopt -s nullglob + tarballs=(auth-dist/*.tgz) + test "${#tarballs[@]}" -eq 1 || { + echo "::error::expected one tarball, found ${#tarballs[@]}"; exit 1; } + tgz="${tarballs[0]}" + name=$(tar xzOf "$tgz" package/package.json | node -p \ + 'JSON.parse(require("node:fs").readFileSync(0,"utf8")).name') + test "$name" = "@cipherstash/auth-${PLATFORM}" || { + echo "::error::packed $name, expected the ${PLATFORM} platform package" + exit 1; } + # Into a variable, not `tar | grep -q`: SIGPIPE under pipefail. See + # _build-ffi-artifacts.yml. + listing=$(tar tzf "$tgz") + grep -qx "package/stack-auth-node.${PLATFORM}.node" <<< "$listing" || { + echo "::error::$tgz has no stack-auth-node.${PLATFORM}.node"; exit 1; } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: auth-platform-${{ matrix.platform }} + path: auth-dist/*.tgz + if-no-files-found: error + + wrapper: + name: wasm + wrapper tarball + # Collects the six platform artifacts, so a caller downloads + # `auth-tarballs` and has all seven. + needs: [binaries] + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # Rust with the wasm32-unknown-unknown target from the root mise.toml, + # and wasm-pack, which `build:wasm` shells out to and the root mise.toml + # does not pin. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + install_args: rust aqua:wasm-bindgen/wasm-pack@0.13.1 + cache: false + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + run_install: false + cache: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + package-manager-cache: false + + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # `wasm/` is in the wrapper's `files` and is not tracked. + - name: Build wasm + working-directory: languages/typescript/packages/auth + run: mise x aqua:wasm-bindgen/wasm-pack@0.13.1 -- pnpm run build:wasm + + # `pnpm pack`, not `npm pack`: the six platform peers are `workspace:*`, + # which only pnpm rewrites to the exact version. An npm-packed wrapper + # would publish `workspace:*` ranges that no registry can resolve. + - name: Pack the wrapper + run: | + set -euo pipefail + mkdir -p auth-dist + pnpm --dir languages/typescript/packages/auth pack + mv languages/typescript/packages/auth/cipherstash-auth-[0-9]*.tgz auth-dist/ + + - name: Verify the wrapper tarball + run: | + set -euo pipefail + tgz=$(ls auth-dist/cipherstash-auth-[0-9]*.tgz) + tar tzf "$tgz" > listing.txt + # Every path the packed manifest's `exports` resolve to, plus every + # plain `files` entry. `wasm/` is a folder entry, covered by the + # wasm export paths. + tar xzOf "$tgz" package/package.json | node -e ' + const j = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const paths = new Set() + const walk = (node) => { + if (typeof node === "string") { if (node.startsWith("./")) paths.add("package/" + node.slice(2)) } + else if (node && typeof node === "object") { for (const v of Object.values(node)) walk(v) } + } + walk(j.exports) + for (const f of j.files ?? []) { if (!f.endsWith("/")) paths.add("package/" + f) } + paths.add("package/wasm/stack_auth_wasm_bg.wasm") + console.log([...paths].sort().join("\n")) + ' > required.txt + cat required.txt + while read -r required ; do + grep -qx "$required" listing.txt || { + echo "::error::$required missing from $tgz"; exit 1; } + done < required.txt + # The six platform peers must be concrete versions equal to the + # wrapper's own. + tar xzOf "$tgz" package/package.json | node -e ' + const j = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const peers = Object.entries(j.peerDependencies ?? {}).filter(([n]) => n.startsWith("@cipherstash/auth-")) + if (peers.length !== 6) { console.error("expected 6 platform peers, found " + peers.length); process.exit(1) } + for (const [n, v] of peers) { + if (v !== j.version) { console.error(n + " is " + v + ", expected " + j.version); process.exit(1) } + } + console.log("platform peers OK") + ' + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: auth-platform-* + path: auth-dist + merge-multiple: true + + - name: Verify all seven tarballs are present and distinct + run: | + set -euo pipefail + shopt -s nullglob + tarballs=(auth-dist/*.tgz) + count=${#tarballs[@]} + test "$count" -eq 7 || { + echo "::error::expected 7 tarballs, found $count"; ls auth-dist; exit 1; } + names=$(for t in "${tarballs[@]}" ; do + tar xzOf "$t" package/package.json | node -p \ + 'JSON.parse(require("node:fs").readFileSync(0,"utf8")).name' + done | sort -u | wc -l) + test "$names" -eq 7 || { + echo "::error::expected 7 distinct package names, found $names"; exit 1; } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: auth-tarballs + path: auth-dist/*.tgz + if-no-files-found: error diff --git a/.github/workflows/auth-preflight.yml b/.github/workflows/auth-preflight.yml new file mode 100644 index 000000000..9bf48e831 --- /dev/null +++ b/.github/workflows/auth-preflight.yml @@ -0,0 +1,132 @@ +name: Auth release pre-flight + +# The `@cipherstash/auth` counterpart of ffi-preflight.yml: build the real +# seven tarballs, check each binary is the architecture and libc its package +# name claims, then install the host-matching pair into a scratch project and +# load it. Point it at the Version Packages PR branch so the tarballs carry the +# versions that will publish. +# +# It never publishes, and cannot: no `id-token` permission, no secret passed to +# the call below, no `registry-url` on setup-node, and no NPM_TOKEN or +# NODE_AUTH_TOKEN anywhere. Adding any one of them turns this into a publisher. +# +# Dispatch-only, so it can run only once it is on the default branch. + +on: + workflow_dispatch: + inputs: + ref: + description: Ref to build and test (e.g. changeset-release/main) + required: true + type: string + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + artifacts: + name: Build artifacts + uses: ./.github/workflows/_build-auth-artifacts.yml + with: + ref: ${{ inputs.ref }} + + smoke: + name: Install and smoke-test + needs: [artifacts] + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: auth-tarballs + path: auth-dist + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 22 + package-manager-cache: false + + # npm refuses to install a platform package whose os/cpu does not match + # (EBADPLATFORM), so the five non-host tarballs are checked statically. + - name: Verify each binary's architecture + run: | + set -euo pipefail + declare -A EXPECT=( + [darwin-arm64]='Mach-O 64-bit.*arm64' + [darwin-x64]='Mach-O 64-bit.*x86_64' + [linux-arm64-gnu]='ELF 64-bit.*ARM aarch64' + [linux-x64-gnu]='ELF 64-bit.*x86-64' + [linux-x64-musl]='ELF 64-bit.*x86-64' + [win32-x64-msvc]='PE32\+.*x86-64' + ) + checked=0 + mkdir -p probe && cd probe + for tgz in ../auth-dist/*.tgz ; do + name=$(tar xzOf "$tgz" package/package.json | node -p \ + "JSON.parse(require('node:fs').readFileSync(0,'utf8')).name") + platform="${name#@cipherstash/auth-}" + [ "$platform" = "$name" ] && continue + test -n "${EXPECT[$platform]+set}" || { + echo "::error::no expected architecture recorded for $platform"; exit 1; } + rm -rf x && mkdir x && tar xzf "$tgz" -C x + binary="x/package/stack-auth-node.${platform}.node" + desc=$(file -b "$binary") + echo "$platform: $desc" + [[ "$desc" =~ ${EXPECT[$platform]} ]] || { + echo "::error::$platform binary is '$desc', expected ${EXPECT[$platform]}" + exit 1; } + # `file` cannot tell gnu from musl; the dynamic section can. Into a + # variable, never into `grep -q` (see ffi-preflight.yml). + case "$platform" in + linux-x64-gnu|linux-arm64-gnu) + dynamic=$(readelf -d "$binary") + grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" || { + echo "::error::$platform does not link glibc"; exit 1; } ;; + linux-x64-musl) + dynamic=$(readelf -d "$binary") + if grep -q 'NEEDED.*libc\.so\.6' <<< "$dynamic" ; then + echo "::error::linux-x64-musl links glibc — it is the gnu binary" + exit 1 + fi + echo "linux-x64-musl: no glibc NEEDED entry" ;; + esac + checked=$((checked + 1)) + done + test "$checked" -eq "${#EXPECT[@]}" || { + echo "::error::checked $checked platform binaries, expected ${#EXPECT[@]}" + exit 1; } + + - name: Install the wrapper and the host platform package + run: | + set -euo pipefail + mkdir -p /tmp/smoke && cd /tmp/smoke + echo '{"name":"smoke","version":"1.0.0","type":"module","private":true}' > package.json + wrapper=$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-[0-9]*.tgz) + host=$(ls "$GITHUB_WORKSPACE"/auth-dist/cipherstash-auth-linux-x64-gnu-*.tgz) + npm install --no-audit --no-fund "$wrapper" "$host" + + # Pure: no client, no credentials, no network. The CommonJS entry loads + # the native binding at require time, so a missing or wrong binary fails + # here. + - name: Smoke-test the installed artifact + run: | + set -euo pipefail + cd /tmp/smoke + cat > smoke.mjs <<'EOF' + import { createRequire } from 'node:module' + const require = createRequire(import.meta.url) + const auth = require('@cipherstash/auth') + for (const name of ['AccessKeyStrategy', 'AutoStrategy', 'OidcFederationStrategy']) { + if (typeof auth[name] !== 'function') throw new Error('no ' + name) + } + const cookies = await import('@cipherstash/auth/cookies') + if (Object.keys(cookies).length === 0) throw new Error('./cookies did not resolve') + const inline = await import('@cipherstash/auth/wasm-inline') + if (Object.keys(inline).length === 0) throw new Error('./wasm-inline did not resolve') + console.log('smoke OK') + EOF + node smoke.mjs diff --git a/.github/workflows/lint-release.yml b/.github/workflows/lint-release.yml index 0125be7de..4a36c5a3e 100644 --- a/.github/workflows/lint-release.yml +++ b/.github/workflows/lint-release.yml @@ -34,6 +34,8 @@ on: - .github/workflows/release.yml - .github/workflows/_build-ffi-artifacts.yml - .github/workflows/ffi-preflight.yml + - .github/workflows/_build-auth-artifacts.yml + - .github/workflows/auth-preflight.yml - .github/workflows/_build-eql-sql.yml - .github/workflows/_build-eql-docs.yml - .github/workflows/release-plz.yml @@ -91,6 +93,8 @@ jobs: .github/workflows/release.yml \ .github/workflows/_build-ffi-artifacts.yml \ .github/workflows/ffi-preflight.yml \ + .github/workflows/_build-auth-artifacts.yml \ + .github/workflows/auth-preflight.yml \ .github/workflows/_build-eql-sql.yml \ .github/workflows/_build-eql-docs.yml \ .github/workflows/release-plz.yml \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 989954bb1..4ea8a3a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,15 @@ name: Release JS # See https://docs.npmjs.com/trusted-publishers#supported-cicd-providers # Enforced by scripts/__tests__/workflow-publish-permissions.test.mjs. # -# THREE RELEASE LINES, ONE FILE: the JS packages (changesets), the seven FFI -# 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. +# 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 are INERT while the seven auth packages are in +# FROZEN_PUBLISHERS: the gate reports `auth=true` only for an unpublished auth +# version, and for a frozen package that is 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 @@ -165,12 +170,14 @@ jobs: 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. + # `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: @@ -340,19 +347,127 @@ jobs: # 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 + 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)" >> "$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] - # `always()` because `publish-ffi` is SKIPPED for an ordinary JS release, - # and a skipped dependency would otherwise skip this job too. + 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 output instead: if FFI was in scope, its publish must have - # SUCCEEDED. + # 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' && @@ -360,6 +475,10 @@ jobs: ( 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 diff --git a/.github/workflows/tests-supply-chain.yml b/.github/workflows/tests-supply-chain.yml index 1f30d8cea..490c48370 100644 --- a/.github/workflows/tests-supply-chain.yml +++ b/.github/workflows/tests-supply-chain.yml @@ -24,10 +24,12 @@ on: - main paths: - '.github/workflows/release.yml' - # The reusable workflow release.yml calls: everything it builds is packed - # and published, and it is on the lint's target list for that reason, so - # an edit to it has to run this gate too. + # The reusable workflows release.yml calls for the two native-binary + # lines: everything they build is packed and published, and they are on + # the lint's target list for that reason, so an edit to either has to run + # this gate too. - '.github/workflows/_build-ffi-artifacts.yml' + - '.github/workflows/_build-auth-artifacts.yml' - '.github/workflows/tests-supply-chain.yml' - 'scripts/lint-no-workflow-caching.mjs' - 'scripts/__tests__/lint-no-workflow-caching.test.mjs' @@ -37,10 +39,12 @@ on: - '**' paths: - '.github/workflows/release.yml' - # The reusable workflow release.yml calls: everything it builds is packed - # and published, and it is on the lint's target list for that reason, so - # an edit to it has to run this gate too. + # The reusable workflows release.yml calls for the two native-binary + # lines: everything they build is packed and published, and they are on + # the lint's target list for that reason, so an edit to either has to run + # this gate too. - '.github/workflows/_build-ffi-artifacts.yml' + - '.github/workflows/_build-auth-artifacts.yml' - '.github/workflows/tests-supply-chain.yml' - 'scripts/lint-no-workflow-caching.mjs' - 'scripts/__tests__/lint-no-workflow-caching.test.mjs' diff --git a/AGENTS.md b/AGENTS.md index cd70b3079..982979ffc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -580,7 +580,11 @@ monorepo, which is where the silent failures are. refuses it. The platform packages publish only a binary built in CI, so their entries declare `noTreeBytes` and check 3 skips them; checks 1 and 2 still apply. `scripts/lint-no-auth-changeset.mjs` refuses a changeset naming any of the - seven, and goes in the same PR. + seven, and goes in the same PR. Deleting the entries also arms two + pipelines: `release.yml`'s `auth-artifacts` and `publish-auth`, which run + once the gate reports `auth=true`, and `release-plz.yml`'s `release-crates` + for `stack-auth` and `stack-profile`, whose switch is + `node scripts/eql-pipeline-armed.mjs crates`. **Check 3 is the one worth understanding before you touch a frozen package.** For a package this repo publishes, in-tree bytes differing from npm is an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b10dc617c..b35dd5c2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,8 +184,8 @@ they always version together, so a bump to any one of them bumps all six. developed here but still published from `cipherstash/cipherstash-suite`. Until publishing moves here, do not add a changeset for them: `pnpm run lint:auth-changeset` fails on one, and `release:gate` blocks any version npm -does not have. Once publishing moves, the seven release together as their own -`fixed` group. +does not have. They are already their own `fixed` group in +`.changeset/config.json`, so once publishing moves the seven release together. Two Rust crates, `stack-auth` and `stack-profile`, are released to crates.io, in one version group of their own, by release-plz from the root Cargo diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index dfb1f1255..aab7669d5 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -2,6 +2,11 @@ "name": "@cipherstash/auth", "version": "0.44.0", "license": "SEE LICENSE IN LICENSE", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth" + }, "main": "index.js", "types": "index.d.ts", "browser": false, diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json index 219540b0d..dae4c7f44 100644 --- a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-darwin-arm64", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/darwin-arm64" + }, "os": [ "darwin" ], @@ -11,4 +16,4 @@ "files": [ "stack-auth-node.darwin-arm64.node" ] -} \ No newline at end of file +} diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json index 402aff111..aaec8dc56 100644 --- a/languages/typescript/packages/auth/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-darwin-x64", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/darwin-x64" + }, "os": [ "darwin" ], @@ -11,4 +16,4 @@ "files": [ "stack-auth-node.darwin-x64.node" ] -} \ No newline at end of file +} diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json index 04a40099d..a6547ee21 100644 --- a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-linux-arm64-gnu", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/linux-arm64-gnu" + }, "os": [ "linux" ], @@ -14,4 +19,4 @@ "libc": [ "glibc" ] -} \ No newline at end of file +} diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json index 3ef4be204..0ab546518 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-linux-x64-gnu", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/linux-x64-gnu" + }, "os": [ "linux" ], @@ -14,4 +19,4 @@ "libc": [ "glibc" ] -} \ No newline at end of file +} diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json index 09fd6602a..5e2de508c 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-linux-x64-musl", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/linux-x64-musl" + }, "os": [ "linux" ], @@ -14,4 +19,4 @@ "libc": [ "musl" ] -} \ No newline at end of file +} diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json index f4b5501c0..7dbc881cc 100644 --- a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -1,6 +1,11 @@ { "name": "@cipherstash/auth-win32-x64-msvc", "version": "0.44.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cipherstash/stack.git", + "directory": "languages/typescript/packages/auth/platforms/win32-x64-msvc" + }, "os": [ "win32" ], @@ -11,4 +16,4 @@ "files": [ "stack-auth-node.win32-x64-msvc.node" ] -} \ No newline at end of file +} diff --git a/scripts/__tests__/auth-build-artifacts.test.mjs b/scripts/__tests__/auth-build-artifacts.test.mjs new file mode 100644 index 000000000..fdaee354d --- /dev/null +++ b/scripts/__tests__/auth-build-artifacts.test.mjs @@ -0,0 +1,81 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `_build-auth-artifacts.yml` builds the seven `@cipherstash/auth` tarballs + * that `release.yml`'s `publish-auth` uploads. Three things about it are easy + * to undo in an edit that still looks right, and each one ships a broken + * package rather than failing a build: + * + * 1. `napi build --platform` with no `--js false` writes napi's own loader over + * the committed `index.js`, one of the wrapper's frozen published files. + * The workflow runs `git diff --exit-code` on the package after the build, + * so the build must leave the tracked tree as it found it. + * 2. The wrapper's six platform peers are `workspace:*`. `pnpm pack` rewrites + * them to the exact version; `npm pack` publishes `workspace:*`, which no + * registry resolves. + * 3. The matrix is a third list of the six platforms, beside the `platforms/` + * folders and the napi triples in the wrapper's `package.json`. + */ + +const WORKFLOW = '.github/workflows/_build-auth-artifacts.yml' +const AUTH_DIR = 'languages/typescript/packages/auth' +const AUTH = join(REPO_ROOT, AUTH_DIR) + +const workflow = readWorkflow(WORKFLOW) +const binaries = workflow?.jobs?.binaries +const wrapper = workflow?.jobs?.wrapper +const runs = (job) => (job?.steps ?? []).map((step) => String(step?.run ?? '')) + +const PLATFORMS = readdirSync(join(AUTH, 'platforms'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + +describe('_build-auth-artifacts.yml', () => { + it('builds every platform package, and only those', () => { + const matrix = binaries?.strategy?.matrix?.include ?? [] + expect(PLATFORMS).toHaveLength(6) + expect(matrix.map((leg) => leg.platform).sort()).toEqual(PLATFORMS) + + const { napi } = JSON.parse( + readFileSync(join(AUTH, 'package.json'), 'utf8'), + ) + expect(matrix.map((leg) => leg.target).sort()).toEqual( + [...napi.triples.additional].sort(), + ) + }) + + it('builds each binding without writing over the committed loader', () => { + const build = runs(binaries).filter((run) => /\bnapi build\b/.test(run)) + expect(build).toHaveLength(1) + for (const flag of [ + '--platform', + '--release', + '--target', + '--strip', + '--dts native.d.ts', + '--js false', + ]) { + expect(build[0]).toContain(flag) + } + }) + + it('fails the leg when the build changed a tracked file', () => { + const all = runs(binaries) + const build = all.findIndex((run) => /\bnapi build\b/.test(run)) + const check = all.findIndex((run) => + new RegExp(`git diff --exit-code\\b.*${AUTH_DIR}`).test(run), + ) + expect(check).toBeGreaterThan(build) + }) + + it('packs the wrapper with pnpm, which rewrites its workspace peers', () => { + const packs = runs(wrapper).filter((run) => /\bpack\b/.test(run)) + expect(packs.some((run) => /\bpnpm\b.*\bpack\b/.test(run))).toBe(true) + expect(packs.some((run) => /\bnpm pack\b/.test(run))).toBe(false) + }) +}) diff --git a/scripts/__tests__/auth-repository-urls.test.mjs b/scripts/__tests__/auth-repository-urls.test.mjs new file mode 100644 index 000000000..8c9fb316a --- /dev/null +++ b/scripts/__tests__/auth-repository-urls.test.mjs @@ -0,0 +1,55 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * The seven `@cipherstash/auth` manifests must name THIS repository. + * + * `release.yml`'s `publish-auth` publishes them with `--provenance`, and npm + * refuses a provenance publish (E422) when `repository.url` does not match the + * repository the attestation names. None of the seven had a `repository` field + * when they were imported from cipherstash-suite, which published them with a + * token and no provenance. `ffi-repository-urls.test.mjs` explains why + * `directory` is asserted as well: a wrong one publishes fine and breaks the + * source link on the package page. + * + * `package.json` is not one of the files the release gate hashes for the auth + * freeze, so adding the field does not trip it. + */ + +const AUTH_DIR = 'languages/typescript/packages/auth' +const AUTH = join(REPO_ROOT, AUTH_DIR) + +const EXPECTED_URL = 'git+https://github.com/cipherstash/stack.git' + +// Directories only, as ffi-repository-urls.test.mjs does. +const PLATFORMS = readdirSync(join(AUTH, 'platforms'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + +const manifests = [ + { dir: AUTH_DIR, path: join(AUTH, 'package.json') }, + ...PLATFORMS.map((platform) => ({ + dir: `${AUTH_DIR}/platforms/${platform}`, + path: join(AUTH, 'platforms', platform, 'package.json'), + })), +] + +describe('@cipherstash/auth manifests name this repository', () => { + it('checks the wrapper and all six platform packages', () => { + expect(manifests).toHaveLength(7) + }) + + for (const { dir, path } of manifests) { + const pkg = JSON.parse(readFileSync(path, 'utf8')) + + it(`${pkg.name} points repository.url at cipherstash/stack`, () => { + expect(pkg.repository?.url).toBe(EXPECTED_URL) + }) + + it(`${pkg.name} names its own path from the repo root`, () => { + expect(pkg.repository?.directory).toBe(dir) + }) + } +}) diff --git a/scripts/__tests__/lint-no-auth-changeset.test.mjs b/scripts/__tests__/lint-no-auth-changeset.test.mjs index 109c66a61..ea8b4bb39 100644 --- a/scripts/__tests__/lint-no-auth-changeset.test.mjs +++ b/scripts/__tests__/lint-no-auth-changeset.test.mjs @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { FROZEN_PUBLISHERS, workspaceManifests } from '../release-gate.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' const SCRIPT = resolve( fileURLToPath(import.meta.url), @@ -120,9 +121,10 @@ describe('lint-no-auth-changeset', () => { }) it('guards exactly the frozen auth packages, which are the auth workspace packages', () => { - // Three lists name the same seven packages until PR E: this guard, the - // release gate's freeze, and the workspace. Drift between any two lets a - // platform package through while the others still treat it as frozen. + // Four lists name the same seven packages until PR E: this guard, the + // release gate's freeze, the workspace, and the changesets `fixed` group. + // Drift between any two lets a platform package through while the others + // still treat it as frozen, or version it apart from the wrapper. const guarded = [ ...readFileSync(SCRIPT, 'utf8').matchAll( /'(@cipherstash\/auth(?:-[a-z0-9-]+)?)'/g, @@ -138,5 +140,11 @@ describe('lint-no-auth-changeset', () => { expect([...new Set(guarded)].sort()).toHaveLength(7) expect([...new Set(guarded)].sort()).toEqual([...frozen].sort()) expect([...new Set(guarded)].sort()).toEqual([...workspace].sort()) + + const { fixed } = JSON.parse( + readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8'), + ) + const group = fixed.find((names) => names.includes('@cipherstash/auth')) + expect([...(group ?? [])].sort()).toEqual([...new Set(guarded)].sort()) }) }) diff --git a/scripts/__tests__/lint-no-workflow-caching.test.mjs b/scripts/__tests__/lint-no-workflow-caching.test.mjs index fbda6351c..75ae967ce 100644 --- a/scripts/__tests__/lint-no-workflow-caching.test.mjs +++ b/scripts/__tests__/lint-no-workflow-caching.test.mjs @@ -17,6 +17,8 @@ import { REPO_ROOT } from './lib/repo-root.mjs' const TARGET_WORKFLOWS = [ '.github/workflows/release.yml', '.github/workflows/_build-ffi-artifacts.yml', + '.github/workflows/_build-auth-artifacts.yml', + '.github/workflows/auth-preflight.yml', // The EQL release line. `scripts/__tests__/eql-suite-ci.test.mjs` reads the // script's real list too, and uses it to exempt these workflows from the // rust-cache requirement — the two rules point opposite ways for a job that diff --git a/scripts/__tests__/lint-release-scope.test.mjs b/scripts/__tests__/lint-release-scope.test.mjs index e34a5967c..fa9f67142 100644 --- a/scripts/__tests__/lint-release-scope.test.mjs +++ b/scripts/__tests__/lint-release-scope.test.mjs @@ -61,6 +61,26 @@ describe('lint-release.yml lints exactly what it triggers on', () => { } }) + it('lints every reusable workflow release.yml calls, and their other callers', () => { + // The two lists agreeing says nothing about whether a NEW release workflow + // made it into either. Derived instead: whatever release.yml builds with is + // release machinery, and so is any other workflow that calls the same + // builder (a preflight), because it runs the same build by another route. + const localUses = (relPath) => + Object.values(readWorkflow(relPath)?.jobs ?? {}) + .map((job) => job?.uses) + .filter((uses) => typeof uses === 'string' && uses.startsWith('./')) + .map((uses) => uses.slice(2)) + const builders = new Set(localUses(`${WORKFLOW_DIR}/release.yml`)) + const callers = workflowFiles().filter((relPath) => + localUses(relPath).some((uses) => builders.has(uses)), + ) + expect(builders.size).toBeGreaterThan(0) + expect( + [...builders, ...callers].filter((relPath) => !linted.includes(relPath)), + ).toEqual([]) + }) + it('lints itself', () => { // The gate has to be inside its own scope: a shell or syntax error // introduced HERE is otherwise checked by nothing. diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index f3a5741d2..764df3f12 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -783,6 +783,23 @@ describe('the gate actually blocks the publish', () => { '`always()` a failed gate would otherwise still reach `changeset publish`.', ).toMatch(/needs\.gate\.result\s*==\s*'success'/) }) + + it('builds and publishes auth only on the gate, and holds the release for it', () => { + // The `publish-ffi` shape: a skipped `publish-auth` whose build failed must + // not read as "auth was not in scope", or `changeset publish` packs the + // auth platform workspaces with no binary. + expect(workflow.jobs.gate.outputs.auth).toBeDefined() + for (const name of ['auth-artifacts', 'publish-auth']) { + expect(String(workflow.jobs[name].if)).toContain( + "needs.gate.outputs.auth == 'true'", + ) + } + const release = workflow.jobs.release + expect(release.needs).toContain('publish-auth') + expect(String(release.if).replace(/\s+/g, ' ')).toContain( + "needs.gate.outputs.auth != 'true' || needs.publish-auth.result == 'success'", + ) + }) }) /** diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index 889a37369..d97e8a892 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -59,6 +59,8 @@ const EXPECTED_DISPATCHABLE = [ // it is the ONLY way to run it, and it exists to be pointed at a Version // Packages branch before the irreversible publish. '.github/workflows/ffi-preflight.yml', + // The same dry run for the @cipherstash/auth line. + '.github/workflows/auth-preflight.yml', '.github/workflows/integration-protect-ffi.yml', // Path-filtered to the release machinery, so dispatch is how it gets run // against a branch that changed something the filter does not name. @@ -412,8 +414,9 @@ const PERMISSIVE_NEEDS = { classify: { outputs: { mode: 'production', version: '3.0.6' } }, 'eql-armed': { outputs: { armed: 'true' } }, 'crates-armed': { outputs: { armed: 'true' } }, - gate: { result: 'success', outputs: { ffi: 'true' } }, + gate: { result: 'success', outputs: { ffi: 'true', auth: 'true' } }, 'publish-ffi': { result: 'success' }, + 'publish-auth': { result: 'success' }, release: { result: 'success', outputs: { diff --git a/scripts/__tests__/workflow-publish-permissions.test.mjs b/scripts/__tests__/workflow-publish-permissions.test.mjs index bf4eb783a..695963c31 100644 --- a/scripts/__tests__/workflow-publish-permissions.test.mjs +++ b/scripts/__tests__/workflow-publish-permissions.test.mjs @@ -44,6 +44,8 @@ import { readWorkflow, workflowFiles } from './lib/workflows.mjs' const PUBLISH_OIDC_JOBS = [ // Uploads the seven prebuilt FFI tarballs. Publishes, so it needs OIDC. '.github/workflows/release.yml / publish-ffi', + // The same for the seven prebuilt @cipherstash/auth tarballs. + '.github/workflows/release.yml / publish-auth', // `changeset publish` for the JS packages, plus the Version Packages PR. '.github/workflows/release.yml / release', // The EQL prerelease path publishes @cipherstash/eql directly rather than diff --git a/scripts/lint-no-auth-changeset.mjs b/scripts/lint-no-auth-changeset.mjs index fd3d35665..93de4124e 100644 --- a/scripts/lint-no-auth-changeset.mjs +++ b/scripts/lint-no-auth-changeset.mjs @@ -67,9 +67,9 @@ for (const { file, packages } of offenders) { console.error( '\nThese seven packages live in this repo but are still PUBLISHED from\n' + 'cipherstash/cipherstash-suite — npm trusted publishing has not been\n' + - 'repointed yet, and this repository has no job that builds their native\n' + - 'binaries. Releasing a bumped version from here is blocked by\n' + - '`release:gate`, and that block stops every other release with it.\n\n' + + 'repointed yet, so `release.yml` here cannot publish them. Releasing a\n' + + 'bumped version from here is blocked by `release:gate`, and that block\n' + + 'stops every other release with it.\n\n' + 'Remove the changeset. Merges that touch the auth packages are paused\n' + 'until the arming PR (PR E of the stack-* crates import), which repoints\n' + 'trusted publishing, deletes this script and writes the changesets. If a\n' + diff --git a/scripts/lint-no-workflow-caching.mjs b/scripts/lint-no-workflow-caching.mjs index bbc3f309b..18bd6f121 100644 --- a/scripts/lint-no-workflow-caching.mjs +++ b/scripts/lint-no-workflow-caching.mjs @@ -17,6 +17,11 @@ const TARGETS = process.argv.slice(2).length // calls it too — cannot become a way to build these artifacts under // different rules. '.github/workflows/_build-ffi-artifacts.yml', + // The @cipherstash/auth line, for the same reasons: the reusable build + // publishes through release.yml's publish-auth, and its dry-run caller + // must not build the same tarballs under other rules. + '.github/workflows/_build-auth-artifacts.yml', + '.github/workflows/auth-preflight.yml', // The EQL release line. The two reusables are reached from release.yml // anyway and named for the same reason `_build-ffi-artifacts.yml` is: a // second caller must not become a way to build them under other rules. diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index edae759f0..c6862e1d2 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -176,9 +176,10 @@ export const FROZEN_PUBLISHERS = new Map([ ].map((name) => [ name, 'Still published from cipherstash/cipherstash-suite — npm trusted publishing ' + - 'for the seven @cipherstash/auth packages names that repository, and ' + - '`release.yml` here has no job that builds the native binaries. Repointing ' + - 'is the arming PR (PR E) of the stack-* crates import.', + 'for the seven @cipherstash/auth packages names that repository, not this ' + + 'one. `release.yml` here builds and publishes them (`publish-auth`), but only ' + + 'once these entries are gone. Repointing is the arming PR (PR E) of the ' + + 'stack-* crates import.', ]), ])