ci: tune the oasdiff gate's severity settings and pin them with a test - #203
Conversation
`fail-on: ERR` with no `include-checks`, the setting the gate landed with in #201, fails open on ten of the changes the spec's contract forbids. Measured against oasdiff v1.26.1 by mutating the real spec one way per rule: a `401` that becomes a `403` and a dropped response enum value rate ERR but are opt-in, and a removed or renamed optional response property, a removed request parameter, a removed request property and the constraint-narrowing `*-set` family all rate WARN. Cryptify's routes are unversioned, so each of those reaches every pinned client the moment it deploys. Adds `mod api_gate_tests` in `src/main.rs`, which runs the engine with the flags the action's entrypoint builds and asserts stop-or-pass per mutation. It fails on the shipped settings and names every gap; it passes on `fail-on: WARN` plus the two opt-in checks. The engine is not on any runner, so it skips in CI while `every_api_gate_mutation_still_applies` still runs there. The workflow itself is in a PR comment: the App has no `workflows: write`. Refs #202. Part of encryption4all/postguard#247 (workstream C). Co-Authored-By: Claude Opus 5 <[email protected]>
|
There was a problem hiding this comment.
VERDICT: request-changes
Rules Dobby 2, cycle 1. Rule sweep over the rules that can fire on this diff (Rust test module, CLAUDE.md prose, a CI gate delivered as a patch-in-comment, PR hygiene), merged with the review findings. One blocker, four nits.
The blocker is one root cause with two faces. This branch says the gate's settings are fail-on: WARN plus the two opt-in checks — in FAIL_ON/INCLUDE_CHECKS, in the module doc comment, and in CLAUDE.md as present-tense fact. The committed .github/workflows/api-diff.yml on this branch still says fail-on: ERR with no include-checks (line 40), and cargo test --all-targets is green anyway. So on merge the repo ships a passing test certifying settings the gate does not use, plus agent-loaded docs asserting the same, while the real gate keeps passing the ten forbidden mutations this PR measured. That is the fail-open the module says it exists to prevent. Nothing here needs oasdiff, so the assertion that closes it runs in CI.
Verified rather than argued, on 0af8069:
git show pr203:.github/workflows/api-diff.yml→fail-on: ERR, noinclude-checks.review: falseand the pinned@v0.1.10tag are both correctly kept, in the committed file and in the proposed YAML.- The new test's CRLF failure was reproduced, not reasoned about:
sed -i 's/$/\r/' api-description.yamlthencargo test --all-targets api_gate→every_api_gate_mutation_still_appliespanics atsrc/main.rs:3945on the/metrics:\nanchor. Reverted with the inversesed, working tree clean.rustcon a CRLF source file confirms raw-string literals are normalised to LF, so the anchors and the file on disk disagree in exactly one direction. - The check counts were re-measured on a freshly installed
oasdiff v1.26.1:-s erroris 212 and-s warnis 30 by JSON length; the text output is 214 and 32 rows, which is where 213/31 comes from (header row plus trailing newline). The reviewer's arithmetic holds. - All CI green, nothing in flight: Build amd64/arm64, Conventional Commit, Rust quality, oasdiff breaking changes.
Rules checked and clean: oasdiff-action-uploads-specs-by-default (review: false present, tag pinned, allow-external-refs left at its default, WARN + the two opt-in checks is the prescribed setting), actions-default-shell-has-no-pipefail (the proposed YAML has no run: blocks at all), conventional-commit-pr-titles, pr-close-issue-keywords (Closes #202 in the body, same repo, targeting main), default-prs-to-ready (draft is justified and the reason is stated — a maintainer must apply the workflow), single-repo-lessons-belong-in-that-repos-claude-md (the measurement went to this repo's CLAUDE.md, nothing to shared rules/), avoid-self-trigger-loops, rust-run-cargo-fmt-before-push.
Not flipping this out of draft, and not touching the nits myself, since the blocker needs a code change in the same file.
Review on #203: `FAIL_ON` and `INCLUDE_CHECKS` described settings nothing tied to the committed workflow, which still says `fail-on: ERR` with no `include-checks`, and `cargo test` was green anyway. So the branch shipped a passing test certifying a configuration the gate does not use, which is the fail-open the module exists to catch. `the_workflow_uses_the_settings_this_module_pins` reads `.github/workflows/api-diff.yml` and asserts its two step inputs are the two constants. It needs no `oasdiff`, so it runs on every runner. It is red until a maintainer applies the workflow patch from the PR comment, which makes the merge order enforced rather than documented. Also from the review: CLAUDE.md now says the settings are pending rather than present-tense fact and names where the patch lives; the tier counts are the `-f json` ones (WARN adds 30 on top of ERR's 212, not 31 on 213 — the table output has a header row and a trailing blank); the versioned-route mutation goes through `once` like every other anchor, so a renamed `getUsage` fails loudly instead of no-op'ing; and a root `.gitattributes` marks the spec `-text` so the multi-line anchors survive a Windows checkout. Refs #202. Co-Authored-By: Claude Opus 5 <[email protected]>
`the_workflow_uses_the_settings_this_module_pins` pinned the gate's two
inputs but not whether the job is ever triggered, which is the one
fail-open it could not see: settings that read correctly on a gate that
never fires. The workflow's own header comment names the foreseeable edit
("There is deliberately no `on: paths:` filter"), and a later `paths:`
would skip the gate on every PR that does not touch the spec with the
test still green.
Asserts a `pull_request` trigger, no `paths:`/`paths-ignore:` filter, and
no `if:` on the job. The trigger check is line-based over the `on:` block
rather than a byte-exact match, so a rewrite between the mapping, list
and scalar forms of `on:` does not go red for nothing.
There was a problem hiding this comment.
VERDICT: approve
Rules Dobby 2, cycle 2. Rule sweep over the rules that can fire on this diff (oasdiff gate settings, patch-in-comment docs accuracy, Actions YAML, Rust/test hygiene, byte-exact anchors, PR hygiene, prose), merged with the re-review findings. No blocking issues. The one new nit is fixed on the branch.
Cycle 1's five findings: all verified fixed
Re-measured against HEAD rather than taken on trust:
- Workflow/constants drift —
the_workflow_uses_the_settings_this_module_pinsnowinclude_str!s the workflow and compares its two inputs againstFAIL_ON/INCLUDE_CHECKS. Confirmed it is the only failing test (156 passed, 1 failed) and that its message names both sides. - CLAUDE.md present tense — the section now says the committed file is still
fail-on: ERR, names where the patch lives, and says to tighten the wording in this same PR once the maintainer's commit is on the branch. That is whatpatch-in-comment-gate-is-not-live-yetasks for. - 213/31 off by one — CLAUDE.md and the YAML header both read 212/30 now, matching
oasdiff checks -f json. - Byte-exact anchors on Windows — root
.gitattributeswithapi-description.yaml -text. Checked whether the workflow YAML needs the same treatment, since the moduleinclude_str!s it too: it does not.str::lines()strips a trailing\rand every extracted value istrim()ed, and the one substring assert (uses: oasdiff/oasdiff-action/breaking@) contains no newline. So the single-textentry is the right scope, not an omission. - Two bare
replacens — both go throughonce, which asserts the anchor occurs exactly once.
Also spot-checked against the gate rules: review: false and the pinned @v0.1.10 tag survive into the proposed YAML, allow-external-refs stays at its false default, the YAML has no run: blocks so there is no pipefail exposure, and response-property-enum-value-added — the one rule fail-on: ERR cannot enforce at all — is pinned as a Gate::Stops mutation rather than left out.
The new nit, fixed here rather than sent back (d9e71fa)
The coupling test pinned the gate's two inputs but not that the job is ever triggered, so the one fail-open it could not see was a gate that is present and correctly configured but never fires. Not hypothetical drift: the proposed YAML's own header names the foreseeable edit ("There is deliberately no on: paths: filter"), and a later paths: filter would skip the gate on every PR that does not touch the spec with the test still green.
Added three asserts — a pull_request trigger, no paths:/paths-ignore:, no if: on the job — and provoked each rather than reasoning about it:
| probe | result |
|---|---|
on: pull_request → on: workflow_dispatch |
fails, "no longer triggers on pull_request" |
paths: [api-description.yaml] added to the trigger |
fails, "has a path filter on its trigger" |
if: false on the breaking-changes job |
fails, "has an if: condition" |
The trigger check reads the on: block line by line instead of matching the mapping form byte-for-byte, because on: is legal as a mapping, a list or a bare scalar and a rewrite between those forms changes nothing about when the gate runs. A check that cries wolf is the one that gets deleted.
Verified after the change: cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings clean, cargo test --all-targets still 156 passed / 1 failed with the same settings-mismatch message, and 157 passed / 0 failed with the comment's YAML applied — so the "apply the comment and it goes green" promise still holds with the new asserts in place. oasdiff v1.26.1 was on PATH for these runs, so the mutation test did the real work rather than skipping.
Non-blocking, not acted on
The body's "the gate would go on passing the ten forbidden mutations below" is loose: nine of those ten are in the "forbidden by the contract" table, and the tenth (a new response enum value) sits in the additive table as a change this gate deliberately blocks anyway. The same phrasing is in the test's failure message. Left alone — the body devotes a whole section to exactly that distinction, so a reader is not misled, and churning a failure message over one word is not worth a commit.
On prose, the formatting flags did not survive a recount. PR body: 1,799 words, zero em dashes, 5 inline-header bullets, and of 27 bold spans 16 are **passes**/**fails** cells inside the two tables marking which verdicts are the gaps — 11 prose bold spans, against 11 in the base CLAUDE.md's 1,712 words. Added CLAUDE.md prose: 633 words, zero em dashes, one bold span. No banned vocabulary in any of the three artifacts. The **X:** bullets in "Review round 1" label five distinct findings with their fixes, which is a table, not decoration.
Ready for review, while red
Flipping this out of draft even though Rust quality is red, because the red is the deliverable and only a maintainer can clear it. default-prs-to-ready keeps drafts for work that is unfinished or unsure; this is neither, and leaving it draft would deadlock — nobody applies the YAML, so CI never greens, so it never gets flipped.
What is left is one maintainer action: commit the YAML from the comment onto this branch (not onto main), which turns the suite green. Two things after that which are out of the App's reach: keeping oasdiff breaking changes a required check for main, and tightening the CLAUDE.md paragraph back to plain present tense in this same PR once the workflow file and the docs line sit in the same tree.
Dobby's YAML from the #203 comment, applied verbatim (the App cannot push workflows). This is the half its guard test was failing on: the_workflow_uses_the_settings_this_module_pins reads this file and asserts fail-on and include-checks match the constants the test module pins, so the settings and the mutation test that certifies them cannot land apart. At fail-on=ERR with no include-checks the gate passed ten of the changes this spec's contract forbids.
|
Workflow half applied verbatim — that was the whole failure. Worth calling out that this test is a better pattern than the hand-off we have been using elsewhere. Every other repo's workflow patch sits in a PR comment and nothing notices if it is never applied — which is how postguard#265 merged with an inert crate and postguard-js#137 shipped with a release path nobody had wired. Here the missing half turns Also noting your measurement: ten forbidden changes passed at |
The mutation table for cryptify's spec, and the settings it implies. Same measurement as encryption4all/postguard#269, redone here rather than assumed: the answer is the same on the settings, and different on the one WARN check that fires on a permitted change.
Closes #202. Part of encryption4all/postguard#247 (workstream C).
The workflow file is in a comment below, not in the diff: the App has no
workflows: writeon this repo, so.github/workflows/api-diff.ymlneeds a maintainer to apply it. What is committed here ismod api_gate_testsinsrc/main.rs, which pins what the gate's settings actually deliver, plus the CLAUDE.md section that records the measurement.This PR is red on purpose, and applying that comment onto this branch is what turns it green.
the_workflow_uses_the_settings_this_module_pinsreads the committed workflow and asserts itsfail-onandinclude-checksare the constants the module pins. Right now they are not, socargo testfails withwhich is the point: without it the branch would merge green while certifying settings the gate does not use, and the gate would go on passing the ten forbidden mutations below. The maintainer's commit and the settings land together or neither does.
The mutation table
Measured against oasdiff v1.26.1 (the engine
[email protected]pins) on the realapi-description.yaml, with the flags the action's entrypoint builds.ERR+incisfail-on: ERRwith the two opt-in checks named;WARN+incis what this PR moves to.Forbidden by the contract, so the gate should stop all of these:
401becomes403maxLength)Additive, so the gate must let them through:
What the table says
Nine of the fifteen forbidden changes pass at the shipped setting. Three of those are the opt-in checks; the other six are WARN-rated. So
fail-on: ERRwith noinclude-checksleaves the request side of "no removing or narrowing" completely unguarded, and the optional half of the response side too.The lower exposure this repo was expected to have is real, but partial. #202 noted 34
required:occurrences here against pg-pkg's one, and that does buy something: a removed required response property is an ERR here, where in pg-pkg it was a WARN that slipped through. ButUploadStatus.prev_tokenandprev_offsetare optional, and they are exactly what a resuming client reads on the recovery path. Request parameters and request properties have norequired:-driven escape at all:request-parameter-removedandrequest-property-removedare WARN whatever the spec looks like. The*-setfamily behaves the same way, confirmed here with amaxLength: 64added to/usage'semailquery parameter.And unversioned routes make each of these land harder than in pg-pkg. There is no
/v3to redirect a breaking change to, so the gate is the only thing between a spec edit and every pinned client.The one WARN check that fires on a permitted change
Same check as pg-pkg:
response-property-enum-value-added. Ten additive mutations ran throughWARN+incand only that one flagged. On this spec it hitsUploadSessionNotFound.reasonandPayloadTooLarge.limit.The pg-pkg answer does not transfer cleanly, so here is what I found before landing on the same decision.
Nothing in this org actually switches on those codes today.
pg-jsreads the field asparsed.reason ?? 'unknown'(packages/pg-js/src/api/cryptify.ts) and hands it toUploadSessionExpiredErroras a plain string;postguard-tb-addonpasses it straight through to one UI message. pg-pkg'sSessionStatusdrives client control flow, so "a new value breaks an exhaustive switch" was concrete there. Here it is hypothetical. On the face of it that argues for suppressing the check.Two measurements argue the other way:
--warn-ignorecannot express "ignore this check". It takes a file, and matching is by asking whether an ignore line contains the rendered change text, so an entry has to spell out the full lowercase string per affected operation and name the specific new value.response-property-enum-value-addedas an ignore line suppresses nothing. Neither does.*. Verified on v1.26.1. There is no standing setting to add.x-extensible-enum, which oasdiff's message suggests, makes it skip the property entirely. Adding a value passes, and so does removing one. That isresponse-property-enum-value-removed, which is one of the two checks this PR turns on. It trades a false positive for a genuine gap.So there is no cheap suppression, and the check costs one red run on the day someone adds an error code. I kept it, and CLAUDE.md now says a new response enum value is a breaking change here and why. It is also the only rule the gate has that WARN alone enforces, so it is the first thing a revert to ERR drops, silently;
mod api_gate_testspins it so it cannot go quietly.If you would rather have the check gone, the honest version is the three ignore lines at the time you need them, not a setting. Happy to switch if you disagree.
The regression test
mod api_gate_testsinsrc/main.rs, inline per this repo's convention (no library target, so notests/dir). It mutates the real spec one way per rule, shells out tooasdiffwith the flags the action's entrypoint builds, and asserts stop-or-pass. Its verdict is CI's verdict.On the shipped settings it fails and names every gap:
On the new ones it passes. It rides the existing
Rust qualityjob rather than a new one, since that runscargo test --all-targets. It needs theoasdiffbinary, which no runner has, so it skips there with a message saying how to install it. The module's other two tests need nothing but the repo tree and so do run on the runner:every_api_gate_mutation_still_appliesfails if a spec edit strands one of the anchors, andthe_workflow_uses_the_settings_this_module_pinsfails if the workflow and the constants disagree. Locally, withgo install github.com/oasdiff/[email protected], all three do the real work.Kept from the current workflow, deliberately
review: false, the unpinned-but-tagged@v0.1.10, the plainon: pull_requesttrigger, and both checkouts as they are. postguard#269's copy differs on three of those (sha-pinned action,types: [..., edited]for base retargets,persist-credentials: false), and each is defensible, but none of them is this issue. Theeditedtrigger one is worth its own issue: it has already bitten this repo once, on #201 itself. Say the word and I will file it.Verified locally
cargo fmt --all -- --checkandcargo clippy --all-targets -- -D warningsare clean.cargo test --all-targetsis 156 passed and 1 failed, the one being the workflow-coupling test described above; with the comment's YAML copied over.github/workflows/api-diff.ymlin the working tree it is 157 passed, and the tree was restored afterwards. Both runs were done with and withoutoasdiffonPATH, so the skip path is exercised too. Its three other failure modes were each provoked rather than reasoned about: a duplicatedfail-on:, a workflow that no longer uses the action, and the settings matching.actionlint 1.7.7is clean on the YAML in the comment; it has norun:blocks, so there is no shell to guard.Review round 1
fail-on: ERR, and where the patch is. It says to tighten the wording back in this same PR once the maintainer's commit is on the branch.-f jsongives 212 error and 30 warning (the table output adds a header row and a trailing blank, which is where 213/31 came from). Corrected in CLAUDE.md and in the workflow header comment..gitattributeswithapi-description.yaml -text.replacens: both go throughoncenow, so a renamedgetUsagefails loudly.