Skip to content
Open
99 changes: 99 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,102 @@ jobs:
- name: Clippy pg-compat
if: steps.changes.outputs.wire == 'true'
run: cargo clippy --manifest-path pg-compat/Cargo.toml --all-targets --locked -- -D warnings

# ---------------------------------------------------------------------------
# Public-API semver gate (#253). Fails a PR that breaks pg-core's or pg-wasm's
# public API without declaring the break.
#
# Versions here are bumped by release-plz, not by the PR that makes the
# change, so "declared" means the conventional-commit `!` marker that
# release-plz turns into a major bump. The marker is read off the PR title
# into SEMVER_RELEASE_TYPE; `edited` is already in this file's trigger list,
# so adding the `!` to the title re-runs the gate.
#
# Same always-reports shape as wire-compat-rust: the path filter is a step,
# not an `on: paths:` key, so a required check never sits pending.
# ---------------------------------------------------------------------------
semver-checks:
name: Public API semver
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read # paths-filter reads the PR's changed files
steps:
# pg-wasm is not published to crates.io, so its baseline is origin/main
# rather than a registry version, and the full history has to be here.
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
api:
- 'pg-core/**'
- 'pg-wasm/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'scripts/semver-checks.sh'
- 'scripts/semver-checks-test.sh'
- '.github/workflows/build.yml'

# Stubs cargo, so it needs no toolchain and runs in under a second. It
# covers the 100-vs-101 exit-code mapping the gate depends on: 100 is a
# semver violation, 101 is the tool or the build failing.
- name: Test the gate script
if: steps.changes.outputs.api == 'true'
shell: bash
run: ./scripts/semver-checks-test.sh

- if: steps.changes.outputs.api == 'true'
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

# Pinned release binary rather than `cargo install` (a five-minute build)
# or a fourth third-party action. Bump the version and the digest together.
- name: Install cargo-semver-checks
if: steps.changes.outputs.api == 'true'
shell: bash
env:
VERSION: 0.49.0
SHA256: 72f6834d75d28a66e02c9fd6a230ce901bb30eee6067b85867a97445df040e4a
run: |
curl -sSfL -o "$RUNNER_TEMP/cargo-semver-checks.tar.gz" \
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${VERSION}/cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
echo "${SHA256} $RUNNER_TEMP/cargo-semver-checks.tar.gz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/cargo-semver-checks.tar.gz" -C "$HOME/.cargo/bin" cargo-semver-checks

# The PR title, not the commit subject: PRs are squash-merged here, so the
# title is what release-plz and the Conventional Commit check read. Passed
# through the environment rather than interpolated into the script, so a
# PR title cannot inject shell.
#
# The title `!` is the only accepted declaration. A `BREAKING CHANGE:`
# footer in the PR *body* is deliberately not accepted: this repo's
# squash_merge_commit_message is COMMIT_MESSAGES, so the body never reaches
# the squashed commit, and release-plz reads commits. Honouring a body-only
# footer would pass the gate on a `fix(pg-core):` PR that release-plz then
# publishes as a patch release of a breaking change.
- name: Read the breaking-change declaration off the PR title
id: declared
if: steps.changes.outputs.api == 'true'
shell: bash
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$PR_TITLE" =~ ^[a-zA-Z]+(\([^\)]*\))?!: ]]; then
echo "Breaking change declared in the PR title; a major bump is allowed."
echo "release_type=major" >> "$GITHUB_OUTPUT"
else
echo "No '!' in the PR title; any breaking change will fail the gate."
fi

- name: Check the public API
if: steps.changes.outputs.api == 'true'
shell: bash
env:
SEMVER_RELEASE_TYPE: ${{ steps.declared.outputs.release_type }}
run: ./scripts/semver-checks.sh
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ pkg_ibs.sec
.env
*.pem
*.patch
# pg-wasm is under the root manifest's `exclude`, so the semver gate's
# `--manifest-path pg-wasm/Cargo.toml` resolves it on its own and writes a
# lockfile next to the manifest. The repo does not track it.
pg-wasm/Cargo.lock
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s
- `pg-compat` is a second excluded sibling crate (root `Cargo.toml` `exclude`), and the exclusion is load-bearing: it depends on `pg-core` from **crates.io** (`=0.6.1`), not on `../pg-core`, so it can open bytes sealed by this tree with published readers. It has its own `Cargo.lock` (run it with `--locked`). Its input comes from `cargo run -p pg-core --features stream --example seal-samples -- <dir>`, a deterministic sealer whose output layout is documented in `pg-compat/README.md`. CI wires the two together: `wire-compat-rust` in `build.yml` seals with HEAD and opens with published pg-core on any PR touching the wire surface (pg-core/pg-wasm/pg-compat trees, the ROOT `Cargo.lock`/`Cargo.toml` — pg-core resolves from the root lockfile — and build.yml itself); `pg-compat-lint` covers the crate's fmt/clippy, which the per-crate matrices don't.
- Appending a field at the *end* of `Header` really is additive: the header is a length-prefixed region and `bincode` ignores trailing bytes, so published `pg-core` 0.6.1 still opens it. A field inserted anywhere else, a changed field type, or a reorder shifts every following byte and the containers stop opening, but *not* with a decode error: 0.6.1 reads a garbage length prefix, attempts a ~20 GiB allocation, and the process aborts (SIGABRT). Expect `reader died on signal 6 ... memory allocation of N bytes failed`, not a message naming the header. This is also why `pg-compat` opens each case in a child process (its `pg-compat-case` binary): an abort is not a panic, `catch_unwind` cannot contain it, and in one process the first broken case would take the run down before the others were tried. Don't reason about "additive" from the struct alone; run the compat gate.
- CI's `Format workspace` matrix runs `cargo fmt --manifest-path pg-<crate>/Cargo.toml --all -- --check` per crate over shared workspace files; always run `cargo fmt --all -- --check` from repo root before pushing, or one crate's drift fails the whole matrix.
- `scripts/semver-checks.sh` runs `cargo-semver-checks` over the two surfaces external consumers build against: `pg-core` against its crates.io release, and `pg-wasm` against `origin/main` (it has no crates.io release; the npm package is versioned from `pg-core`). It is NOT yet a CI gate: the `semver-checks` job for `build.yml` sits in the patch comment on PR #270 and needs a maintainer to apply it, so run the script yourself before pushing anything that touches those crates. Four things it encodes. (1) `pg-core` needs `--only-explicit-features --features test,rust,stream`, the same set the test and clippy matrices use: cargo-semver-checks otherwise enables everything that doesn't look unstable, which pulls in `web` and hits its `compile_error!`. (2) `pg-core`'s `web,stream` surface is deliberately not checked. `Unsealer` has two `unseal` methods there on different instantiations (owned `self` in `client/web/mod.rs`, `&mut self` in `client/web/stream.rs`) and cargo-semver-checks 0.49 pairs them by name alone, so it reports `method_receiver_mut_ref_became_owned` against byte-identical source; `rust,stream` is clean because both receivers are owned there. (3) Any wasm32 run needs `RUSTFLAGS=--cap-lints=warn`, because the `--cap-lints allow` cargo-semver-checks sets silences the "dropping unsupported crate type" warnings cargo reads back when probing rustc, and cargo then dies with "output of --print=file-names missing". (4) `cargo-semver-checks` splits its non-zero exits: `100` is a semver violation, `101` is the tool or the build failing (unresolvable baseline rev, missing rustup target, registry fetch failure, compile error in the crate). Never treat "non-zero" as "breaking change" here, because the advice a semver gate prints is "declare the break", and on this repo that means a `!` in the PR title and a spurious major release of `pg-core`. `scripts/semver-checks-test.sh` pins that mapping; it stubs `cargo`, so it runs in well under a second and needs neither cargo-semver-checks nor a wasm32 toolchain. Run it after touching the gate.
- release-plz owns the version numbers, so the PR making a breaking change cannot bump the crate to match (bumping `pg-core` alone doesn't even resolve: `pg-cli` requires `^0.6.1`). What the semver gate accepts as the declaration is the conventional-commit `!` in the PR title, and only that; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. A `BREAKING CHANGE:` footer in the PR body is not accepted and must not be: this repo's `squash_merge_commit_message` is `COMMIT_MESSAGES`, so the body never reaches the squashed commit, and release-plz reading a bare `fix(pg-core):` subject would cut a patch release of a break the gate had already waved through. Two consequences of the merge settings worth knowing when you declare a break. `squash_merge_commit_title` is `COMMIT_OR_PR_TITLE`, which is the PR title on a multi-commit PR but the commit's subject when the PR has exactly one commit — so on a single-commit PR put the `!` in the commit subject too, or the gate goes green off the PR title while release-plz cuts a patch. And `--release-type major` doesn't merely permit a bigger bump: every lint exists to demand a bump the declaration already grants, so all of them skip and the run checks nothing (`0 checks: 0 pass, 253 skip`) on both surfaces at once. A green gate on a `!` PR verified nothing; a `!` added for a pg-wasm break also passes any unrelated pg-core break in the same PR.
- The Docker build (`Dockerfile`, `FROM rust:<version>-slim`) pins an older or different Rust than the `Test workspace`/`Format workspace` jobs' `dtolnay/rust-toolchain@stable`. A change can pass every workspace test and still fail Docker Build on a type-inference difference that doesn't reproduce on host stable (e.g. a slice-element-type unification difference across rustc versions). Check the Dockerfile's current pin, and run `cargo build --profile edge --bin pg-pkg` locally before pushing any `Cargo.toml` dependency bump; for a true repro, build the Docker image.
- The `dobby-coder` GitHub App lacks `workflows: write` on this repo; any push touching `.github/workflows/*.yml` is rejected at the remote. Before treating a fix as blocked, check whether the same effect can be achieved in a pushable file (crate manifest, source, committed script); if a fix genuinely can only live in a workflow file, ship the pushable half and hand the maintainer ready-to-paste YAML in the PR body.

Expand Down
208 changes: 208 additions & 0 deletions scripts/semver-checks-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
#
# Regression tests for scripts/semver-checks.sh, covering the exit-code
# contract: cargo-semver-checks answers 100 for a semver violation and 101 for
# the tool or the build failing, and only 100 may be reported as a breaking
# change. See the header of semver-checks.sh.
#
# `cargo` is stubbed, so this runs in about a second and needs neither
# cargo-semver-checks nor a wasm32 toolchain.
#
# Usage:
# scripts/semver-checks-test.sh
#
set -euo pipefail

root=$(git rev-parse --show-toplevel)
cd "$root"

gate="$root/scripts/semver-checks.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
stubs="$tmp/bin"
mkdir "$stubs"

# Stub cargo: exits with STUB_EXIT_PG_CORE or STUB_EXIT_PG_WASM depending on the
# --manifest-path it was handed, and records its argv per crate so the tests can
# assert on what the gate passed through.
cat >"$stubs/cargo" <<'STUB'
#!/usr/bin/env bash
set -uo pipefail
crate=unknown
for arg in "$@"; do
case $arg in
pg-core/Cargo.toml) crate=pg_core ;;
pg-wasm/Cargo.toml) crate=pg_wasm ;;
esac
done
printf '%s\n' "$@" >"$STUB_ARGV_DIR/$crate.argv"
case $crate in
pg_core) exit "${STUB_EXIT_PG_CORE:-0}" ;;
pg_wasm) exit "${STUB_EXIT_PG_WASM:-0}" ;;
esac
exit 0
STUB
chmod +x "$stubs/cargo"

# The gate checks for cargo-semver-checks with `command -v` before running.
printf '#!/bin/sh\nexit 0\n' >"$stubs/cargo-semver-checks"
chmod +x "$stubs/cargo-semver-checks"

failures=0
argv_dir=

# Takes VAR=VALUE arguments and hands them to the gate through `env`, so nothing
# leaks into the next case the way an assignment prefixed onto a function call
# can.
run_gate() {
argv_dir=$(mktemp -d "$tmp/argv.XXXXXX")
set +e
gate_out=$(env PATH="$stubs:$PATH" STUB_ARGV_DIR="$argv_dir" "$@" "$gate" 2>&1)
gate_status=$?
set -e
}

check() {
local what=$1 want=$2 got=$3
if [[ $want == "$got" ]]; then
echo " ok $what"
else
echo " FAIL $what: want ${want}, got ${got}"
failures=$((failures + 1))
fi
}

# Substring assertions take the haystack from $gate_out.
check_says() {
local what=$1 needle=$2
if [[ $gate_out == *"$needle"* ]]; then
echo " ok $what"
else
echo " FAIL $what: output does not contain '${needle}'"
printf '%s\n' "$gate_out" | sed 's/^/ | /'
failures=$((failures + 1))
fi
}

check_silent_about() {
local what=$1 needle=$2
if [[ $gate_out != *"$needle"* ]]; then
echo " ok $what"
else
echo " FAIL $what: output should not contain '${needle}'"
printf '%s\n' "$gate_out" | sed 's/^/ | /'
failures=$((failures + 1))
fi
}

echo "both surfaces clean"
run_gate
check "exit status" 0 "$gate_status"
check_says "reports success" "No undeclared breaking public-API changes."

echo "pg-core reports a semver violation (100)"
run_gate STUB_EXIT_PG_CORE=100
check "exit status" 1 "$gate_status"
check_says "names the crate" "Breaking public-API changes in: pg-core"

echo "both surfaces report a semver violation (100)"
run_gate STUB_EXIT_PG_CORE=100 STUB_EXIT_PG_WASM=100
check "exit status" 1 "$gate_status"
check_says "names both crates" "Breaking public-API changes in: pg-core pg-wasm"

# The regression this file exists for. Before the fix both of these landed on
# the same exit 1 and the same "declare the break" advice, which on a
# release-plz repo means a spurious major release of pg-core.
echo "pg-core fails to build (101)"
run_gate STUB_EXIT_PG_CORE=101
check "propagates the tool's exit code" 101 "$gate_status"
check_says "says it is not a semver break" "tool or build"
check_silent_about "does not claim a breaking change" "Breaking public-API changes"

echo "pg-wasm baseline does not resolve (101)"
run_gate STUB_EXIT_PG_WASM=101
check "propagates the tool's exit code" 101 "$gate_status"
check_silent_about "does not claim a breaking change" "Breaking public-API changes"

# A build failure is a build failure whatever the declared release type, so the
# escape hatch must not turn it green.
echo "pg-wasm fails to build (101) with SEMVER_RELEASE_TYPE=major"
run_gate STUB_EXIT_PG_WASM=101 SEMVER_RELEASE_TYPE=major
check "propagates the tool's exit code" 101 "$gate_status"
check_silent_about "does not claim a breaking change" "Breaking public-API changes"

# A build failure on the second surface must not throw away a real violation
# already found on the first: the author would fix the baseline, re-run, and only
# then learn about the break.
echo "pg-core violation (100) plus pg-wasm build failure (101)"
run_gate STUB_EXIT_PG_CORE=100 STUB_EXIT_PG_WASM=101
check "propagates the tool's exit code" 101 "$gate_status"
check_says "still reports the recorded break" "Breaking public-API changes in: pg-core"
check_says "says the 101 is not a semver break" "tool or build"

echo "an unrecognised exit code is not swallowed"
run_gate STUB_EXIT_PG_CORE=2
check "propagates the tool's exit code" 2 "$gate_status"
check_silent_about "does not claim a breaking change" "Breaking public-API changes"

echo "SEMVER_RELEASE_TYPE reaches both invocations"
run_gate SEMVER_RELEASE_TYPE=major
check "exit status" 0 "$gate_status"
for crate in pg_core pg_wasm; do
if grep -qx -- '--release-type' "$argv_dir/$crate.argv" &&
grep -qx -- 'major' "$argv_dir/$crate.argv"; then
echo " ok $crate got --release-type major"
else
echo " FAIL $crate did not get --release-type major"
failures=$((failures + 1))
fi
done

echo "the default run passes no --release-type"
run_gate
check "exit status" 0 "$gate_status"
for crate in pg_core pg_wasm; do
if grep -qx -- '--release-type' "$argv_dir/$crate.argv"; then
echo " FAIL $crate got an unasked-for --release-type"
failures=$((failures + 1))
else
echo " ok $crate got no --release-type"
fi
done

echo "SEMVER_WASM_BASELINE reaches the pg-wasm invocation"
run_gate SEMVER_WASM_BASELINE=refs/heads/some-branch
check "exit status" 0 "$gate_status"
if grep -qx -- 'refs/heads/some-branch' "$argv_dir/pg_wasm.argv"; then
echo " ok pg-wasm got the baseline rev"
else
echo " FAIL pg-wasm did not get the baseline rev"
failures=$((failures + 1))
fi

# `cd "$(git rev-parse --show-toplevel)"` does not abort here: the substitution
# fails but `cd ""` succeeds, so the gate went on to check relative manifest
# paths against whatever the CWD happened to be. Assert on cargo never being
# reached, not just on the exit status, because the stub itself fails without a
# repo to record into.
echo "run from outside a git repository"
argv_dir=$(mktemp -d "$tmp/argv.XXXXXX")
set +e
outside_out=$(cd / && env PATH="$stubs:$PATH" STUB_ARGV_DIR="$argv_dir" "$gate" 2>&1)
outside_status=$?
set -e
check "exit status is non-zero" "yes" "$([[ $outside_status -ne 0 ]] && echo yes || echo no)"
if [[ -z $(ls -A "$argv_dir") ]]; then
echo " ok aborts before invoking cargo"
else
echo " FAIL ran cargo outside the repo: $(ls -A "$argv_dir" | tr '\n' ' ')"
printf '%s\n' "$outside_out" | sed 's/^/ | /'
failures=$((failures + 1))
fi

echo
if [[ $failures -gt 0 ]]; then
echo "${failures} assertion(s) failed" >&2
exit 1
fi
echo "all assertions passed"
Loading