From d967da9d2e0804f0b6f17dfc99cf3ba179be59aa Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:28:16 +0000 Subject: [PATCH 01/10] ci: semver gate script for the pg-core and pg-wasm public APIs scripts/semver-checks.sh runs cargo-semver-checks over the two surfaces external consumers build against and fails on a breaking change that the PR has not declared. The workflow job that calls it ships as a patch in a PR comment; the bot cannot push .github/workflows. Refs #253. --- scripts/semver-checks.sh | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100755 scripts/semver-checks.sh diff --git a/scripts/semver-checks.sh b/scripts/semver-checks.sh new file mode 100755 index 00000000..2d8650d1 --- /dev/null +++ b/scripts/semver-checks.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Public-API semver gate for the two crates external consumers build against: +# pg-core (crates.io) and pg-wasm (published to npm as @e4a/pg-wasm). +# +# A breaking change to either surface fails this script unless the change is +# declared as breaking. Versions in this repo are bumped by release-plz, not by +# the PR that makes the change, so the declaration is the `!` in the +# conventional-commit PR title (plus the `BREAKING CHANGE:` footer) that +# release-plz turns into a major bump. CI translates that into +# SEMVER_RELEASE_TYPE=major; see .github/workflows/build.yml. +# +# Usage: +# scripts/semver-checks.sh +# +# Environment: +# SEMVER_RELEASE_TYPE major|minor|patch. Passed to --release-type, which +# overrides the bump cargo-semver-checks derives from +# the version numbers. Set it to `major` to allow +# breaking changes through. +# SEMVER_WASM_BASELINE Git revision pg-wasm is compared against. +# Default: origin/main. pg-wasm is not on crates.io, so +# there is no registry baseline to fetch. +# +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +release_type=() +if [[ -n "${SEMVER_RELEASE_TYPE:-}" ]]; then + release_type=(--release-type "$SEMVER_RELEASE_TYPE") +fi +wasm_baseline="${SEMVER_WASM_BASELINE:-origin/main}" + +if ! command -v cargo-semver-checks >/dev/null; then + echo "cargo-semver-checks is not installed: cargo install cargo-semver-checks --locked" >&2 + exit 127 +fi + +failed=() + +# pg-core, native surface. The feature set is explicit for the same reason the +# test and clippy jobs spell it out: `web` is compile_error! off wasm32, so the +# feature heuristic cargo-semver-checks uses by default (everything except +# unstable-looking features) cannot build this crate. `test` is in the set +# because the `test` module it gates is public API that pg-compat and pg-wasm +# build against. +# +# pg-core's `web,stream` surface is deliberately NOT checked: on that feature +# combination `Unsealer` has two `unseal` methods 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 across versions by name alone, reporting +# method_receiver_mut_ref_became_owned against byte-identical source. pg-wasm +# below covers the surface those consumers actually call. +echo "==> pg-core (native, features test,rust,stream)" +cargo semver-checks \ + --manifest-path pg-core/Cargo.toml \ + --only-explicit-features \ + --features test,rust,stream \ + "${release_type[@]}" || failed+=("pg-core") + +# pg-wasm has to be built for wasm32, and cargo-semver-checks passes +# `--cap-lints allow` in RUSTFLAGS, which silences the "dropping unsupported +# crate type" warnings cargo reads back when it probes rustc for wasm32 target +# info. Cargo then aborts with "output of --print=file-names missing when +# learning about target-specific information from rustc". Setting RUSTFLAGS +# ourselves keeps cargo-semver-checks from adding its own cap and lets the +# probe through. +echo "==> pg-wasm (wasm32-unknown-unknown, baseline ${wasm_baseline})" +RUSTFLAGS="--cap-lints=warn" cargo semver-checks \ + --manifest-path pg-wasm/Cargo.toml \ + --target wasm32-unknown-unknown \ + --baseline-rev "$wasm_baseline" \ + "${release_type[@]}" || failed+=("pg-wasm") + +if [[ ${#failed[@]} -gt 0 ]]; then + echo + echo "Breaking public-API changes in: ${failed[*]}" >&2 + echo "Either keep the change additive, or declare the break: give the PR a" >&2 + echo "conventional-commit title with '!' (e.g. feat(pg-core)!: ...) and a" >&2 + echo "BREAKING CHANGE: footer, so release-plz cuts a major release." >&2 + exit 1 +fi + +echo +echo "No undeclared breaking public-API changes." From 62cf52665e6a4b95572eaaf18e91f21f1f2732c4 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:30:37 +0000 Subject: [PATCH 02/10] docs: record the semver gate and its two workarounds in CLAUDE.md Refs #253. --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index df77c61c..2108a795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -- `, 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-/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. Three 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". +- 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, the same marker release-plz reads; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. - The Docker build (`Dockerfile`, `FROM rust:-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. From 23701f5829b85d609f4967e12d8e68001eccacfc Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:41 +0000 Subject: [PATCH 03/10] fix(ci): tell a semver break apart from a tool failure in the gate cargo-semver-checks answers 100 for a semver violation and 101 for the tool or the build failing. The script treated every non-zero exit as a breaking change, so an unresolvable baseline rev, a missing wasm32 target, a registry fetch failure or a compile error all printed "Breaking public-API changes in: ..." and advised adding a `!` to the PR title. On this repo that makes release-plz cut a spurious major release of pg-core, and it does not even clear the check: --release-type major leaves a 101 non-zero. run_check now records only 100 and lets anything else out with the tool's own exit code. Two shell fixes ride along. `cd "$(git rev-parse --show-toplevel)"` did not abort outside a git repository, because the substitution fails but `cd ""` succeeds: the gate went on to check relative manifest paths against whatever the CWD happened to be, and reported success. And `"${release_type[@]}"` on an empty array aborts under `set -u` on bash before 4.4, which is macOS system bash. scripts/semver-checks-test.sh covers the exit-code mapping, the two environment variables and the outside-a-repo abort. It stubs cargo, so it needs neither cargo-semver-checks nor a wasm32 toolchain and runs in under a second. Eleven of its assertions fail against the previous script. --- scripts/semver-checks-test.sh | 199 ++++++++++++++++++++++++++++++++++ scripts/semver-checks.sh | 43 +++++++- 2 files changed, 237 insertions(+), 5 deletions(-) create mode 100755 scripts/semver-checks-test.sh diff --git a/scripts/semver-checks-test.sh b/scripts/semver-checks-test.sh new file mode 100755 index 00000000..e9d5cfc4 --- /dev/null +++ b/scripts/semver-checks-test.sh @@ -0,0 +1,199 @@ +#!/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" + +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" diff --git a/scripts/semver-checks.sh b/scripts/semver-checks.sh index 2d8650d1..e9d7da2b 100755 --- a/scripts/semver-checks.sh +++ b/scripts/semver-checks.sh @@ -22,9 +22,20 @@ # Default: origin/main. pg-wasm is not on crates.io, so # there is no registry baseline to fetch. # +# Exit codes: 1 for an undeclared breaking change, 127 if the tool is missing, +# and cargo-semver-checks' own code for anything else. It has two distinct +# non-zero exits and they must not be conflated. 100 is a semver violation. 101 +# is the tool or the build failing: an unresolvable baseline rev, a missing +# rustup target, a registry fetch failure, a compile error in the crate. Only +# 100 means "declare the break". Reporting a 101 that way would tell the author +# to add a `!` that makes release-plz cut a spurious major release, and it would +# not even clear the check, because --release-type major leaves a 101 non-zero. +# scripts/semver-checks-test.sh covers this mapping. +# set -euo pipefail -cd "$(git rev-parse --show-toplevel)" +root=$(git rev-parse --show-toplevel) +cd "$root" release_type=() if [[ -n "${SEMVER_RELEASE_TYPE:-}" ]]; then @@ -39,6 +50,23 @@ fi failed=() +# Record a semver violation (exit 100) and carry on so both surfaces get +# reported; let anything else out with the tool's own exit code. +run_check() { + local name=$1 + shift + "$@" && return 0 + local ec=$? + if [[ $ec -eq 100 ]]; then + failed+=("$name") + return 0 + fi + echo "cargo-semver-checks failed on ${name} (exit ${ec}): tool or build" >&2 + echo "failure, not a semver break. Adding '!' to the PR title will not" >&2 + echo "clear this." >&2 + exit "$ec" +} + # pg-core, native surface. The feature set is explicit for the same reason the # test and clippy jobs spell it out: `web` is compile_error! off wasm32, so the # feature heuristic cargo-semver-checks uses by default (everything except @@ -52,12 +80,16 @@ failed=() # cargo-semver-checks 0.49 pairs them across versions by name alone, reporting # method_receiver_mut_ref_became_owned against byte-identical source. pg-wasm # below covers the surface those consumers actually call. +# +# `${release_type[@]+...}` rather than a bare `"${release_type[@]}"`: expanding +# an empty array aborts under `set -u` on bash < 4.4, which is macOS system bash. echo "==> pg-core (native, features test,rust,stream)" -cargo semver-checks \ +run_check pg-core \ + cargo semver-checks \ --manifest-path pg-core/Cargo.toml \ --only-explicit-features \ --features test,rust,stream \ - "${release_type[@]}" || failed+=("pg-core") + ${release_type[@]+"${release_type[@]}"} # pg-wasm has to be built for wasm32, and cargo-semver-checks passes # `--cap-lints allow` in RUSTFLAGS, which silences the "dropping unsupported @@ -67,11 +99,12 @@ cargo semver-checks \ # ourselves keeps cargo-semver-checks from adding its own cap and lets the # probe through. echo "==> pg-wasm (wasm32-unknown-unknown, baseline ${wasm_baseline})" -RUSTFLAGS="--cap-lints=warn" cargo semver-checks \ +run_check pg-wasm \ + env RUSTFLAGS="--cap-lints=warn" cargo semver-checks \ --manifest-path pg-wasm/Cargo.toml \ --target wasm32-unknown-unknown \ --baseline-rev "$wasm_baseline" \ - "${release_type[@]}" || failed+=("pg-wasm") + ${release_type[@]+"${release_type[@]}"} if [[ ${#failed[@]} -gt 0 ]]; then echo From 7ad3e0a1fbd0f19bb78f7944aa2ae49365e529d5 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:41 +0000 Subject: [PATCH 04/10] chore: gitignore the Cargo.lock the semver gate writes for pg-wasm pg-wasm is under the root manifest's `exclude`, so running the gate with `--manifest-path pg-wasm/Cargo.toml` resolves it on its own and leaves an untracked lockfile next to the manifest. Now that running the gate is a routine step, a later `git add -A` would commit a file the repo does not track. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 93037dc6..ccaf82b8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From ae446bfc25d585a378100153282225819f4da34b Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:41 +0000 Subject: [PATCH 05/10] docs: record the semver gate's exit-code contract in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2108a795..f0489c0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ 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 -- `, 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-/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. Three 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". +- `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. Three 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, the same marker release-plz reads; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. - The Docker build (`Dockerfile`, `FROM rust:-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. From b5f3dcb743494b08b36e263c517fc6bcf5b5d621 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:49:16 +0000 Subject: [PATCH 06/10] fix(ci): make the PR title '!' the only breaking-change declaration The gate's contract said the declaration was the conventional-commit `!` in the PR title "plus the BREAKING CHANGE: footer", and the advice it printed on a break told the author to supply both. The workflow step that feeds it read those as alternatives and accepted a footer in the PR body on its own. Those are not equivalent on this repo. `squash_merge_commit_message` is COMMIT_MESSAGES, so the PR body never reaches the squashed commit, and release-plz reads commits: a PR titled `fix(pg-core): ...` with a footer only in its body sets --release-type major, turns the gate green, and gets a patch release of a breaking change published to crates.io. The title `!` is the marker release-plz actually reads, so it is the only one the gate accepts. The footer stays useful changelog prose. Also in the script: - report the violations recorded so far before exiting on a tool or build failure. A 101 on pg-wasm used to discard a 100 already found on pg-core, so fixing the baseline and re-running was the only way to learn about the break. - write down what --release-type major does: it does not permit a larger bump, it leaves nothing to check (`0 checks: 0 pass, 253 skip`, measured on both surfaces), and it reaches both invocations, so a `!` declared for one crate passes an unrelated break in the other. CLAUDE.md gains the same, plus the other half of the merge settings: `squash_merge_commit_title` is COMMIT_OR_PR_TITLE, which is the commit's subject when a PR has exactly one commit, so a single-commit PR needs the `!` in the commit subject too. Refs #253 --- CLAUDE.md | 2 +- scripts/semver-checks-test.sh | 9 ++++++++ scripts/semver-checks.sh | 40 +++++++++++++++++++++++++---------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0489c0b..743e0664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s - 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-/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. Three 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, the same marker release-plz reads; CI turns it into `SEMVER_RELEASE_TYPE=major`, which the script passes as `--release-type major`. +- 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:-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. diff --git a/scripts/semver-checks-test.sh b/scripts/semver-checks-test.sh index e9d5cfc4..b9dcacd4 100755 --- a/scripts/semver-checks-test.sh +++ b/scripts/semver-checks-test.sh @@ -131,6 +131,15 @@ 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" diff --git a/scripts/semver-checks.sh b/scripts/semver-checks.sh index e9d7da2b..0cfe576f 100755 --- a/scripts/semver-checks.sh +++ b/scripts/semver-checks.sh @@ -6,18 +6,24 @@ # A breaking change to either surface fails this script unless the change is # declared as breaking. Versions in this repo are bumped by release-plz, not by # the PR that makes the change, so the declaration is the `!` in the -# conventional-commit PR title (plus the `BREAKING CHANGE:` footer) that -# release-plz turns into a major bump. CI translates that into +# conventional-commit PR title, and only that: release-plz reads commit +# messages, and this repo squash-merges with squash_merge_commit_message = +# COMMIT_MESSAGES, so a `BREAKING CHANGE:` footer written in the PR body never +# reaches the commit release-plz reads. The footer is useful prose for the +# changelog; it is not the declaration. CI translates the title marker into # SEMVER_RELEASE_TYPE=major; see .github/workflows/build.yml. # # Usage: # scripts/semver-checks.sh # # Environment: -# SEMVER_RELEASE_TYPE major|minor|patch. Passed to --release-type, which -# overrides the bump cargo-semver-checks derives from -# the version numbers. Set it to `major` to allow -# breaking changes through. +# SEMVER_RELEASE_TYPE major|minor|patch. Passed to --release-type. Note what +# `major` does: every cargo-semver-checks lint exists to +# demand a bump that a declared major already grants, so +# all of them skip and the run checks nothing (`0 checks: +# 0 pass, 253 skip`). It also reaches both invocations +# below, so a `!` declared for a pg-wasm break passes any +# unrelated pg-core break in the same PR as well. # SEMVER_WASM_BASELINE Git revision pg-wasm is compared against. # Default: origin/main. pg-wasm is not on crates.io, so # there is no registry baseline to fetch. @@ -50,6 +56,16 @@ fi failed=() +# Report the semver violations recorded so far, if any. +report_breaks() { + [[ ${#failed[@]} -gt 0 ]] || return 0 + echo >&2 + echo "Breaking public-API changes in: ${failed[*]}" >&2 + echo "Either keep the change additive, or declare the break: give the PR a" >&2 + echo "conventional-commit title with '!' (e.g. feat(pg-core)!: ...), which is" >&2 + echo "what release-plz reads to cut a major release." >&2 +} + # Record a semver violation (exit 100) and carry on so both surfaces get # reported; let anything else out with the tool's own exit code. run_check() { @@ -61,6 +77,12 @@ run_check() { failed+=("$name") return 0 fi + # A violation already recorded for the other surface is a real break and has + # to be reported here too, or fixing the build failure costs a round trip to + # discover it. The tool-failure message goes last, because it is what the + # non-zero exit is about: no `!` clears it. + report_breaks + echo >&2 echo "cargo-semver-checks failed on ${name} (exit ${ec}): tool or build" >&2 echo "failure, not a semver break. Adding '!' to the PR title will not" >&2 echo "clear this." >&2 @@ -107,11 +129,7 @@ run_check pg-wasm \ ${release_type[@]+"${release_type[@]}"} if [[ ${#failed[@]} -gt 0 ]]; then - echo - echo "Breaking public-API changes in: ${failed[*]}" >&2 - echo "Either keep the change additive, or declare the break: give the PR a" >&2 - echo "conventional-commit title with '!' (e.g. feat(pg-core)!: ...) and a" >&2 - echo "BREAKING CHANGE: footer, so release-plz cuts a major release." >&2 + report_breaks exit 1 fi From 56feaac4af1fd0022c859cb9fcbbcda00dabdd09 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:18:35 +0000 Subject: [PATCH 07/10] docs: fix the semver gate bullet's item count and bold density The bullet said "Three things it encodes" and then listed four: the exit-code item (4) was appended in ae446bf without updating the count. Dropped the five bold spans from the two new bullets as well. Five in 528 words is 9.5 per 1,000, against the org writing rules' cap of 1 per 1,000 and the 1-in-1,287 the rest of this file sits at. The exit codes read as `100` and `101` in backticks instead; the other three needed no emphasis. Refs #253 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 743e0664..5e34340a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +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 -- `, 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-/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. Three 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. +- `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:-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. From f56ecb2941bc208c8de2d076ff9625cd71a60248 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Tue, 28 Jul 2026 23:21:00 +0200 Subject: [PATCH 08/10] ci: run the cargo-semver-checks gate Dobby's patch from the #270 comment, applied verbatim (the App cannot push workflows). Without it scripts/semver-checks.sh ships with nothing calling it. --- .github/workflows/build.yml | 99 +++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e0be8b4..f49320d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 From de7c8d6601d7aa9f3b9b3f8634008fbbfa5b5a5e Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:17:04 +0000 Subject: [PATCH 09/10] docs: record that a build.yml merge is unpushable for the App too A maintainer applied the semver-checks job onto this branch, so the branch now carries a build.yml change. main then grew wire-compat-js in the same place, and the base-branch merge that reconciles them updates a workflow file, which the dobby-coder App cannot push. The existing bullet only said 'a fix that lives in a workflow file'; a conflict resolution is neither a fix nor splittable out of the merge commit, so say so. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e34340a..2b265afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s - `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:-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. +- 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. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`. ## Dependencies From e9734a270d10b2b64f3c41b1e9c1444e9add0b29 Mon Sep 17 00:00:00 2001 From: "dobby-yivi-agent[bot]" <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:17:37 +0000 Subject: [PATCH 10/10] docs: the semver job is applied, so stop calling it a pending patch f56ecb2 put the job in build.yml, but the CLAUDE.md bullet still told the next reader the gate was inert and lived in a PR comment. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b265afd..08544077 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ 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 -- `, 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-/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. +- `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`). The `semver-checks` job in `build.yml` calls it on any PR touching `pg-core`, `pg-wasm`, the root manifest or the script itself; run it yourself too before pushing such a change, since the job needs a wasm32 toolchain and a pinned cargo-semver-checks download and is therefore not the fastest feedback. 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:-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. The block covers *merge* commits too, which is easy to miss: once a branch carries its own `build.yml` change (typically a maintainer applying such a patch onto it), a later `git merge origin/main` that has to touch `build.yml` produces a commit updating a workflow file, and the push is rejected even when the resolution is only "keep both new jobs". Nothing can be split out of a merge commit, so that merge has to be landed by a maintainer, or the App needs `workflows: write`.