fix(ci): compare the wire schema against the run's base revision #59

Merged
natsukium merged 11 commits from fix/proto-compat-ci-base-19 into main 2026-09-04 03:32:47 +09:00
Owner

Fixes #19.

buf breaking --against '.git#ref=HEAD' compared the schema with
itself, and the check silently passed whenever the Nix store path had
no .git to compare against — a breaking wire edit could land on
main without CI ever catching it.

Summary

  • scripts/proto/compat.sh now selects the comparison base from the
    triggering event (PR base / github.event.before / prior commit or
    release tag for direct pushes) instead of comparing HEAD with
    itself, and fails closed when the base can't be resolved (shallow
    clone, missing history) rather than passing.
  • Buf's own exit status is classified so a compile failure or a
    missing buf binary fails the gate even under a live
    pre-release acknowledgment.
  • An acknowledgment line covers only the base it names: it still
    rejects a candidate that breaks a field the base only just added,
    and still accepts a candidate whose base grew compatibly since the
    acknowledgment was written.
  • PROTOCOL_MINOR is pinned to the minor-version ledger with a test,
    and tag builds compare against the release baseline
    (proto-baseline/) instead of the acknowledgment path; the release
    baseline's own CI wiring is tracked as a follow-up in
    docs/backlog.md since no workflow runs on tags yet.
  • Acknowledgments for an intentional pre-release break live in the new
    crates/felis-protocol/proto/BREAKING.md, keyed to the base
    revision they cover.
  • docs/reference/testing.md and docs/explanation/architecture/ipc.md
    describe the gate's base-selection rules and the acknowledgment
    mechanism; the wire gate's tag path is documented as script behavior
    rather than a separate CI job.

Doc cascade

Reference (docs/reference/testing.md, docs/reference/ipc.md) and
explanation (docs/explanation/architecture/ipc.md) twins updated
together per the doc-cascade skill, plus docs/backlog.md for the
open release-baseline follow-up; grepped docs/ for the changed terms
(base selection, acknowledgment, PROTOCOL_MINOR) to confirm no stale
references remain.

Verified: just check green (fmt, clippy, nextest, deny), pi review PASS.

Fixes #19. `buf breaking --against '.git#ref=HEAD'` compared the schema with itself, and the check silently passed whenever the Nix store path had no `.git` to compare against — a breaking wire edit could land on `main` without CI ever catching it. ## Summary - `scripts/proto/compat.sh` now selects the comparison base from the triggering event (PR base / `github.event.before` / prior commit or release tag for direct pushes) instead of comparing HEAD with itself, and fails closed when the base can't be resolved (shallow clone, missing history) rather than passing. - Buf's own exit status is classified so a compile failure or a missing `buf` binary fails the gate even under a live pre-release acknowledgment. - An acknowledgment line covers only the base it names: it still rejects a candidate that breaks a field the base only just added, and still accepts a candidate whose base grew compatibly since the acknowledgment was written. - `PROTOCOL_MINOR` is pinned to the minor-version ledger with a test, and tag builds compare against the release baseline (`proto-baseline/`) instead of the acknowledgment path; the release baseline's own CI wiring is tracked as a follow-up in `docs/backlog.md` since no workflow runs on tags yet. - Acknowledgments for an intentional pre-release break live in the new `crates/felis-protocol/proto/BREAKING.md`, keyed to the base revision they cover. - `docs/reference/testing.md` and `docs/explanation/architecture/ipc.md` describe the gate's base-selection rules and the acknowledgment mechanism; the wire gate's tag path is documented as script behavior rather than a separate CI job. ## Doc cascade Reference (`docs/reference/testing.md`, `docs/reference/ipc.md`) and explanation (`docs/explanation/architecture/ipc.md`) twins updated together per the doc-cascade skill, plus `docs/backlog.md` for the open release-baseline follow-up; grepped `docs/` for the changed terms (base selection, acknowledgment, PROTOCOL_MINOR) to confirm no stale references remain. Verified: just check green (fmt, clippy, nextest, deny), pi review PASS.
The only `buf breaking` in CI ran as a pre-commit hook under `nix flake
check`, which evaluates a store copy with no `.git`; the hook's
work-tree guard exits 0 there, and even with history a CI checkout is
HEAD itself, so the schema was compared with itself or not at all. A
wire-incompatible `felis.proto` edit could therefore merge.

The gate is now a `proto-compat` job that runs `scripts/proto/compat.sh`
on a full-history checkout. The script resolves a base from the event
(a PR's merge-base with its base branch, a push's `event.before`, the
committed release baseline on a tag build or a null-sha push) and
fails by name when it cannot: a shallow clone or an unfetched ref is
exactly the case the gate exists for, so no path turns missing history
into a pass. An intended pre-release break is acknowledged in
`crates/felis-protocol/proto/BREAKING.md` by naming the base it was
written against; `SKIP=buf-breaking` was rejected because CI never
sees it, and a commit trailer because it is not in the PR diff and
does not survive a squash. The acknowledgment also holds when its
revision is an ancestor of the base with no `.proto` change between
the two, so a rebase over prose-only commits does not invalidate it
while an acknowledgment from an earlier break stops matching as soon
as the schema moves. Tag builds ignore it: past the first final
release a break owes a `PROTOCOL_MAJOR` bump and a new baseline.

The baseline lives at `proto-baseline/`, outside the Buf module, since
a copy inside `crates/felis-protocol/proto` would be a second
declaration of the same package. The `first-release` line stands in
for it until the first final release publishes one; the release job
reconciles it with the published asset before a final tag.

The negative case (a breaking schema fails) is proven on every run by
`compat_test.sh`, a throwaway-repo exit-code suite, rather than once
by a throwaway PR: Forgejo Actions has no expect-fail step, and a
permanent negative fixture would need a second Buf module. `just
check` runs both the suite and the gate, keeping the local mirror
1:1 with pr.yml. The pre-commit hook stays as the developer's early
warning (work tree against HEAD), which is the one place that
comparison means something, and its comment no longer claims to be
the CI gate.

Refs #19
`buf breaking` sees field numbers, not which minor authorized them, so
a `PROTOCOL_MINOR` bump with no ledger row in `docs/reference/ipc.md`
had nothing to catch it; the ledger is the review gate for "is this
really additive", and an unrecorded minor is a row nobody reviewed.

`MINOR_LEDGER` mirrors the table's first column, a compile-time
assertion ties its last entry to `PROTOCOL_MINOR`, and a test parses
the doc's table and requires the same contiguous set of minors. The
doc is read at test time rather than `include_str!`-ed so the crate
stays packageable on its own.

Refs #19
The testing reference claimed `nix flake check` covered `buf breaking`,
which it cannot (no history in the sandbox). The reference now lists
the `proto-compat` job, the base each run compares against, the
acknowledgment rule, and the three gates that together cover
compatibility (Buf's field comparison, the ledger sync test, the
row-codec and carrier golden vectors) with what each cannot see; the
IPC reference states how a pre-freeze break is acknowledged and what
the first final release changes; the IPC explanation carries the
argument for a file over a `SKIP` env or commit trailer, for a
dedicated job over the flake check, for the baseline living outside
the Buf module, and the revisit triggers. The extend-ipc skill gains
the `just proto-compat` step so a proto edit meets the gate locally.

Refs #19
`compare_with_git_base` treated every non-zero `buf breaking` exit as a
wire break and then let a live `base:` acknowledgment pass it. buf
exits 1 for an execution error (an unfetchable ref) and 127 when
absent, and a schema that does not compile exits 100 with a `COMPILE`
annotation; on a PR carrying an intended break, or on main between
that merge and the next proto change, each of those went green with
no comparison made. Only exit 100 with wire-break annotations is now
a result the acknowledgment may cover; everything else dies by name.

The self-test ran every event path from a HEAD whose one break was
acknowledged against every candidate base, so it expected 0 whatever
revision the script chose: comparing HEAD with itself, or with the PR
base tip instead of the merge-base, passed the suite. Each path now
also runs from an unacknowledged break (the PR base branch carries
HEAD's schema, so only the merge-base fails) and asserts the printed
`comparing against <sha>` line; mutating any of the four base
selections, or collapsing the buf status check, fails the suite.

Refs #19
The testing and IPC references described a tag build and a release
job as gates CI runs, but no workflow in this repository runs on
tags and no release workflow exists; `scripts/proto/compat.sh` has
the tag path and its self-test proves it, and that is all a reader
can rely on today. The references now say so, the release-workflow
obligations (run the tag path before a final release, reconcile
`first-release` and the baseline with the published releases, commit
the baseline afterwards) and the open post-release major question
move to the backlog with their blocker, and the open-issue numbers
leave the reference quadrant. The baseline-placement argument lived
in both twins; the reference keeps the fact and links the
explanation.

`just check` gained the wire-compatibility self-test and gate, so
CLAUDE.md and the implement-feature skill list them and the two
conditions they add: `origin/main` must be fetched, and the recipes
are bash-shebang, so a Windows host runs the cargo halves directly.

Refs #19
ack_covers_base required the acknowledged base to be byte-identical
(`.proto`-diff clean) to the run's base, but a `pull_request` run
measures from the merge-base while the `push` after the merge
measures from main's tip. Any sibling PR that merged an unrelated,
purely additive schema change in between moved the two out of
byte-identical range, so a break honestly acknowledged and green on
the PR turned red on the post-merge push with no recourse but a new
acknowledgment for a schema that never actually broke against it.

The check now runs `buf breaking` between the acknowledged revision
and the run's base instead of diffing the `.proto` text: an
acknowledgment covers the base whenever nothing wire-incompatible
landed on top of it, which is what a rebase or sibling merge that
only adds fields actually preserves. A genuinely incompatible change
on top of the acknowledged base still fails the check and retires
the acknowledgment, same as before.

Refs #19

Assisted-by: Claude Code
fix(ci): reject a candidate that breaks a field the base only added
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
build / build felis (aarch64-darwin) (pull_request) Successful in 1m31s
bench / Criterion regression gate (pull_request) Successful in 2m34s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m14s
pr / nix flake check (pull_request) Successful in 16s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m21s
pr / generated code is current (pull_request) Successful in 1s
pr / wire schema is compatible with the base (pull_request) Successful in 7s
pr / cargo clippy (Windows cross) (pull_request) Successful in 28s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m11s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
pr / cargo check (MSRV) (pull_request) Successful in 28s
pr / cargo test (Windows) (pull_request) Successful in 12m42s
pr / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
pr / frontend smoke (Windows) (pull_request) Successful in 7m0s
9a9900ed4b
ack_covers_base only checked that the base's own history grew
compatibly from the acknowledged sha (buf breaking sha->base clean).
It never checked the candidate against that growth, so once a base
picked up a new field after the acknowledged sha -- any additive
sibling change -- an unrelated, unacknowledged break to that same new
field rode through: the acknowledged break against the old fields and
the new break against the added field showed up together in the
candidate-vs-base diagnostics, but the sha->base check alone couldn't
tell them apart, and nothing else in the gate looked at what actually
broke. A rebase conflict resolution that quietly retypes a field the
base introduced would pass silently as long as some ack line named a
purely-additive ancestor.

The check now also verifies that every violation in the candidate-vs-
base diagnostics also appears in the candidate-vs-sha diagnostics
(diagnostic_identity strips position fields so an unrelated field
added earlier in the file doesn't shift line numbers and produce a
false mismatch). A field the base added since the acknowledged sha
has no counterpart in the sha comparison, so a candidate that breaks
it is no longer covered by an old acknowledgment.

Refs #19

Assisted-by: Claude Code
An acknowledgment line committed while the schema stayed compatible
becomes part of the base by the time the next change measures against
it. The moved-base rule then covered a later, undeclared break: the
base is an additive-only descendant of the acknowledged sha, and the
break shows up identically against both, so the gate passed a change
whose own diff acknowledged nothing — losing the review signal the
file exists to force.

An acknowledgment now speaks only for the change that introduces it,
so lines present in the base's own BREAKING.md are skipped, leaving
the ancestor and diagnostic checks for the rebase and post-merge
cases they were written for.

Refs #19

Assisted-by: Claude Code
Two paths inside the acknowledgment check turned a tooling failure
into a pass, which is the one outcome this gate must never produce.

`candidate_only_breaks_sha` compared the two diagnostic sets from an
`if` condition, where `set -e` is suspended: a `comm` that never ran
left the difference empty, reading as "the acknowledgment covers every
violation" and passing an unacknowledged break. Each step is now
checked and a failure is fatal.

The acknowledgment file is read twice, from the work tree and from the
base commit, and a symlink makes those two readings differ — the
committed object holds the target path, not the acknowledgment — so an
inherited line looked novel. A symlinked acknowledgment is refused, as
is a base tree entry that is not a regular blob or cannot be read.

Refs #19

Assisted-by: Claude Code
fix(ci): treat a grep error as an error, not as "no match"
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
build / build felis (aarch64-darwin) (pull_request) Successful in 1m32s
bench / Criterion regression gate (pull_request) Successful in 2m23s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m5s
pr / nix flake check (pull_request) Successful in 16s
pr / cargo test (Windows) (pull_request) Has started running
pr / cargo build / clippy / test / deny (pull_request) Failing after 2m2s
pr / generated code is current (pull_request) Successful in 2s
pr / wire schema is compatible with the base (pull_request) Successful in 9s
pr / cargo clippy (Windows cross) (pull_request) Successful in 32s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m4s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
pr / cargo check (MSRV) (pull_request) Successful in 40s
pr / frontend smoke (Windows) (pull_request) Has been cancelled
pr / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
54cafd83fb
grep answers 0 for a match and 1 for none, but anything above that
means it never searched. Three probes in the wire gate read every
nonzero status as "no match", and two of those readings are the
permissive one: a failed inherited-acknowledgment probe declares a
leftover acknowledgment new, and a failed COMPILE probe declares an
uncompilable schema an ordinary wire break that an acknowledgment may
then cover.

A shared probe classifies the three outcomes and stops the run on the
third. It has to be called outside a pipeline, since the die of a
subshell would surface as the same "no match" it exists to rule out,
so the acknowledgment lines are captured once and fed from a here
string.

Refs #19

Assisted-by: Claude Code
chore: re-run CI after a runner-cancelled build job
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
build / build felis (aarch64-darwin) (pull_request) Successful in 1m33s
pr / cargo test (Windows) (pull_request) Successful in 12m19s
bench / Criterion regression gate (pull_request) Successful in 2m30s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m10s
pr / nix flake check (pull_request) Successful in 5s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m36s
pr / generated code is current (pull_request) Successful in 2s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / cargo clippy (Windows cross) (pull_request) Successful in 28s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 2m20s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
pr / cargo check (MSRV) (pull_request) Successful in 32s
pr / frontend smoke (Windows) (pull_request) Successful in 7m39s
pr / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
dd8a62de5b
The 'cargo build / clippy / test / deny' job on 54cafd8 died mid-compile
with 'this step has been cancelled: ctx: context canceled, exec: RUN
signal: terminated' — the runner dropped the step, not a test or lint
failure. Forgejo exposes no per-job re-run to the CLI, so a no-op commit
is the only way to ask for a fresh run.

Assisted-by: Claude Code
natsukium deleted branch fix/proto-compat-ci-base-19 2026-09-04 03:32:47 +09:00
Sign in to join this conversation.
No description provided.