Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# pg-pkg/tests/api_gate.rs locates blocks in this spec with multi-line raw-string
# anchors. rustc normalises CRLF to LF inside raw strings, but the file read off
# disk keeps whatever git wrote, so on a Windows checkout (core.autocrlf=true is
# the Git for Windows default) every anchor misses and the suite goes red on a
# clean tree. Keeping the stored bytes is also what makes the oasdiff gate's
# verdict reproducible off a local checkout.
pg-pkg/api-description.yaml -text
107 changes: 107 additions & 0 deletions .github/workflows/api-diff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: API diff
#
# Breaking-change gate on the pg-pkg OpenAPI contract (#249).
#
# pg-pkg/api-description.yaml is the pinned v2 HTTP contract (#242), one of the
# three seams COMPATIBILITY.md guarantees. This job diffs the PR's spec against
# the branch the PR targets and fails on any change oasdiff rates WARN or ERR.
#
# Escape hatch, not a wall: pg-pkg's routes are versioned (unlike cryptify's,
# whose gate this mirrors), so a change /v2 cannot take additively goes under a
# new prefix (/v3/...) with /v2 left running, and /v2 is retired later through
# the deprecation process in COMPATIBILITY.md, once postguard-ops#64 telemetry
# shows nobody calls it. A /v3 route added next to /v2 reads as additive, so
# this gate passes it. Reach for that before reaching for err-ignore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, and a question rather than a defect.

This line presents /v3 as the escape hatch while pointing at err-ignore as the thing not to reach for. err-ignore is a file of regexes; what #249 actually offered as the alternative to a versioned route was "an explicit override label", and this PR does not implement one.

Dropping the label is defensible: on a required check it makes the gate advisory for anyone who can apply a label, and because pg-pkg's routes are versioned, /v3 is a real alternative rather than a formality. But it is a second deviation from #249, and the PR body claimed only one until this review. I have added it to the body; if you agree with the choice, a clause saying so belongs here too, since this comment is where the next person hitting a red gate will look.

If you would rather have the label, it is if: "!contains(github.event.pull_request.labels.*.name, 'api-break-approved')" on the job, and it needs a maintainer commit for the same permissions reason.

#
# Why WARN and not ERR
# --------------------
# `fail-on: ERR` would leave four of the changes COMPATIBILITY.md forbids
# passing silently, because oasdiff rates them WARN, and on this spec that is
# most of the "no removing a route or a field" rule: only `status` is ever
# `required`, so `key` (the IBE user secret key the endpoint exists to return),
# `proofStatus`, `pubSignKey` and the rest are optional, and removing or
# renaming an optional response property is WARN. So is removing a request
# parameter. Measured on this spec against oasdiff v1.26.1:
#
# mutation fail-on ERR fail-on WARN
# optional response property removed (`key`) passes fails
# optional response property renamed passes fails
# required path parameter removed passes fails
# response enum value added passes fails
#
# The fourth is the rule COMPATIBILITY.md gains alongside this gate; see below.
#
# WARN adds 31 checks on top of ERR's 213. All but one are changes
# COMPATIBILITY.md already forbids (request-parameter-removed,
# request-property-removed, response-body-media-type-schema-removed, and the
# constraint-narrowing *-set family). The exception is
# response-property-enum-value-added: adding a value to SessionStatus or
# ProofStatus fails this gate. That is deliberate, and COMPATIBILITY.md now
# says so too. A client that switches on the enum without a default branch
# breaks on a value it has never seen, so a new status is a /v3 change (or an
# x-extensible-enum one), not an additive one. It is the one rule here that
# only WARN enforces, so it is also the first casualty of a revert to ERR;
# pg-pkg/tests/api_gate.rs pins it.
#
# Two more checks are opt-in: they rate ERR but only run when named, so they
# need the include-checks input below. Without it, changing a 401 to a 403 and
# dropping an enum value from a response both pass. Keep include-checks and
# fail-on in step with the local-repro command in CLAUDE.md, or a local run
# quietly disagrees with CI.
#
# Not covered: the gate compares documented paths, and the spec documents the
# canonical /v2/request/... paths only (see its "Path prefix aliases" note), so
# dropping the /v2/irma/... alias handlers before #257's deprecation has run
# passes this gate. That one stays a review rule.
#
# There is deliberately no `on: paths:` filter. A path-filtered job reports no
# status on the PRs it skips, so as a required check it would leave every PR
# that does not touch the spec pending forever. The job is two checkouts and one
# container, so it just always runs.
#

on:
pull_request:
# `edited` included for base retargets (e.g. a stacked PR's base merging):
# this job's verdict depends on the base sha, and without `edited` a stale
# verdict stays attached to the unchanged head sha.
types: [opened, synchronize, reopened, edited]

permissions:
contents: read

jobs:

breaking-changes:
name: API breaking changes (oasdiff)
runs-on: ubuntu-latest
steps:
- name: Check out the pull request
uses: actions/checkout@v6
with:
# The spec is all this job reads, and the oasdiff container gets the
# workspace mounted; do not leave a push token in .git/config for it.
persist-credentials: false
- name: Check out the base spec
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
persist-credentials: false
- name: Diff the spec against the base branch
# Pinned by sha because this step is the gate's verdict. The tag pins
# the engine: v0.1.10 is FROM tufin/oasdiff:v1.26.1, so a local
# `oasdiff v1.26.1` reproduces what CI decides here.
uses: oasdiff/oasdiff-action/breaking@0ab8ad204b00d25acc5ae87106281433e288d0c1 # v0.1.10
with:
base: base/pg-pkg/api-description.yaml
revision: pg-pkg/api-description.yaml
fail-on: WARN
# Both of these rate ERR but are opt-in, so they do not run unless
# named: a changed non-success status (401 -> 403) and an enum value
# dropped from a response property.
include-checks: response-non-success-status-removed,response-property-enum-value-removed
# Do not upload the two specs to oasdiff.com for a side-by-side
# review page. The default is `true`; the detection and the inline
# annotations work without it, so nothing leaves CI.
review: false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, needs your hands (the App has no workflows: write): add github-token: '' to this with: block.

The input is left at its action default, which is ${{ github.token }}, so the job's GITHUB_TOKEN is handed to the third-party oasdiff container even though nothing uses it. Confirmed in the real run on this branch: the docker invocation carries -e "INPUT_GITHUB-TOKEN" and "***" as its last positional argument. Both post_review_comment call sites in the action's entrypoint at the pinned sha are guarded by [ "$review" != "false" ], so with review: false the token is never read.

That makes it a free removal, and it is the same threat model this step already reasons about two lines up, where persist-credentials: false is set on both checkouts because the workspace is mounted into that container. No behaviour change.

5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ Migrated from the dobby memory repo (`encryption4all/dobby`). This file is the s
- JS-reader gotchas the Node gate ran into, all still true of `@e4a/pg-wasm` 0.6.1 and `@e4a/pg-js` 1.11.0/2.3.3: (a) `@e4a/pg-wasm`'s default (bundler) entry does `import * as wasm from "./index_bg.wasm"`, which plain Node cannot resolve — import `@e4a/pg-wasm/web` and pass the module bytes to its default export, resolving them via `new URL('index_bg.wasm', import.meta.resolve('@e4a/pg-wasm/web'))` since the `.wasm` file is not in the package's `exports`; (b) `Unsealer.unseal()`/`StreamUnsealer.unseal()` *consume* the unsealer (wasm-bindgen `__destroy_into_raw`), so a tidy `free()` afterwards is a double free reported as `null pointer passed to rust`, which reads exactly like a corrupt container; (c) `pg-js` is stream-mode only in both directions — `toBytes()` seals with `sealStream` and its decrypt path only ever builds a `StreamUnsealer`, so a memory-mode container fails with `mode is not supported: InMemory { size: N }`; (d) `pg-js` 1.x discards what `StreamUnsealer.unseal()` returns and reports `public_identity()` instead, so it never surfaces the private signing policy of a `*-privsig` container and its `sender.raw` is the bare header policy rather than 2.x's `{public, private?}`; (e) `pg-js`'s decrypt path needs a PKG at `pkgUrl` for exactly two GETs — `/v2/sign/parameters` for the verifying key and `/v2/irma/key/<ts>` (bearer token) for the USK — which is why the gate can drive it offline from the artifact's own `vk.json`/`usk-*.json`.
- 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.
- `Run wasm tests in browsers` flakes, and the error names the wrong culprit. `Error: missing field 'chunk'` is `wasm-bindgen-test-runner` failing to parse a truncated webdriver reply; the cause is the line above it, `[SEVERE]: Timed out receiving message from renderer: 30.000`. Read the driver stderr before suspecting the test. The matrix is fail-fast, so one browser timing out reports the other two as failures when they were cancelled: check each job's own conclusion, not the summary. Seen on the same sha passing at 07:45 and failing at 07:48 (runs 30432815599 and 30432995207 on #269, a docs-only commit). Re-run rather than debug, and note that `dobby-coder` cannot: `POST /actions/runs/{id}/rerun-failed-jobs` is 403 for the App, so a maintainer has to click it, or a fresh push has to supersede the run.
- `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:<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. 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`.
- `pg-pkg/api-description.yaml` is the pinned v2 HTTP contract and is additive-only (see `COMPATIBILITY.md`). Its breaking-change gate is `.github/workflows/api-diff.yml`, job `API breaking changes (oasdiff)`, which runs on every PR; `pg-pkg/tests/api_gate.rs` is its executable spec. That test is the executable record of what the gate stops; it mutates the spec 19 ways and asserts each verdict. The verdict test skips when `oasdiff` is not on `PATH`, which is the case in CI; the other two run everywhere, one checking that every mutation still finds its anchor and one reading `fail-on`/`include-checks`/the pinned action ref back out of the workflow YAML, so editing the step and the constants apart fails the suite instead of quietly weakening the gate. The action ref is pinned because it is what selects the engine version (`v0.1.10` is `FROM tufin/oasdiff:v1.26.1`), so bumping the action silently re-measures every verdict. Those anchors are multi-line raw strings matched against the spec read off disk, which is why root `.gitattributes` marks `pg-pkg/api-description.yaml -text`: rustc normalises CRLF inside raw strings but `read_to_string` does not, so without it a Windows checkout fails `every_mutation_still_applies` on a clean tree. Run it (and read it) before touching the gate's settings: `go install github.com/oasdiff/oasdiff@v1.26.1 && cargo test --manifest-path pg-pkg/Cargo.toml --all-features --test api_gate`.
- The oasdiff gate's settings are **not** self-evident and `--fail-on ERR` alone fails open. `fail-on: WARN` is deliberate: oasdiff rates removing or renaming an *optional* response property, and removing a request parameter, as WARN, and this spec marks only `status` as `required`, so at ERR the gate silently passed a removed `key` (the IBE user secret key `/v2/request/key` exists to return), a renamed `proofStatus` and a dropped `timestamp` parameter. Two more, a changed non-success status (401 to 403) and a dropped response enum value, rate ERR but are **opt-in**, so they only run when named in `include-checks`. Of the 31 WARN checks, `response-property-enum-value-added` is the only one that fires on something `COMPATIBILITY.md` does not already forbid, which is why that document now names a new response enum value as non-additive too. Reproduce a verdict with the exact flags the action's entrypoint builds (`oasdiff/oasdiff-action/breaking@v0.1.10` is `FROM tufin/oasdiff:v1.26.1`, so the pinned tag is what makes a local run authoritative): `git show origin/main:pg-pkg/api-description.yaml > /tmp/base.yaml && oasdiff breaking /tmp/base.yaml pg-pkg/api-description.yaml --allow-external-refs=false --composed=false --fail-on WARN --include-checks response-non-success-status-removed,response-property-enum-value-removed`. Two traps: `oasdiff --version` prints `oasdiff version main` after a `go install` of a tag because the version comes from release ldflags (the code is still the tag), and `--fail-on` takes `ERR`/`WARN` while `oasdiff checks --severity` takes `error`/`warn`/`info`. `--severity ERR` is a usage error, so `oasdiff checks --severity ERR | wc -l` counts the help text instead and badly undercounts the tier (it is 213 error checks, 31 warn, 265 info). The spec has no external `$ref`s, so `allow-external-refs` stays at its safe (SSRF-guarding) default. The gate only sees paths the spec documents, and the spec documents canonical paths only, so dropping the `/v2/irma/...` alias handlers (#257) passes it.
- 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`. Measured exception, worth trying before handing the sync over: the App pushed `ce0fc59` on this branch, a merge whose diff against its first parent added main's 64 new `build.yml` lines. That merge needed no resolution inside `build.yml` — it took main's side whole, so the blob it committed already existed in the repo. Try the merge and read the remote's answer; only escalate on an actual rejection.

## Dependencies

Expand Down
Loading
Loading