From 84fe5ed5c44f88237a89ca38b86169fd6ba31302 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 21:20:32 +1100 Subject: [PATCH 1/6] ci(golang): tag Go module releases with their WASI guests The Go module embeds three WASI guests built from Rust, and they are gitignored on main. Go fetches a module version as the files committed at its tag, so tagging a main commit would give `go get` a module that builds and then fails at NewClient with ErrGuestNotBuilt. release-golang.yml is dispatched by hand with a version. It builds the guests and the deterministic test builds, runs the Go tests against them, builds the EQL SQL bundles, then commits the three guests on a release-only commit that no branch points at, tags it languages/golang/v and pushes the tag alone. The GitHub release carries the EQL v4 install and uninstall SQL and the guests' SHA-256, and is marked a pre-release for a pre-release version. A last step asks proxy.golang.org for the version so a tag that does not resolve fails the run. It restores no cache, since it publishes binaries, and is registered with lint-no-workflow-caching and the EQL suite's publish-path exemption. Refs #1034 --- .github/workflows/release-golang.yml | 176 ++++++++++++++++++ AGENTS.md | 2 +- scripts/__tests__/eql-suite-ci.test.mjs | 1 + .../lint-no-workflow-caching.test.mjs | 3 + scripts/lint-no-workflow-caching.mjs | 3 + 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-golang.yml diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml new file mode 100644 index 000000000..cfde8d62a --- /dev/null +++ b/.github/workflows/release-golang.yml @@ -0,0 +1,176 @@ +name: Release (Go) + +# Tags a release of the Go module (languages/golang) that `go get` can use. +# +# The module embeds three WASI guests built from Rust (encrypt/wasm, +# encrypt/eql/wasm, auth/wasm). They are gitignored on main, and Go fetches a +# module version as the files committed at its tag, so a tag on a main commit +# would build and then fail at `NewClient` with ErrGuestNotBuilt. This +# workflow builds the guests at the commit it runs on, commits them on a +# release-only commit that no branch points at, tags that commit +# `languages/golang/v` (the prefix Go requires for a module in a +# subdirectory) and pushes the tag alone. main stays free of binaries. +# +# The GitHub release it creates carries the EQL v4 SQL bundle the module's +# EQL types are stored in, built from the same commit, so one release page +# holds everything a user of that version installs. Run by hand: +# +# gh workflow run release-golang.yml -f version=0.1.0-alpha.1 +# +# A version with a pre-release suffix is published as a GitHub pre-release. + +on: + workflow_dispatch: + inputs: + version: + description: "Module version without the leading v, e.g. 0.1.0-alpha.1" + required: true + type: string + +# The job that tags asks for write access itself; see +# scripts/__tests__/workflow-publish-permissions.test.mjs. +permissions: + contents: read + +concurrency: + group: release-golang + cancel-in-progress: false + +jobs: + release: + runs-on: blacksmith-16vcpu-ubuntu-2204 + permissions: + # Push the tag and create the release. + contents: write + env: + VERSION: ${{ inputs.version }} + # wasm:guest:test and the Go tests use the pinned test toolset. + MISE_ENV: test + steps: + - name: Check the version + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::version must be semver without a leading v, got '$VERSION'" + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # The tag is pushed with the job's token. + persist-credentials: true + + - name: Refuse a tag that already exists + run: | + tag="languages/golang/v$VERSION" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + echo "::error::$tag already exists; a Go module version is immutable once fetched" + exit 1 + fi + + # No cache: this job publishes binaries, and a poisoned restore would + # ship in them. scripts/lint-no-workflow-caching.mjs holds it. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + cache: false + + - name: Add rustfmt and clippy to the pinned toolchain + run: rustup component add rustfmt clippy + + # The release builds, each with its import surface checked, and the + # deterministic test builds the hermetic Go tests load. + - name: Build the guests + run: | + mise run wasm:guest:build + mise run wasm:guest:build:eql + mise run wasm:auth-guest:build + mise run wasm:guest:build:deterministic + mise run wasm:guest:build:eql:deterministic + + - name: Go tests against the guests being released + run: mise run go:test + + # eql_v4.version() reports `go-`: the bundle is released with + # this Go version, not as an @cipherstash/eql version. `--force` + # because `--version` is not part of the build's cache key, so a cache + # hit would re-serve whatever version an earlier build stamped; the grep + # proves the stamp landed. + - name: Build the EQL SQL bundles + working-directory: packages/eql + run: | + mise run --force build --version "go-$VERSION" + grep -q "'go-$VERSION'" release/cipherstash-encrypt-v4.sql \ + || { echo "::error::the v4 bundle is not stamped go-$VERSION"; exit 1; } + + - name: Commit the guests and tag the commit + run: | + set -euo pipefail + tag="languages/golang/v$VERSION" + guests=( + languages/golang/encrypt/wasm/stack_encrypt_guest.wasm + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm + languages/golang/auth/wasm/stack_auth_guest.wasm + ) + : > guests.sha256 + for guest in "${guests[@]}"; do + test -s "$guest" || { echo "::error::$guest was not built"; exit 1; } + sha256sum "$guest" >> guests.sha256 + done + cat guests.sha256 + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout --detach + # The guests are gitignored on main; -f adds them to this commit only. + git add -f "${guests[@]}" + git commit -m "release(golang): v$VERSION with its WASI guests" \ + -m "Built from $GITHUB_SHA by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + git tag -a "$tag" -m "Go module v$VERSION (built from $GITHUB_SHA)" + git push origin "refs/tags/$tag" + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="languages/golang/v$VERSION" + cp packages/eql/release/cipherstash-encrypt-v4.sql eql-v4-install.sql + cp packages/eql/release/cipherstash-encrypt-v4-uninstall.sql eql-v4-uninstall.sql + { + echo "Go module \`github.com/cipherstash/stack/languages/golang\` v$VERSION, built from $GITHUB_SHA." + echo + echo '```sh' + echo "go get github.com/cipherstash/stack/languages/golang@v$VERSION" + echo "go install github.com/cipherstash/stack/languages/golang/cmd/stashgen@v$VERSION" + echo '```' + echo + echo "Values this version writes are stored in EQL v4 columns. Install EQL v4 into PostgreSQL with the attached \`eql-v4-install.sql\` (\`psql -f eql-v4-install.sql\`)." + echo + echo "SHA-256 of the embedded WASI guests:" + echo '```' + cat guests.sha256 + echo '```' + } > notes.md + prerelease=() + if [[ "$VERSION" == *-* ]]; then prerelease=(--prerelease); fi + gh release create "$tag" --verify-tag "${prerelease[@]}" \ + --title "Go v$VERSION" --notes-file notes.md \ + eql-v4-install.sql eql-v4-uninstall.sql guests.sha256 + + # The proxy fetches a version the first time it is asked for it; asking + # here proves the tag resolves as a module before anyone depends on it. + - name: Check that Go resolves the version + env: + GOPROXY: https://proxy.golang.org + GOFLAGS: -mod=mod + run: | + set -euo pipefail + for attempt in 1 2 3 4 5 6; do + if go list -m "github.com/cipherstash/stack/languages/golang@v$VERSION"; then + exit 0 + fi + sleep 20 + done + echo "::error::the Go proxy did not resolve v$VERSION" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 6e9020f11..dae70f0eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre]`): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index fcf8a7ad9..834048894 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -1770,6 +1770,7 @@ describe('every job that compiles Rust restores the shared cache', () => { ).toEqual([ '.github/workflows/_build-eql-docs.yml (publish-docs)', '.github/workflows/_build-eql-sql.yml (build)', + '.github/workflows/release-golang.yml (release)', '.github/workflows/release-postgres-eql-image.yml (build-sql)', '.github/workflows/release.yml (prerelease-eql-npm)', ]) diff --git a/scripts/__tests__/lint-no-workflow-caching.test.mjs b/scripts/__tests__/lint-no-workflow-caching.test.mjs index 75ae967ce..5384ac5a7 100644 --- a/scripts/__tests__/lint-no-workflow-caching.test.mjs +++ b/scripts/__tests__/lint-no-workflow-caching.test.mjs @@ -28,6 +28,9 @@ const TARGET_WORKFLOWS = [ '.github/workflows/release-plz.yml', '.github/workflows/release-postgres-eql-image.yml', '.github/workflows/tests-supply-chain.yml', + // The Go module's release: it commits WASI guests built in the job onto + // the tag `go get` fetches. + '.github/workflows/release-golang.yml', ] const SCRIPT = resolve( diff --git a/scripts/lint-no-workflow-caching.mjs b/scripts/lint-no-workflow-caching.mjs index 18bd6f121..2fbb7d88b 100644 --- a/scripts/lint-no-workflow-caching.mjs +++ b/scripts/lint-no-workflow-caching.mjs @@ -35,6 +35,9 @@ const TARGETS = process.argv.slice(2).length '.github/workflows/release-plz.yml', '.github/workflows/release-postgres-eql-image.yml', '.github/workflows/tests-supply-chain.yml', + // Commits the Go module's WASI guests on the tag `go get` fetches, so + // a poisoned restore would ship in every Go program built from it. + '.github/workflows/release-golang.yml', ] // `uses:` values that pull in the GitHub Actions cache directly. From 1839dcb1ad2b5df4708878438dbce9b515dee6a3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 23:07:16 +1100 Subject: [PATCH 2/6] ci(golang): keep the release token out of the build and check versions first The release job held `contents: write` in `.git/config` while it ran every cargo build script, proc macro and Go test in the module's dependency tree, and it pushed the tag before anything that could fail after it, so a version Go refuses, or a failed `gh release create`, spent the version number. The workflow is now three jobs. `build` has no write token: it builds and tests the guests, builds the EQL bundles first so a ref without the v4 bundle fails in minutes, checks the guests' lockfiles are current, hashes the guests before the tests and checks them after, and proves the release tree from `git archive` as `go get` will see it. `publish` holds the write token and runs only git and gh; it rebuilds the release commit from the artifact and refuses it unless its tree is the one `build` tested. It accepts a tag an earlier attempt of the same run pushed, so "Re-run failed jobs" finishes a release instead of burning the version. `verify` is read-only and downloads the module zip through the proxy from an empty directory with GOWORK off, checking the guests in it against the hashes. scripts/go-release-version.mjs refuses non-canonical semver, +build metadata, a major the module path cannot publish, and a final version from anywhere but main, before anything is built. Refs #1034. --- .github/workflows/release-golang.yml | 295 ++++++++++++++---- AGENTS.md | 2 +- SECURITY.md | 14 +- scripts/__tests__/eql-suite-ci.test.mjs | 2 +- scripts/__tests__/go-release-version.test.mjs | 133 ++++++++ scripts/go-release-version.mjs | 128 ++++++++ 6 files changed, 510 insertions(+), 64 deletions(-) create mode 100644 scripts/__tests__/go-release-version.test.mjs create mode 100644 scripts/go-release-version.mjs diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index cfde8d62a..9b56843e7 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -15,9 +15,30 @@ name: Release (Go) # EQL types are stored in, built from the same commit, so one release page # holds everything a user of that version installs. Run by hand: # -# gh workflow run release-golang.yml -f version=0.1.0-alpha.1 +# gh workflow run release-golang.yml -f version=0.1.0-alpha.1 --ref # -# A version with a pre-release suffix is published as a GitHub pre-release. +# The ref must carry the EQL v4 bundle (packages/eql's v4 build), or the +# build job stops at "Build the EQL SQL bundles". A version with a pre-release +# suffix may be cut from any branch and is published as a GitHub pre-release; +# a final version is cut from main only (scripts/go-release-version.mjs). +# +# Three jobs, split by what they may touch: +# +# - `build` holds no write token. It is the only job that runs third-party +# code (the toolchain installs, cargo build scripts and proc macros, the Go +# tests), and it hands the guests, their hashes and the SQL to `publish` as +# an artifact, with the git tree it built and tested as a job output. +# - `publish` holds `contents: write` and runs only git and gh. It rebuilds +# the release commit from the artifact, refuses it unless its tree is the one +# `build` tested, pushes the tag and creates the release. +# - `verify` is read-only again: it asks the Go proxy for the version, which +# fetches it, and checks the guests in the module zip are the ones built. +# +# A version is immutable once the proxy has fetched it, so a tag is never +# moved or deleted. If `publish` or `verify` fails after the tag is pushed, +# use "Re-run failed jobs" on the same run: `publish` accepts a tag that an +# earlier attempt of the run pushed with the same tree, and creates or +# completes the release. A new dispatch of the same version is refused. on: workflow_dispatch: @@ -27,7 +48,7 @@ on: required: true type: string -# The job that tags asks for write access itself; see +# Only `publish` asks for write access; see # scripts/__tests__/workflow-publish-permissions.test.mjs. permissions: contents: read @@ -37,38 +58,39 @@ concurrency: cancel-in-progress: false jobs: - release: + build: + name: Build and test the release runs-on: blacksmith-16vcpu-ubuntu-2204 - permissions: - # Push the tag and create the release. - contents: write + timeout-minutes: 90 env: VERSION: ${{ inputs.version }} - # wasm:guest:test and the Go tests use the pinned test toolset. + # The Go tests run under the same mise environment as tests-golang.yml's + # wasi-check job, so they see what CI sees. MISE_ENV: test + outputs: + tree: ${{ steps.stage.outputs.tree }} steps: - - name: Check the version - run: | - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::version must be semver without a leading v, got '$VERSION'" - exit 1 - fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - # The tag is pushed with the job's token. - persist-credentials: true + persist-credentials: false + + # Before anything is built: every version Go would refuse, a v2+ major + # on a path with no /vN suffix included, fails here rather than after + # the tag is pushed. + - name: Check the version and the ref + run: node scripts/go-release-version.mjs "$VERSION" "$GITHUB_REF" - name: Refuse a tag that already exists run: | tag="languages/golang/v$VERSION" if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then - echo "::error::$tag already exists; a Go module version is immutable once fetched" + echo "::error::$tag already exists; a Go module version is immutable once fetched. To finish a failed release, re-run its failed jobs instead." exit 1 fi - # No cache: this job publishes binaries, and a poisoned restore would - # ship in them. scripts/lint-no-workflow-caching.mjs holds it. + # No cache: this job builds the binaries that are published, and a + # poisoned restore would ship in them. scripts/lint-no-workflow-caching.mjs + # holds it. - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 @@ -79,55 +101,175 @@ jobs: - name: Add rustfmt and clippy to the pinned toolchain run: rustup component add rustfmt clippy - # The release builds, each with its import surface checked, and the - # deterministic test builds the hermetic Go tests load. + # First of the builds because it is the cheap one that fails on a ref + # without the v4 bundle. eql_v4.version() reports `go-`: the + # bundle is released with this Go version, not as an @cipherstash/eql + # version. `--force` because `--version` is not part of the build's + # cache key, so a cache hit would re-serve whatever version an earlier + # build stamped; the grep proves the stamp landed. + - name: Build the EQL SQL bundles + working-directory: packages/eql + run: | + set -euo pipefail + mise run --force build --version "go-$VERSION" + for sql in release/cipherstash-encrypt-v4.sql release/cipherstash-encrypt-v4-uninstall.sql; do + if [ ! -s "$sql" ]; then + echo "::error::packages/eql/$sql was not built. Dispatch this workflow from a ref that carries the EQL v4 bundle." + exit 1 + fi + done + grep -qF "'go-$VERSION'" release/cipherstash-encrypt-v4.sql \ + || { echo "::error::the v4 bundle is not stamped go-$VERSION"; exit 1; } + + # The guest builds pass no --locked, and a stale lock would be resolved + # afresh here into a module nobody can rebuild from the tag. + - name: Check the guests' lockfiles are current + run: | + set -euo pipefail + for guest in languages/golang/encrypt/guest languages/golang/auth/guest; do + cargo metadata --locked --format-version 1 --manifest-path "$guest/Cargo.toml" > /dev/null + done + - name: Build the guests run: | mise run wasm:guest:build mise run wasm:guest:build:eql mise run wasm:auth-guest:build + + # Hashed before the test builds and the tests run, and checked again + # after them below, so the guests committed are the ones tested. + - name: Hash the guests + run: | + set -euo pipefail + mkdir -p release-out + sha256sum \ + languages/golang/encrypt/wasm/stack_encrypt_guest.wasm \ + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm \ + languages/golang/auth/wasm/stack_auth_guest.wasm \ + > release-out/guests.sha256 + cat release-out/guests.sha256 + + # The deterministic test builds the hermetic Go tests load. + - name: Build the test guests + run: | mise run wasm:guest:build:deterministic mise run wasm:guest:build:eql:deterministic - name: Go tests against the guests being released run: mise run go:test - # eql_v4.version() reports `go-`: the bundle is released with - # this Go version, not as an @cipherstash/eql version. `--force` - # because `--version` is not part of the build's cache key, so a cache - # hit would re-serve whatever version an earlier build stamped; the grep - # proves the stamp landed. - - name: Build the EQL SQL bundles - working-directory: packages/eql - run: | - mise run --force build --version "go-$VERSION" - grep -q "'go-$VERSION'" release/cipherstash-encrypt-v4.sql \ - || { echo "::error::the v4 bundle is not stamped go-$VERSION"; exit 1; } + - name: Check the tests ran against the guests being released + run: sha256sum -c release-out/guests.sha256 - - name: Commit the guests and tag the commit + # The release commit, made here only to prove it: `publish` makes it + # again from the artifact and must arrive at the same tree. Built from + # `git archive` of that commit, so only committed files take part, as + # they do for `go get`. + - name: Stage the release commit and check it as Go will fetch it + id: stage run: | set -euo pipefail - tag="languages/golang/v$VERSION" - guests=( - languages/golang/encrypt/wasm/stack_encrypt_guest.wasm - languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm - languages/golang/auth/wasm/stack_auth_guest.wasm - ) - : > guests.sha256 - for guest in "${guests[@]}"; do - test -s "$guest" || { echo "::error::$guest was not built"; exit 1; } - sha256sum "$guest" >> guests.sha256 - done - cat guests.sha256 git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout --detach # The guests are gitignored on main; -f adds them to this commit only. + mapfile -t guests < <(cut -d' ' -f3 release-out/guests.sha256) + git add -f "${guests[@]}" + git commit -q -m "release(golang): v$VERSION with its WASI guests" + tree=$(git rev-parse 'HEAD^{tree}') + + fetched=$(mktemp -d) + git archive HEAD languages/golang | tar -x -C "$fetched" + (cd "$fetched" && sha256sum -c "$GITHUB_WORKSPACE/release-out/guests.sha256") + (cd "$fetched/languages/golang" && GOWORK=off GOFLAGS=-mod=readonly go build ./...) + + echo "tree=$tree" >> "$GITHUB_OUTPUT" + echo "release tree $tree" + + - name: Stage the release files + run: | + set -euo pipefail + while read -r _ guest; do + install -D -m 0644 "$guest" "release-out/$guest" + done < release-out/guests.sha256 + cp packages/eql/release/cipherstash-encrypt-v4.sql release-out/eql-v4-install.sql + cp packages/eql/release/cipherstash-encrypt-v4-uninstall.sql release-out/eql-v4-uninstall.sql + find release-out -type f | sort + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: go-release + path: release-out/ + if-no-files-found: error + retention-days: 7 + + publish: + name: Tag and release + needs: build + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + # Required reviewers set on this environment in the repository settings + # gate every release; with none set, it gates nothing. + environment: release-golang + permissions: + # Push the tag and create the release. + contents: write + env: + VERSION: ${{ inputs.version }} + TREE: ${{ needs.build.outputs.tree }} + steps: + # No credential in .git/config: the token is handed to the one push + # below, and to gh, and to nothing else. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: go-release + path: release-out + + - name: Commit the guests + run: | + set -euo pipefail + while read -r _ guest; do + install -D -m 0644 "release-out/$guest" "$guest" + done < release-out/guests.sha256 + sha256sum -c release-out/guests.sha256 + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout --detach + mapfile -t guests < <(cut -d' ' -f3 release-out/guests.sha256) git add -f "${guests[@]}" git commit -m "release(golang): v$VERSION with its WASI guests" \ -m "Built from $GITHUB_SHA by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + tree=$(git rev-parse 'HEAD^{tree}') + if [ "$tree" != "$TREE" ]; then + echo "::error::release tree $tree is not the tree the build job tested ($TREE)" + exit 1 + fi + + # A tag already there is accepted only if an earlier attempt of this + # run pushed it: the same tree, on top of the same source commit. + - name: Push the tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="languages/golang/v$VERSION" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + git fetch -q --no-tags --depth=1 origin "+refs/tags/$tag:refs/existing-release" + existing=$(git rev-parse 'refs/existing-release^{commit}') + parent=$(git cat-file -p "$existing" | sed -n 's/^parent //p') + if [ "$(git rev-parse "$existing^{tree}")" = "$TREE" ] && [ "$parent" = "$GITHUB_SHA" ]; then + echo "$tag was pushed by an earlier attempt of this run; keeping it" + exit 0 + fi + echo "::error::$tag already exists and is not this run's release commit" + exit 1 + fi git tag -a "$tag" -m "Go module v$VERSION (built from $GITHUB_SHA)" - git push origin "refs/tags/$tag" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$tag" - name: Create the GitHub release env: @@ -135,8 +277,12 @@ jobs: run: | set -euo pipefail tag="languages/golang/v$VERSION" - cp packages/eql/release/cipherstash-encrypt-v4.sql eql-v4-install.sql - cp packages/eql/release/cipherstash-encrypt-v4-uninstall.sql eql-v4-uninstall.sql + assets=(release-out/eql-v4-install.sql release-out/eql-v4-uninstall.sql release-out/guests.sha256) + if gh release view "$tag" >/dev/null 2>&1; then + echo "the release for $tag exists from an earlier attempt; replacing its assets" + gh release upload "$tag" --clobber "${assets[@]}" + exit 0 + fi { echo "Go module \`github.com/cipherstash/stack/languages/golang\` v$VERSION, built from $GITHUB_SHA." echo @@ -149,25 +295,60 @@ jobs: echo echo "SHA-256 of the embedded WASI guests:" echo '```' - cat guests.sha256 + cat release-out/guests.sha256 echo '```' } > notes.md prerelease=() if [[ "$VERSION" == *-* ]]; then prerelease=(--prerelease); fi gh release create "$tag" --verify-tag "${prerelease[@]}" \ - --title "Go v$VERSION" --notes-file notes.md \ - eql-v4-install.sql eql-v4-uninstall.sql guests.sha256 + --title "Go v$VERSION" --notes-file notes.md "${assets[@]}" + + verify: + name: Check that Go resolves the version + needs: publish + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Go alone, at the root mise.toml's pin. + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + install_args: go + working_directory: . + cache: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: go-release + path: release-out - # The proxy fetches a version the first time it is asked for it; asking - # here proves the tag resolves as a module before anyone depends on it. - - name: Check that Go resolves the version + # The proxy fetches a version the first time it is asked for it, so + # this also makes the version available before anyone depends on it. + # `go mod download` fetches the module zip itself, which `go list -m` + # does not, and it runs from an empty directory with GOWORK off so + # nothing in this checkout can answer for the proxy. + - name: Download the version through the Go proxy env: GOPROXY: https://proxy.golang.org - GOFLAGS: -mod=mod + GOWORK: "off" + GOFLAGS: "" run: | set -euo pipefail + module=github.com/cipherstash/stack/languages/golang + # guests.sha256 names paths from the repository root; the module + # zip starts at languages/golang. + sed 's# languages/golang/# #' release-out/guests.sha256 > "$RUNNER_TEMP/module.sha256" + cd "$(mktemp -d)" for attempt in 1 2 3 4 5 6; do - if go list -m "github.com/cipherstash/stack/languages/golang@v$VERSION"; then + if dir=$(go mod download -json "$module@v$VERSION" | jq -er .Dir); then + (cd "$dir" && sha256sum -c "$RUNNER_TEMP/module.sha256") exit 0 fi sleep 20 diff --git a/AGENTS.md b/AGENTS.md index dae70f0eb..6edfc0999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre]`): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. Never tag a main commit by hand: `go get` would fetch a module with no guests. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix), and a final version dispatched from anywhere but `main`; a pre-release may come from a branch. The build runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh, and refuses a release commit whose tree is not the one the build tested. A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/SECURITY.md b/SECURITY.md index 821c8fad4..8c44a1d22 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,8 @@ It also carries the source of five Rust crates published to crates.io, **`stack-encrypt-derive`** (`packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`), and of the **Go module** at `languages/golang` (`encrypt` and `auth`, over WASI guests built -from the stack-* crates), which has no release yet. All of these are in scope +from the stack-* crates), released by `.github/workflows/release-golang.yml` +as `languages/golang/vX.Y.Z` tags. All of these are in scope for security reports on the same terms as the npm packages above. The five stack-* crates are licensed under the PolyForm Internal Use License @@ -57,7 +58,10 @@ production service. > `stack-profile`, `stack-kms`, `stack-encrypt` and `stack-encrypt-derive` > crates are published from here by `.github/workflows/release-plz.yml` (the > first version of each of the last three was published by hand, because -> crates.io configures Trusted Publishing on an existing crate). EQL moved here at the Phase 5 cutover in +> crates.io configures Trusted Publishing on an existing crate). The Go module +> is released from here by `.github/workflows/release-golang.yml`, as +> `languages/golang/vX.Y.Z` tags that `go get` fetches through the Go module +> proxy. EQL moved here at the Phase 5 cutover in > `docs/plans/2026-08-13-eql-monorepo-absorption.md`, and the auth packages and > the two crates at the arming PR of the stack-* crates import. Releases made > before those moves were built elsewhere: `@cipherstash/eql@3.0.5` and earlier @@ -222,13 +226,13 @@ We mitigate this by: - Explicitly disabling all caching in every workflow that publishes an artefact — `release.yml`, the reusables it calls (`_build-ffi-artifacts.yml`, - `_build-eql-sql.yml`, `_build-eql-docs.yml`), `release-plz.yml` and - `release-postgres-eql-image.yml` + `_build-eql-sql.yml`, `_build-eql-docs.yml`), `release-plz.yml`, + `release-postgres-eql-image.yml` and `release-golang.yml` - Automated checks for disabled caching on high-risk workflows (`scripts/lint-no-workflow-caching.mjs`), working from an allowlist of audited actions rather than a denylist of cache actions — so an action it has never seen is a finding by default. The cost is accepted: four EQL release jobs - compile Rust with no cache restore. + and the Go release's build job compile Rust with no cache restore. [1]: https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/ diff --git a/scripts/__tests__/eql-suite-ci.test.mjs b/scripts/__tests__/eql-suite-ci.test.mjs index 834048894..ee00032a8 100644 --- a/scripts/__tests__/eql-suite-ci.test.mjs +++ b/scripts/__tests__/eql-suite-ci.test.mjs @@ -1770,7 +1770,7 @@ describe('every job that compiles Rust restores the shared cache', () => { ).toEqual([ '.github/workflows/_build-eql-docs.yml (publish-docs)', '.github/workflows/_build-eql-sql.yml (build)', - '.github/workflows/release-golang.yml (release)', + '.github/workflows/release-golang.yml (build)', '.github/workflows/release-postgres-eql-image.yml (build-sql)', '.github/workflows/release.yml (prerelease-eql-npm)', ]) diff --git a/scripts/__tests__/go-release-version.test.mjs b/scripts/__tests__/go-release-version.test.mjs new file mode 100644 index 000000000..dbec3f55a --- /dev/null +++ b/scripts/__tests__/go-release-version.test.mjs @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + allowedMajors, + goReleaseVersionErrors, + modulePathOf, +} from '../go-release-version.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `release-golang.yml` pushes an immutable tag before `go` resolves it, so a + * version Go refuses must be refused here instead, before the build. + */ + +const MODULE = 'github.com/cipherstash/stack/languages/golang' +const MAIN = 'refs/heads/main' +const BRANCH = 'refs/heads/ci/golang-release' + +const errorsFor = (version, ref = MAIN, modulePath = MODULE) => + goReleaseVersionErrors(version, { modulePath, ref }) + +describe('goReleaseVersionErrors', () => { + it.each([ + '0.1.0', + '1.0.0', + '0.10.20', + '0.1.0-alpha.1', + '0.1.0-rc.0', + '1.2.3-x-y.7.z9', + ])('accepts %s from main', (version) => { + expect(errorsFor(version)).toEqual([]) + }) + + it.each([ + ['v0.1.0', 'leading v'], + ['01.2.3', 'leading zeros'], + ['0.01.0', 'leading zeros'], + ['0.1', 'MAJOR.MINOR.PATCH'], + ['0.1.0.0', 'MAJOR.MINOR.PATCH'], + ['0.1.0-', 'empty pre-release identifier'], + ['0.1.0-alpha..1', 'empty pre-release identifier'], + ['0.1.0-alpha.01', 'leading zero'], + ['0.1.0-al_pha', 'may contain only'], + ['0.1.0+build.1', '+build metadata'], + ['2.0.0', 'major version 0 or 1'], + ['2.0.0-alpha.1', 'major version 0 or 1'], + ])('refuses %s (%s)', (version, reason) => { + const errors = errorsFor(version) + expect(errors.length).toBeGreaterThan(0) + expect(errors.join('\n')).toContain(reason) + }) + + it('lets a pre-release come from a branch', () => { + expect(errorsFor('0.1.0-alpha.1', BRANCH)).toEqual([]) + }) + + it('keeps a final version on main', () => { + expect(errorsFor('0.1.0', BRANCH).join('\n')).toContain( + 'released from main only', + ) + }) + + it('follows a /vN module path to its own major', () => { + expect(errorsFor('2.1.0', MAIN, `${MODULE}/v2`)).toEqual([]) + expect(errorsFor('1.0.0', MAIN, `${MODULE}/v2`).join('\n')).toContain( + 'major version 2', + ) + }) +}) + +describe('allowedMajors', () => { + it('reads the /vN suffix and nothing else', () => { + expect(allowedMajors(MODULE)).toEqual([0, 1]) + expect(allowedMajors(`${MODULE}/v3`)).toEqual([3]) + // `/v1` is not a major-version suffix Go recognises. + expect(allowedMajors(`${MODULE}/v1`)).toEqual([0, 1]) + }) +}) + +describe('the module being released', () => { + it('is the one at languages/golang', () => { + const goMod = readFileSync( + join(REPO_ROOT, 'languages/golang/go.mod'), + 'utf8', + ) + expect(modulePathOf(goMod)).toBe(MODULE) + }) +}) + +describe('the script as the workflow runs it', () => { + const run = (...args) => { + try { + return { + status: 0, + out: execFileSync( + process.execPath, + [join(REPO_ROOT, 'scripts/go-release-version.mjs'), ...args], + { encoding: 'utf8' }, + ), + } + } catch (error) { + return { status: error.status, out: String(error.stdout) } + } + } + + it('exits 0 for a version it accepts', () => { + expect(run('0.1.0-alpha.1', BRANCH).status).toBe(0) + }) + + it('exits 1 with a workflow error annotation for one it refuses', () => { + const { status, out } = run('2.0.0', MAIN) + expect(status).toBe(1) + expect(out).toContain('::error::') + }) + + it('is what release-golang.yml checks the version with, before any build', () => { + // The check is only worth something if it runs before the guests are + // built and long before the tag is pushed. + const steps = readWorkflow('.github/workflows/release-golang.yml').jobs + .build.steps + const check = steps.findIndex((step) => + String(step.run ?? '').includes('scripts/go-release-version.mjs'), + ) + const firstBuild = steps.findIndex((step) => + String(step.run ?? '').includes('mise run'), + ) + expect(check).toBeGreaterThanOrEqual(0) + expect(check).toBeLessThan(firstBuild) + }) +}) diff --git a/scripts/go-release-version.mjs b/scripts/go-release-version.mjs new file mode 100644 index 000000000..799910204 --- /dev/null +++ b/scripts/go-release-version.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Decides whether `.github/workflows/release-golang.yml` may tag a version. +// +// node scripts/go-release-version.mjs +// +// A Go module version is immutable once the proxy has fetched it, and the +// workflow pushes its tag before `go` ever resolves it. So every way Go could +// refuse the version has to be caught here, before anything is built or +// pushed, rather than by the proxy check at the end of the run, after the tag +// exists and the version number is spent. +// +// Three rules: +// +// 1. CANONICAL SEMVER. Go accepts only `vMAJOR.MINOR.PATCH[-PRERELEASE]` with +// no leading zeros in a numeric part or a numeric pre-release identifier, +// no empty identifier, and no `+build` metadata (`v1.0.0+meta` is not +// canonical, and the tag would never be the version `go get` reports). +// 2. MAJOR VERSION MATCHES THE MODULE PATH. A module whose path has no `/vN` +// suffix may only publish v0 and v1; `v2.0.0` at that path is refused with +// "module path must match major version". A path ending `/vN` publishes +// major N only. +// 3. A FINAL VERSION COMES FROM MAIN. `workflow_dispatch` runs on whatever ref +// the dispatcher picks. A pre-release (a version with a `-suffix`) may be +// cut from a branch, which is how a partner alpha is built from an +// unmerged stack; a final version may not. + +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(import.meta.dirname, '..') +const GO_MOD = join(REPO_ROOT, 'languages/golang/go.mod') + +const NUMERIC = /^(0|[1-9][0-9]*)$/ +const IDENTIFIER = /^[0-9A-Za-z-]+$/ + +/** The `module` line of a go.mod. */ +export function modulePathOf(goMod) { + const match = goMod.match(/^module\s+(\S+)\s*$/m) + if (!match) throw new Error('go.mod has no module line') + return match[1] +} + +/** The major version a module path may publish: `/vN` gives N, otherwise 0 or 1. */ +export function allowedMajors(modulePath) { + const suffix = modulePath.match(/\/v([2-9]|[1-9][0-9]+)$/) + return suffix ? [Number(suffix[1])] : [0, 1] +} + +/** + * Every reason `version` may not be released from `ref` for the module at + * `modulePath`. Empty when it may. + */ +export function goReleaseVersionErrors(version, { modulePath, ref }) { + const errors = [] + + if (version.startsWith('v')) { + errors.push(`give the version without the leading v, got '${version}'`) + return errors + } + if (version.includes('+')) { + errors.push( + `'${version}' carries +build metadata, which a Go module version cannot`, + ) + return errors + } + + const dash = version.indexOf('-') + const core = dash === -1 ? version : version.slice(0, dash) + const pre = dash === -1 ? null : version.slice(dash + 1) + + const parts = core.split('.') + if (parts.length !== 3 || !parts.every((part) => NUMERIC.test(part))) { + errors.push( + `'${version}' is not MAJOR.MINOR.PATCH with no leading zeros (e.g. 0.1.0 or 0.1.0-alpha.1)`, + ) + return errors + } + + if (pre !== null) { + const ids = pre.split('.') + for (const id of ids) { + if (id === '') { + errors.push(`'${version}' has an empty pre-release identifier`) + } else if (!IDENTIFIER.test(id)) { + errors.push( + `pre-release identifier '${id}' may contain only [0-9A-Za-z-]`, + ) + } else if (/^[0-9]+$/.test(id) && !NUMERIC.test(id)) { + errors.push(`numeric pre-release identifier '${id}' has a leading zero`) + } + } + } + + const major = Number(parts[0]) + const majors = allowedMajors(modulePath) + if (!majors.includes(major)) { + errors.push( + `module ${modulePath} can only publish major version ${majors.join(' or ')}, got ${major}` + + (majors.length > 1 + ? '; a v2+ release needs a /vN suffix on the module path first' + : ''), + ) + } + + if (pre === null && ref !== 'refs/heads/main') { + errors.push( + `a final version is released from main only, this run is on ${ref}; dispatch from main, or give a pre-release version such as ${core}-alpha.1`, + ) + } + + return errors +} + +function main() { + const [version, ref] = process.argv.slice(2) + if (!version || !ref) { + console.error('usage: go-release-version.mjs ') + process.exit(2) + } + const modulePath = modulePathOf(readFileSync(GO_MOD, 'utf8')) + const errors = goReleaseVersionErrors(version, { modulePath, ref }) + for (const error of errors) console.log(`::error::${error}`) + if (errors.length > 0) process.exit(1) + console.log(`${modulePath} v${version} may be released from ${ref}`) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main() From 0fe816a7f5a5dc43fc672cc1be95c81546a6ddbc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 23:26:32 +1100 Subject: [PATCH 3/6] docs(golang): give the Go module its own MIT licence file Go puts a licence in a module's zip only from a file named exactly LICENSE, in the module directory or at the repository root, and the root file here is LICENSE.md. So every release zip went out with no licence at all, and pkg.go.dev would not show the module's documentation. languages/golang/LICENSE is the repository's MIT text. The encrypt and auth READMEs now say that the WASI guests the module embeds are compiled from the stack-* crates, which stay under the PolyForm Internal Use License. Refs #1034. --- languages/golang/LICENSE | 21 +++++++++++++++++++++ languages/golang/auth/README.md | 11 +++++++++++ languages/golang/encrypt/README.md | 12 ++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 languages/golang/LICENSE diff --git a/languages/golang/LICENSE b/languages/golang/LICENSE new file mode 100644 index 000000000..eb1c0cbdb --- /dev/null +++ b/languages/golang/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CipherStash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/languages/golang/auth/README.md b/languages/golang/auth/README.md index 79c6d60eb..23ceb9b02 100644 --- a/languages/golang/auth/README.md +++ b/languages/golang/auth/README.md @@ -119,3 +119,14 @@ mise run go:test # the whole Go module, both packages The guest module is embedded from `wasm/` and not committed; without it, `Open` returns `ErrGuestNotBuilt` and the tests skip. + +## Licence + +The Go code in this module is MIT-licensed ([`LICENSE`](../LICENSE)). The +credential guest it embeds is compiled from Rust crates that are not: +`stack-auth`, `stack-profile` and `stack-guest-abi` are licensed under the +[PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). +Those terms allow use for your internal business operations only. Each +crate's `LICENSE` in the +[CipherStash Stack repository](https://github.com/cipherstash/stack/tree/main/packages) +has the full text. diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index bc4a3e155..3d608770c 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -293,3 +293,15 @@ credential guest) before `go test`, and `mise run wasm:guest:build:deterministic` for the test build the hermetic round-trip and fixture tests use. Without the builds `NewClient` returns `ErrGuestNotBuilt` and the tests skip. + +## Licence + +The Go code in this module is MIT-licensed ([`LICENSE`](../LICENSE)). The +WASI guests it embeds are compiled from Rust crates that are not: the +stack-encrypt guest and its EQL build are compiled from `stack-encrypt`, +`stack-kms`, `stack-auth` and `stack-guest-abi`, which are licensed under the +[PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). +Those terms allow use for your internal business operations only. Each +crate's `LICENSE` in the +[CipherStash Stack repository](https://github.com/cipherstash/stack/tree/main/packages) +has the full text. From e331c23ac3c7e6f635086ac895e804976aad3970 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 12 Oct 2026 23:53:39 +1100 Subject: [PATCH 4/6] test(ci): hold the Go release's non-publishing jobs read-only workflow-publish-permissions.test.mjs found publishing workflows by their `id-token: write` job, and the Go release publishes by pushing a tag with `contents: write`, no OIDC. So the test never looked at it, and `build` or `verify` could have been given write access with the suite still green. TAG_PUBLISHING_WORKFLOWS names it as a publishing workflow, and `release-golang.yml / publish` joins REPO_WRITE_JOBS as the one job there that may write. A new check keeps the list honest: each entry must be a real workflow with a sanctioned writer. The encrypt README's licence note now names stack-profile, which the encrypt guest compiles in through stack-auth. Refs #1034. --- languages/golang/encrypt/README.md | 3 +- .../workflow-publish-permissions.test.mjs | 51 +++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 3d608770c..75305e5e0 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -299,7 +299,8 @@ round-trip and fixture tests use. Without the builds `NewClient` returns The Go code in this module is MIT-licensed ([`LICENSE`](../LICENSE)). The WASI guests it embeds are compiled from Rust crates that are not: the stack-encrypt guest and its EQL build are compiled from `stack-encrypt`, -`stack-kms`, `stack-auth` and `stack-guest-abi`, which are licensed under the +`stack-kms`, `stack-auth`, `stack-profile` (a dependency of `stack-auth`) and +`stack-guest-abi`, which are licensed under the [PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). Those terms allow use for your internal business operations only. Each crate's `LICENSE` in the diff --git a/scripts/__tests__/workflow-publish-permissions.test.mjs b/scripts/__tests__/workflow-publish-permissions.test.mjs index 695963c31..c133c67f3 100644 --- a/scripts/__tests__/workflow-publish-permissions.test.mjs +++ b/scripts/__tests__/workflow-publish-permissions.test.mjs @@ -81,6 +81,18 @@ const NON_PUBLISH_OIDC_JOBS = [ const OIDC_JOBS = [...PUBLISH_OIDC_JOBS, ...NON_PUBLISH_OIDC_JOBS] +/** + * Workflows that publish with no OIDC at all, so the `id-token: write` marker + * the checks below key on never finds them. + * + * The Go module is published by pushing a tag: `go get` fetches a version as + * the files committed at `languages/golang/vX.Y.Z`, and the proxy makes it + * immutable once fetched. The credential is `contents: write`, and the same + * rule applies — only the job that pushes may hold it, so the jobs that run + * the build (third-party code) and the proxy check stay read-only. + */ +const TAG_PUBLISHING_WORKFLOWS = ['.github/workflows/release-golang.yml'] + /** * The jobs in a publishing workflow that may hold ANY writable scope. A * superset of `OIDC_JOBS`, asserted as such below. @@ -113,6 +125,9 @@ const REPO_WRITE_JOBS = [ // pull-requests: write — posts and updates the sticky cargo-mutants report // comment on the PR. '.github/workflows/mutants.yml / mutants', + // contents: write — pushes the languages/golang/vX.Y.Z tag and creates the + // GitHub release. Publishing for the Go module; see TAG_PUBLISHING_WORKFLOWS. + '.github/workflows/release-golang.yml / publish', ] /** @@ -223,13 +238,15 @@ describe('supply chain — a publishing workflow grants OIDC per job', () => { // without being classified as a known non-publishing exchange. The // second disjunct matters: an unsanctioned publisher must not also switch // this check off for the file it is in. - .filter(({ file, workflowLevel, jobs }) => - jobs.some( - ([name, job]) => - sanctioned(file, name) || - (effective(job, workflowLevel)?.['id-token'] === 'write' && - !nonPublishingOidc(file, name)), - ), + .filter( + ({ file, workflowLevel, jobs }) => + TAG_PUBLISHING_WORKFLOWS.includes(file) || + jobs.some( + ([name, job]) => + sanctioned(file, name) || + (effective(job, workflowLevel)?.['id-token'] === 'write' && + !nonPublishingOidc(file, name)), + ), ) .flatMap(({ file, workflowLevel, jobs }) => jobs @@ -247,6 +264,19 @@ describe('supply chain — a publishing workflow grants OIDC per job', () => { ).toEqual([]) }) + it('names only tag-publishing workflows that exist and have a sanctioned writer', () => { + // A stale entry would switch the read-only check off for nothing, and a + // renamed workflow would silently drop out of it. + const files = new Set(workflows.map(({ file }) => file)) + for (const file of TAG_PUBLISHING_WORKFLOWS) { + expect(files.has(file), `${file} is not a workflow`).toBe(true) + expect( + REPO_WRITE_JOBS.some((entry) => entry.startsWith(`${file} / `)), + `${file} publishes, so one of its jobs must be in REPO_WRITE_JOBS`, + ).toBe(true) + } + }) + it('never classifies an OIDC holder without sanctioning its writes', () => { // The one way the split above could go wrong: an OIDC holder added to the // classified set and not carried into `REPO_WRITE_JOBS`. It is spelled as @@ -264,9 +294,10 @@ describe('supply chain — a publishing workflow grants OIDC per job', () => { // to the REPOSITORY default, which is settings-controlled and outside this // tree. A publishing workflow must not have its floor set somewhere a // reviewer of this repo cannot see. - const publishing = new Set( - PUBLISH_OIDC_JOBS.map((entry) => entry.split(' / ')[0]), - ) + const publishing = new Set([ + ...PUBLISH_OIDC_JOBS.map((entry) => entry.split(' / ')[0]), + ...TAG_PUBLISHING_WORKFLOWS, + ]) const offenders = workflows .filter( ({ file, workflowLevel }) => From 7204fae59914ee44c8e3a7fbb23cb30585f8bcd8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 00:06:46 +1100 Subject: [PATCH 5/6] ci(golang): take the release's guest paths from the workflow, not the artifact The publish job read the paths it committed from the artifact's guests.sha256. The build job writes that manifest before it runs third-party code (the deterministic guest builds and the Go tests), so a compromise there could add a line naming any file, ship the file in the artifact, and have publish commit it to the tag with the write token. The tree check would not catch it, because build derives the tree it reports from the same manifest. The three guest paths now live in a workflow-level GUESTS list that every job reads. publish refuses a manifest that is not exactly one SHA-256 line for each of them, in order, before it copies anything. go-release-artifact.test.mjs runs the real publish step against forged manifests: an extra, substituted or out-of-module path, a missing or reordered guest, and a BSD-format line that sha256sum -c also accepts. --- .github/workflows/release-golang.yml | 50 +++- AGENTS.md | 2 +- .../__tests__/go-release-artifact.test.mjs | 217 ++++++++++++++++++ 3 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 scripts/__tests__/go-release-artifact.test.mjs diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index 9b56843e7..a56139e74 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -53,6 +53,17 @@ on: permissions: contents: read +env: + # The only files a release commit adds, in the order guests.sha256 lists + # them. Every job takes the paths from here and never from the artifact: + # guests.sha256 is written before the build job runs third-party code, so + # `publish` checks it names exactly these and nothing else + # (scripts/__tests__/go-release-artifact.test.mjs). + GUESTS: >- + languages/golang/encrypt/wasm/stack_encrypt_guest.wasm + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm + languages/golang/auth/wasm/stack_auth_guest.wasm + concurrency: group: release-golang cancel-in-progress: false @@ -142,11 +153,8 @@ jobs: run: | set -euo pipefail mkdir -p release-out - sha256sum \ - languages/golang/encrypt/wasm/stack_encrypt_guest.wasm \ - languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm \ - languages/golang/auth/wasm/stack_auth_guest.wasm \ - > release-out/guests.sha256 + read -ra guests <<< "$GUESTS" + sha256sum "${guests[@]}" > release-out/guests.sha256 cat release-out/guests.sha256 # The deterministic test builds the hermetic Go tests load. @@ -173,7 +181,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout --detach # The guests are gitignored on main; -f adds them to this commit only. - mapfile -t guests < <(cut -d' ' -f3 release-out/guests.sha256) + read -ra guests <<< "$GUESTS" git add -f "${guests[@]}" git commit -q -m "release(golang): v$VERSION with its WASI guests" tree=$(git rev-parse 'HEAD^{tree}') @@ -189,9 +197,10 @@ jobs: - name: Stage the release files run: | set -euo pipefail - while read -r _ guest; do + read -ra guests <<< "$GUESTS" + for guest in "${guests[@]}"; do install -D -m 0644 "$guest" "release-out/$guest" - done < release-out/guests.sha256 + done cp packages/eql/release/cipherstash-encrypt-v4.sql release-out/eql-v4-install.sql cp packages/eql/release/cipherstash-encrypt-v4-uninstall.sql release-out/eql-v4-uninstall.sql find release-out -type f | sort @@ -229,17 +238,36 @@ jobs: name: go-release path: release-out + # The artifact comes from a job that ran third-party code after it wrote + # guests.sha256, so the manifest is data to check, not a list of paths + # to trust: a line naming any other file would put that file in the + # release commit, and the tree comparison below would not notice, + # because the build job derived its tree from the same manifest. The + # paths come from GUESTS; the manifest must be exactly one SHA-256 line + # for each, in order, before anything is copied. - name: Commit the guests run: | set -euo pipefail - while read -r _ guest; do + read -ra guests <<< "$GUESTS" + mapfile -t lines < release-out/guests.sha256 + if [ "${#lines[@]}" -ne "${#guests[@]}" ]; then + echo "::error::guests.sha256 has ${#lines[@]} lines, not one for each of the ${#guests[@]} guests" + exit 1 + fi + line_re='^[0-9a-f]{64} (.+)$' + for i in "${!guests[@]}"; do + if ! [[ "${lines[$i]}" =~ $line_re ]] || [ "${BASH_REMATCH[1]}" != "${guests[$i]}" ]; then + echo "::error::guests.sha256 line $((i + 1)) is not the SHA-256 of ${guests[$i]}" + exit 1 + fi + done + for guest in "${guests[@]}"; do install -D -m 0644 "release-out/$guest" "$guest" - done < release-out/guests.sha256 + done sha256sum -c release-out/guests.sha256 git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout --detach - mapfile -t guests < <(cut -d' ' -f3 release-out/guests.sha256) git add -f "${guests[@]}" git commit -m "release(golang): v$VERSION with its WASI guests" \ -m "Built from $GITHUB_SHA by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" diff --git a/AGENTS.md b/AGENTS.md index 6edfc0999..0f3bb3c93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix), and a final version dispatched from anywhere but `main`; a pre-release may come from a branch. The build runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh, and refuses a release commit whose tree is not the one the build tested. A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix), and a final version dispatched from anywhere but `main`; a pre-release may come from a branch. The build runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/scripts/__tests__/go-release-artifact.test.mjs b/scripts/__tests__/go-release-artifact.test.mjs new file mode 100644 index 000000000..893658460 --- /dev/null +++ b/scripts/__tests__/go-release-artifact.test.mjs @@ -0,0 +1,217 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `release-golang.yml`'s `publish` job holds `contents: write` and builds the + * release commit from an artifact made by `build`, the job that runs + * third-party code (cargo build scripts, proc macros, the Go tests) AFTER it + * writes `guests.sha256`. So the manifest is untrusted input to `publish`. If + * `publish` took its paths from the manifest, a build-time compromise could + * add a line for, say, `languages/golang/encrypt/init.go` and ship it on the + * tag; the tree comparison would pass, because `build` derived the tree it + * reports from the same manifest. + * + * These tests run the real "Commit the guests" step against forged artifacts. + */ + +const WORKFLOW = readWorkflow('.github/workflows/release-golang.yml') +const GUESTS = String(WORKFLOW.env.GUESTS).split(/\s+/).filter(Boolean) +const COMMIT_STEP = WORKFLOW.jobs.publish.steps.find( + (step) => step.name === 'Commit the guests', +) + +const git = (cwd, ...args) => + execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() + +/** + * The step runs on Linux, where `install -D` is GNU coreutils. macOS ships BSD + * `install`, which has no `-D`; a Homebrew coreutils install provides GNU's as + * `ginstall`, and the step sees it as `install` through a shim directory. + * Without either, the commit case cannot run here and is skipped; the refusal + * cases stop before `install` and run everywhere. + */ +const GNU_INSTALL = ['install', 'ginstall'].find((name) => { + const probe = spawnSync(name, ['--version'], { encoding: 'utf8' }) + return probe.status === 0 && probe.stdout.includes('GNU coreutils') +}) +const SHIMS = mkdtempSync(join(tmpdir(), 'go-release-artifact-bin-')) +if (GNU_INSTALL === 'ginstall') { + const path = execFileSync('bash', ['-c', 'command -v ginstall'], { + encoding: 'utf8', + }).trim() + symlinkSync(path, join(SHIMS, 'install')) +} + +afterAll(() => { + rmSync(SHIMS, { recursive: true, force: true }) +}) + +const sha256 = (cwd, path) => + execFileSync('sha256sum', [path], { cwd, encoding: 'utf8' }).trim() + +let repo + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'go-release-artifact-')) + git(repo, 'init', '-q', '-b', 'main') + git(repo, 'config', 'user.name', 'test') + git(repo, 'config', 'user.email', 'test@example.com') + git(repo, 'config', 'commit.gpgsign', 'false') + writeFileSync(join(repo, '.gitignore'), '*.wasm\n') + git(repo, 'add', '.gitignore') + git(repo, 'commit', '-q', '-m', 'source') +}) + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }) +}) + +/** Put `path` in the artifact with `body`. Returns its manifest line. */ +function stage(path, body) { + const at = join(repo, 'release-out', path) + mkdirSync(dirname(at), { recursive: true }) + writeFileSync(at, body) + return sha256(join(repo, 'release-out'), path) +} + +/** The honest manifest: one line per guest, in GUESTS order. */ +function honestManifest() { + return GUESTS.map((guest) => stage(guest, `guest ${guest}\n`)) +} + +function writeManifest(lines) { + writeFileSync( + join(repo, 'release-out/guests.sha256'), + `${lines.join('\n')}\n`, + ) +} + +/** The tree the release commit should have: HEAD plus the three guests. */ +function expectedTree() { + const index = join(repo, '.git', 'expected-index') + const env = { ...process.env, GIT_INDEX_FILE: index } + execFileSync('git', ['read-tree', 'HEAD'], { cwd: repo, env }) + for (const guest of GUESTS) { + const blob = git(repo, 'hash-object', '-w', join('release-out', guest)) + execFileSync( + 'git', + ['update-index', '--add', '--cacheinfo', `100644,${blob},${guest}`], + { cwd: repo, env }, + ) + } + return execFileSync('git', ['write-tree'], { + cwd: repo, + env, + encoding: 'utf8', + }).trim() +} + +function runCommitStep(tree) { + return spawnSync('bash', ['-c', String(COMMIT_STEP.run)], { + cwd: repo, + encoding: 'utf8', + env: { + PATH: `${SHIMS}:${process.env.PATH}`, + HOME: repo, + GIT_CONFIG_NOSYSTEM: '1', + GUESTS: WORKFLOW.env.GUESTS, + VERSION: '0.1.0-alpha.1', + TREE: tree, + GITHUB_SHA: git(repo, 'rev-parse', 'HEAD'), + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'cipherstash/stack', + GITHUB_RUN_ID: '1', + }, + }) +} + +describe('release-golang.yml publish: Commit the guests', () => { + it('exists, and the publish job takes its paths from GUESTS', () => { + expect(COMMIT_STEP).toBeDefined() + expect(GUESTS).toHaveLength(3) + expect(String(COMMIT_STEP.run)).toContain('"$GUESTS"') + }) + + it.skipIf(!GNU_INSTALL)( + 'commits the three guests from an honest artifact', + () => { + writeManifest(honestManifest()) + const tree = expectedTree() + const result = runCommitStep(tree) + expect(result.stderr + result.stdout).not.toContain('::error::') + expect(result.status).toBe(0) + expect(git(repo, 'rev-parse', 'HEAD^{tree}')).toBe(tree) + expect( + git(repo, 'show', '--name-only', '--format=', 'HEAD').split('\n'), + ).toEqual([...GUESTS].sort()) + }, + ) + + it.each([ + [ + 'an extra path', + (lines) => [ + ...lines, + stage('languages/golang/encrypt/init.go', 'package encrypt\n'), + ], + ], + [ + 'a substituted path', + (lines) => [ + lines[0], + lines[1], + stage('languages/golang/auth/init.go', 'package auth\n'), + ], + ], + [ + 'a path outside the module', + (lines) => [ + lines[0], + lines[1], + stage('.github/workflows/x.yml', 'on: push\n'), + ], + ], + ['a missing guest', (lines) => lines.slice(0, 2)], + ['the guests out of order', (lines) => [lines[1], lines[0], lines[2]]], + [ + 'a BSD-format line sha256sum -c would also accept', + (lines) => { + const [hash, path] = lines[2].split(' ') + return [lines[0], lines[1], `SHA256 (${path}) = ${hash}`] + }, + ], + ])('refuses a manifest with %s before copying anything', (_, forge) => { + const honest = honestManifest() + // The tree a compromised build would report for its forged manifest is + // whatever it likes; give the step the honest tree so only the manifest + // check can stop it. + const tree = expectedTree() + const head = git(repo, 'rev-parse', 'HEAD') + writeManifest(forge(honest)) + + const result = runCommitStep(tree) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::guests.sha256') + expect(git(repo, 'rev-parse', 'HEAD')).toBe(head) + for (const path of [ + ...GUESTS, + 'languages/golang/encrypt/init.go', + 'languages/golang/auth/init.go', + '.github/workflows/x.yml', + ]) { + expect(existsSync(join(repo, path)), path).toBe(false) + } + }) +}) From 556f239f53b85e789c35dbd43166e068c5f2679b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 00:25:59 +1100 Subject: [PATCH 6/6] ci(golang): close the remaining gaps before a release tag is pushed - Refuse a version the Go proxy already lists. The tag check misses a tag deleted after the proxy fetched it, and a new tag at that number would fail every user's checksum. The check reads @v/list only: asking for the version before its tag exists makes the proxy cache "not found" for up to 30 minutes, which would fail `verify` after the push. - Refuse a version shaped like a Go pseudo-version. Go checks such a tag against a commit and timestamp rather than reading it as a tag, so it would fail only in `verify`, after the push. The pattern is golang.org/x/mod/module's, and the test cases were checked against module.IsPseudoVersion. - Check packages/eql's Cargo.lock with --locked alongside the guests', and before the EQL build, whose `cargo run`s pass no --locked. - Fetch the commit in `publish` with unauthenticated git instead of actions/checkout, whose token input defaults to the job's writable token even with persist-credentials: false. - Make go-release-artifact.test.mjs portable: fixture hashes come from node:crypto, GNU install and sha256sum are shimmed from Homebrew's g* names when present, and the suite skips on a bash without mapfile. - Say in the encrypt and auth READMEs that a tagged version carries the WASI guests and a pseudo-version or checkout does not. --- .github/workflows/release-golang.yml | 56 +++-- AGENTS.md | 2 +- languages/golang/auth/README.md | 9 +- languages/golang/encrypt/README.md | 14 +- .../__tests__/go-release-artifact.test.mjs | 207 ++++++++++-------- .../__tests__/go-release-proxy-check.test.mjs | 111 ++++++++++ scripts/__tests__/go-release-version.test.mjs | 9 + scripts/go-release-version.mjs | 20 +- 8 files changed, 317 insertions(+), 111 deletions(-) create mode 100644 scripts/__tests__/go-release-proxy-check.test.mjs diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index a56139e74..59a325f08 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -99,6 +99,27 @@ jobs: exit 1 fi + # The tag check misses a version whose tag was deleted after the proxy + # fetched it. The proxy goes on serving that version and sum.golang.org + # keeps its hash, so a new tag at the same number would fail every + # user's checksum. This asks @v/list, which the proxy has been seen to + # go on listing a deleted tag in (golang/go#44306), though it does not + # promise to. It never asks for the version itself (@v/.info, + # `go list -m`): asked before the tag exists, the proxy caches "not + # found" for up to 30 minutes, and `verify` would fail after the push. + # Here and not in `publish`, so a re-run of `publish` that finds its own + # earlier tag is not refused. + - name: Refuse a version the Go proxy already lists + run: | + set -euo pipefail + module=github.com/cipherstash/stack/languages/golang + list=$(curl -fsS --retry 3 "https://proxy.golang.org/$module/@v/list") \ + || { echo "::error::could not read the Go proxy's version list for $module"; exit 1; } + if grep -qxF "v$VERSION" <<< "$list"; then + echo "::error::the Go proxy already lists $module v$VERSION; its contents are fixed even though the tag is gone. Release a new version." + exit 1 + fi + # No cache: this job builds the binaries that are published, and a # poisoned restore would ship in them. scripts/lint-no-workflow-caching.mjs # holds it. @@ -112,6 +133,17 @@ jobs: - name: Add rustfmt and clippy to the pinned toolchain run: rustup component add rustfmt clippy + # Neither the guest builds nor the EQL build (two plain `cargo run`s in + # packages/eql/tasks/build.sh) pass --locked, and a stale lock would be + # resolved afresh here into guests or SQL nobody can rebuild from the + # tag. Before the EQL build, so it runs first of all the cargo work. + - name: Check the Cargo lockfiles are current + run: | + set -euo pipefail + for workspace in packages/eql languages/golang/encrypt/guest languages/golang/auth/guest; do + cargo metadata --locked --format-version 1 --manifest-path "$workspace/Cargo.toml" > /dev/null + done + # First of the builds because it is the cheap one that fails on a ref # without the v4 bundle. eql_v4.version() reports `go-`: the # bundle is released with this Go version, not as an @cipherstash/eql @@ -132,15 +164,6 @@ jobs: grep -qF "'go-$VERSION'" release/cipherstash-encrypt-v4.sql \ || { echo "::error::the v4 bundle is not stamped go-$VERSION"; exit 1; } - # The guest builds pass no --locked, and a stale lock would be resolved - # afresh here into a module nobody can rebuild from the tag. - - name: Check the guests' lockfiles are current - run: | - set -euo pipefail - for guest in languages/golang/encrypt/guest languages/golang/auth/guest; do - cargo metadata --locked --format-version 1 --manifest-path "$guest/Cargo.toml" > /dev/null - done - - name: Build the guests run: | mise run wasm:guest:build @@ -227,11 +250,18 @@ jobs: VERSION: ${{ inputs.version }} TREE: ${{ needs.build.outputs.tree }} steps: - # No credential in .git/config: the token is handed to the one push + # Not actions/checkout: its `token` input defaults to github.token, + # which in this job can write, and persist-credentials: false only stops + # it being left in .git/config. The repository is public, so the commit + # is fetched without a credential. The token is handed to the one push # below, and to gh, and to nothing else. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false + - name: Fetch the commit without a credential + run: | + set -euo pipefail + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + git fetch -q --no-tags --depth=1 origin "$GITHUB_SHA" + git checkout -q --detach FETCH_HEAD - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: diff --git a/AGENTS.md b/AGENTS.md index 0f3bb3c93..5df839fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix), and a final version dispatched from anywhere but `main`; a pre-release may come from a branch. The build runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), and a final version dispatched from anywhere but `main`; a pre-release may come from a branch. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/languages/golang/auth/README.md b/languages/golang/auth/README.md index 23ceb9b02..553bed0c3 100644 --- a/languages/golang/auth/README.md +++ b/languages/golang/auth/README.md @@ -117,8 +117,13 @@ mise run wasm:auth-guest:build # the guest, with its import-surface gate mise run go:test # the whole Go module, both packages ``` -The guest module is embedded from `wasm/` and not committed; without it, -`Open` returns `ErrGuestNotBuilt` and the tests skip. +The guest module is embedded from `wasm/`. A tagged version of the module, +as `go get` resolves it, carries it: the release workflow commits it to the +commit the tag names, so nothing needs building. A pseudo-version +(`go get ...@main`, or any commit rather than a tag) does not, and neither +does a checkout of this repository, where the guest is not committed and the +commands above build it. Without it, `Open` returns `ErrGuestNotBuilt` and +the tests skip. ## Licence diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 75305e5e0..6f8f51904 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -20,6 +20,9 @@ The package reference is on [pkg.go.dev]; the generator's reference is go get -tool github.com/cipherstash/stack/languages/golang/cmd/stashgen ``` + This resolves to the latest tagged version, which carries the engine + builds the module embeds; see [Building](#building). + 2. Put a `stash` tag on every exported field of the struct, and a `go:generate` comment beside it. @@ -287,7 +290,16 @@ column. ## Building The package embeds `wasm/stack_encrypt_guest.wasm`, a build artefact of the -Rust crate in [`guest/`](guest/). It is not committed: run +Rust crate in [`guest/`](guest/), and package `encrypt/eql` embeds the EQL +build beside it. A tagged version of the module (such as `v0.1.0-alpha.1`, as +`go get` resolves it) carries both builds and the credential guest: the +release workflow commits them to the commit the tag names, so a module that +requires a tagged version needs no Rust toolchain and no build step. A +pseudo-version does not. `go get ...@main`, or any commit rather than a +tag, fetches a tree without the builds; it compiles, and `NewClient` returns +`ErrGuestNotBuilt`. + +In a checkout of this repository the builds are not committed: run `mise run wasm:guest:build` (and `mise run wasm:auth-guest:build` for the credential guest) before `go test`, and `mise run wasm:guest:build:deterministic` for the test build the hermetic diff --git a/scripts/__tests__/go-release-artifact.test.mjs b/scripts/__tests__/go-release-artifact.test.mjs index 893658460..6129de716 100644 --- a/scripts/__tests__/go-release-artifact.test.mjs +++ b/scripts/__tests__/go-release-artifact.test.mjs @@ -1,8 +1,10 @@ import { execFileSync, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -35,30 +37,48 @@ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() /** - * The step runs on Linux, where `install -D` is GNU coreutils. macOS ships BSD - * `install`, which has no `-D`; a Homebrew coreutils install provides GNU's as - * `ginstall`, and the step sees it as `install` through a shim directory. - * Without either, the commit case cannot run here and is skipped; the refusal - * cases stop before `install` and run everywhere. + * The step runs on Linux under bash 5 with GNU coreutils. Two of those are not + * a given on a developer's machine: + * + * - `mapfile` is bash 4 and later. macOS's own /bin/bash is 3.2, so the suite + * is skipped when the `bash` on PATH has no `mapfile`; every case needs it. + * - `install -D` and `sha256sum -c` are GNU's. macOS's `install` has no `-D`. + * Homebrew coreutils provides GNU's as `ginstall` and `gsha256sum`, and the + * step sees them under their own names through a shim directory. Only the + * commit case reaches either; it is skipped without them. The refusal cases + * stop before both. + * + * Fixture hashes come from node:crypto, so writing a manifest needs neither. */ -const GNU_INSTALL = ['install', 'ginstall'].find((name) => { - const probe = spawnSync(name, ['--version'], { encoding: 'utf8' }) - return probe.status === 0 && probe.stdout.includes('GNU coreutils') -}) +const BASH_HAS_MAPFILE = + spawnSync('bash', ['-c', 'mapfile -t lines < /dev/null']).status === 0 + const SHIMS = mkdtempSync(join(tmpdir(), 'go-release-artifact-bin-')) -if (GNU_INSTALL === 'ginstall') { - const path = execFileSync('bash', ['-c', 'command -v ginstall'], { - encoding: 'utf8', - }).trim() - symlinkSync(path, join(SHIMS, 'install')) + +/** Put GNU's `tool` on the shim PATH. False when there is none. */ +function shimGnu(tool) { + for (const name of [tool, `g${tool}`]) { + const probe = spawnSync(name, ['--version'], { encoding: 'utf8' }) + if (probe.status !== 0 || !probe.stdout.includes('GNU coreutils')) continue + if (name !== tool) { + const path = execFileSync('bash', ['-c', `command -v ${name}`], { + encoding: 'utf8', + }).trim() + symlinkSync(path, join(SHIMS, tool)) + } + return true + } + return false } +const GNU_COREUTILS = [shimGnu('install'), shimGnu('sha256sum')].every(Boolean) afterAll(() => { rmSync(SHIMS, { recursive: true, force: true }) }) -const sha256 = (cwd, path) => - execFileSync('sha256sum', [path], { cwd, encoding: 'utf8' }).trim() +/** A `sha256sum` line: the hex digest, two spaces, the path. */ +const sha256Line = (file, path) => + `${createHash('sha256').update(readFileSync(file)).digest('hex')} ${path}` let repo @@ -82,7 +102,7 @@ function stage(path, body) { const at = join(repo, 'release-out', path) mkdirSync(dirname(at), { recursive: true }) writeFileSync(at, body) - return sha256(join(repo, 'release-out'), path) + return sha256Line(at, path) } /** The honest manifest: one line per guest, in GUESTS order. */ @@ -136,82 +156,85 @@ function runCommitStep(tree) { }) } -describe('release-golang.yml publish: Commit the guests', () => { - it('exists, and the publish job takes its paths from GUESTS', () => { - expect(COMMIT_STEP).toBeDefined() - expect(GUESTS).toHaveLength(3) - expect(String(COMMIT_STEP.run)).toContain('"$GUESTS"') - }) - - it.skipIf(!GNU_INSTALL)( - 'commits the three guests from an honest artifact', - () => { - writeManifest(honestManifest()) - const tree = expectedTree() - const result = runCommitStep(tree) - expect(result.stderr + result.stdout).not.toContain('::error::') - expect(result.status).toBe(0) - expect(git(repo, 'rev-parse', 'HEAD^{tree}')).toBe(tree) - expect( - git(repo, 'show', '--name-only', '--format=', 'HEAD').split('\n'), - ).toEqual([...GUESTS].sort()) - }, - ) +describe.skipIf(!BASH_HAS_MAPFILE)( + 'release-golang.yml publish: Commit the guests', + () => { + it('exists, and the publish job takes its paths from GUESTS', () => { + expect(COMMIT_STEP).toBeDefined() + expect(GUESTS).toHaveLength(3) + expect(String(COMMIT_STEP.run)).toContain('"$GUESTS"') + }) + + it.skipIf(!GNU_COREUTILS)( + 'commits the three guests from an honest artifact', + () => { + writeManifest(honestManifest()) + const tree = expectedTree() + const result = runCommitStep(tree) + expect(result.stderr + result.stdout).not.toContain('::error::') + expect(result.status).toBe(0) + expect(git(repo, 'rev-parse', 'HEAD^{tree}')).toBe(tree) + expect( + git(repo, 'show', '--name-only', '--format=', 'HEAD').split('\n'), + ).toEqual([...GUESTS].sort()) + }, + ) - it.each([ - [ - 'an extra path', - (lines) => [ - ...lines, - stage('languages/golang/encrypt/init.go', 'package encrypt\n'), + it.each([ + [ + 'an extra path', + (lines) => [ + ...lines, + stage('languages/golang/encrypt/init.go', 'package encrypt\n'), + ], ], - ], - [ - 'a substituted path', - (lines) => [ - lines[0], - lines[1], - stage('languages/golang/auth/init.go', 'package auth\n'), + [ + 'a substituted path', + (lines) => [ + lines[0], + lines[1], + stage('languages/golang/auth/init.go', 'package auth\n'), + ], ], - ], - [ - 'a path outside the module', - (lines) => [ - lines[0], - lines[1], - stage('.github/workflows/x.yml', 'on: push\n'), + [ + 'a path outside the module', + (lines) => [ + lines[0], + lines[1], + stage('.github/workflows/x.yml', 'on: push\n'), + ], ], - ], - ['a missing guest', (lines) => lines.slice(0, 2)], - ['the guests out of order', (lines) => [lines[1], lines[0], lines[2]]], - [ - 'a BSD-format line sha256sum -c would also accept', - (lines) => { - const [hash, path] = lines[2].split(' ') - return [lines[0], lines[1], `SHA256 (${path}) = ${hash}`] - }, - ], - ])('refuses a manifest with %s before copying anything', (_, forge) => { - const honest = honestManifest() - // The tree a compromised build would report for its forged manifest is - // whatever it likes; give the step the honest tree so only the manifest - // check can stop it. - const tree = expectedTree() - const head = git(repo, 'rev-parse', 'HEAD') - writeManifest(forge(honest)) - - const result = runCommitStep(tree) - - expect(result.status).not.toBe(0) - expect(result.stdout).toContain('::error::guests.sha256') - expect(git(repo, 'rev-parse', 'HEAD')).toBe(head) - for (const path of [ - ...GUESTS, - 'languages/golang/encrypt/init.go', - 'languages/golang/auth/init.go', - '.github/workflows/x.yml', - ]) { - expect(existsSync(join(repo, path)), path).toBe(false) - } - }) -}) + ['a missing guest', (lines) => lines.slice(0, 2)], + ['the guests out of order', (lines) => [lines[1], lines[0], lines[2]]], + [ + 'a BSD-format line sha256sum -c would also accept', + (lines) => { + const [hash, path] = lines[2].split(' ') + return [lines[0], lines[1], `SHA256 (${path}) = ${hash}`] + }, + ], + ])('refuses a manifest with %s before copying anything', (_, forge) => { + const honest = honestManifest() + // The tree a compromised build would report for its forged manifest is + // whatever it likes; give the step the honest tree so only the manifest + // check can stop it. + const tree = expectedTree() + const head = git(repo, 'rev-parse', 'HEAD') + writeManifest(forge(honest)) + + const result = runCommitStep(tree) + + expect(result.status).not.toBe(0) + expect(result.stdout).toContain('::error::guests.sha256') + expect(git(repo, 'rev-parse', 'HEAD')).toBe(head) + for (const path of [ + ...GUESTS, + 'languages/golang/encrypt/init.go', + 'languages/golang/auth/init.go', + '.github/workflows/x.yml', + ]) { + expect(existsSync(join(repo, path)), path).toBe(false) + } + }) + }, +) diff --git a/scripts/__tests__/go-release-proxy-check.test.mjs b/scripts/__tests__/go-release-proxy-check.test.mjs new file mode 100644 index 000000000..882e7961a --- /dev/null +++ b/scripts/__tests__/go-release-proxy-check.test.mjs @@ -0,0 +1,111 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `release-golang.yml` refuses a version the Go proxy already lists, which + * catches a tag deleted after the proxy fetched it. The check must read + * `@v/list` and never ask for the version itself: a request for a version + * whose tag does not exist yet makes the proxy cache "not found" for up to 30 + * minutes, and the `verify` job, which asks for it straight after the push, + * would then fail. + */ + +const WORKFLOW = readWorkflow('.github/workflows/release-golang.yml') +const STEP_NAME = 'Refuse a version the Go proxy already lists' +const BUILD_STEPS = WORKFLOW.jobs.build.steps +const PROXY_STEP = BUILD_STEPS.find((step) => step.name === STEP_NAME) + +let bin + +beforeEach(() => { + bin = mkdtempSync(join(tmpdir(), 'go-release-proxy-')) +}) + +afterEach(() => { + rmSync(bin, { recursive: true, force: true }) +}) + +/** Run the step with a `curl` that answers `list`, or fails when null. */ +function runWithProxyList(version, list) { + const curl = join(bin, 'curl') + writeFileSync( + curl, + list === null + ? '#!/bin/sh\necho "curl: (22) 503" >&2\nexit 22\n' + : `#!/bin/sh\nprintf '%s' '${list}'\n`, + ) + chmodSync(curl, 0o755) + return spawnSync('bash', ['-c', String(PROXY_STEP.run)], { + encoding: 'utf8', + env: { PATH: `${bin}:${process.env.PATH}`, VERSION: version }, + }) +} + +describe('release-golang.yml: the Go proxy check', () => { + it('runs in build, before anything is built', () => { + const check = BUILD_STEPS.indexOf(PROXY_STEP) + const firstBuild = BUILD_STEPS.findIndex((step) => + String(step.run ?? '').includes('mise run'), + ) + expect(check).toBeGreaterThanOrEqual(0) + expect(check).toBeLessThan(firstBuild) + }) + + it('reads @v/list and nothing else', () => { + expect(String(PROXY_STEP.run)).toContain( + 'https://proxy.golang.org/$module/@v/list', + ) + }) + + it('is the only step before the push that talks to the proxy', () => { + // Anything that resolves the version through the proxy before the tag + // exists caches "not found" against it. `echo` lines are text for the + // release notes, such as the `go get` a user runs, not commands. + const commands = (run) => + String(run ?? '') + .split('\n') + .filter((line) => !/^\s*echo /.test(line)) + .join('\n') + const asksForAVersion = + /go (mod download|list -m|get)|@v\/v|\.info\b|@latest/ + for (const job of ['build', 'publish']) { + for (const step of WORKFLOW.jobs[job].steps) { + expect(commands(step.run), `${job}: ${step.name}`).not.toMatch( + asksForAVersion, + ) + } + } + }) + + it('passes a version the proxy does not list', () => { + const result = runWithProxyList( + '0.1.0-alpha.1', + 'v0.1.0-alpha.10\nv0.1.0-alpha.2\n', + ) + expect(result.stdout).not.toContain('::error::') + expect(result.status).toBe(0) + }) + + it('passes when the proxy lists nothing yet', () => { + expect(runWithProxyList('0.1.0-alpha.1', '').status).toBe(0) + }) + + it('refuses a version the proxy lists', () => { + const result = runWithProxyList( + '0.1.0-alpha.1', + 'v0.1.0-alpha.1\nv0.1.0-alpha.2\n', + ) + expect(result.status).toBe(1) + expect(result.stdout).toContain('::error::the Go proxy already lists') + }) + + it('fails closed when the proxy cannot be read', () => { + const result = runWithProxyList('0.1.0-alpha.1', null) + expect(result.status).toBe(1) + expect(result.stdout).toContain('::error::could not read') + }) +}) diff --git a/scripts/__tests__/go-release-version.test.mjs b/scripts/__tests__/go-release-version.test.mjs index dbec3f55a..1eca3c37c 100644 --- a/scripts/__tests__/go-release-version.test.mjs +++ b/scripts/__tests__/go-release-version.test.mjs @@ -30,6 +30,10 @@ describe('goReleaseVersionErrors', () => { '0.1.0-alpha.1', '0.1.0-rc.0', '1.2.3-x-y.7.z9', + // Near misses: a 14-digit identifier that is not a pseudo-version's + // timestamp position, and a date-stamped pre-release. + '0.1.0-alpha.20261012131135', + '0.1.0-rc.20261012', ])('accepts %s from main', (version) => { expect(errorsFor(version)).toEqual([]) }) @@ -47,6 +51,11 @@ describe('goReleaseVersionErrors', () => { ['0.1.0+build.1', '+build metadata'], ['2.0.0', 'major version 0 or 1'], ['2.0.0-alpha.1', 'major version 0 or 1'], + // The three pseudo-version forms: no earlier tag, after a pre-release, + // and after a release (the patch bumped, `-0.` before the timestamp). + ['0.0.0-20261012131135-deadbeefdead', 'pseudo-version'], + ['0.1.0-alpha.1.0.20261012131135-deadbeefdead', 'pseudo-version'], + ['0.1.1-0.20261012131135-deadbeefdead', 'pseudo-version'], ])('refuses %s (%s)', (version, reason) => { const errors = errorsFor(version) expect(errors.length).toBeGreaterThan(0) diff --git a/scripts/go-release-version.mjs b/scripts/go-release-version.mjs index 799910204..bb5e739c0 100644 --- a/scripts/go-release-version.mjs +++ b/scripts/go-release-version.mjs @@ -9,7 +9,7 @@ // pushed, rather than by the proxy check at the end of the run, after the tag // exists and the version number is spent. // -// Three rules: +// Four rules: // // 1. CANONICAL SEMVER. Go accepts only `vMAJOR.MINOR.PATCH[-PRERELEASE]` with // no leading zeros in a numeric part or a numeric pre-release identifier, @@ -19,7 +19,13 @@ // suffix may only publish v0 and v1; `v2.0.0` at that path is refused with // "module path must match major version". A path ending `/vN` publishes // major N only. -// 3. A FINAL VERSION COMES FROM MAIN. `workflow_dispatch` runs on whatever ref +// 3. NOT A PSEUDO-VERSION. Go reads a version shaped like a pseudo-version +// (`v0.0.0-20261012131135-deadbeefdead`, `v1.2.4-0.20261012131135-…`) as +// a timestamp and revision to check against the repository, not as a tag, +// so a tag of that shape builds here and fails only in `verify`, after the +// push. Refused by the same pattern `golang.org/x/mod/module`'s +// `IsPseudoVersion` uses. +// 4. A FINAL VERSION COMES FROM MAIN. `workflow_dispatch` runs on whatever ref // the dispatcher picks. A pre-release (a version with a `-suffix`) may be // cut from a branch, which is how a partner alpha is built from an // unmerged stack; a final version may not. @@ -32,6 +38,10 @@ const REPO_ROOT = resolve(import.meta.dirname, '..') const GO_MOD = join(REPO_ROOT, 'languages/golang/go.mod') const NUMERIC = /^(0|[1-9][0-9]*)$/ +// golang.org/x/mod/module's pseudoVersionRE, less the `+build` suffix, +// which is refused before this is reached. +const PSEUDO_VERSION = + /^v[0-9]+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+$/ const IDENTIFIER = /^[0-9A-Za-z-]+$/ /** The `module` line of a go.mod. */ @@ -92,6 +102,12 @@ export function goReleaseVersionErrors(version, { modulePath, ref }) { } } + if (pre !== null && PSEUDO_VERSION.test(`v${version}`)) { + errors.push( + `'${version}' has the shape of a Go pseudo-version, which Go checks against a commit and timestamp rather than reading as a tag`, + ) + } + const major = Number(parts[0]) const majors = allowedMajors(modulePath) if (!majors.includes(major)) {