Skip to content

feat(ci): enforce vNEXT placeholder resolution in release PRs - #670

Merged
padak merged 3 commits into
mainfrom
feat/vnext-residue-gate
Aug 23, 2026
Merged

feat(ci): enforce vNEXT placeholder resolution in release PRs#670
padak merged 3 commits into
mainfrom
feat/vnext-residue-gate

Conversation

@padak

@padak padak commented Aug 23, 2026

Copy link
Copy Markdown
Member

Closes the last hand-run step of the release checklist.

The defect

CONTRIBUTING.md step 4 said: resolve every (since vNEXT), then verify with

grep -rn "vNEXT" CLAUDE.md docs/ plugins/ src/keboola_agent_cli/commands/context.py

The grep MUST come back empty before merging.

It never could. The process docs have to mention the placeholder they describe — CLAUDE.md lines 170, 177, 271 and docs/web-server.md:351 all contain it permanently. So every release forced a fresh eyeball-classification of each hit, with a silent-ship failure mode when one was missed. That is not a hypothetical: 0.90.0 (#669) had 16 to resolve across 6 files, and getting one wrong ships agents a gate no installed version can satisfy — strictly worse than no gate, because they then refuse a command the user has.

The rule

A vNEXT inside an inline-code span is prose quoting the token; outside one it is a live gate.

Validated against the pre-0.90.0 tree (1860a86): 16 real gates found, 4 prose mentions ignored, no allowlist needed. 16 is exactly what #669 resolved by hand.

Two asymmetries — both measured, not assumed

Backtick-stripping is NOT applied to numeric gates. I checked before generalising: docs/sdk.md writes 14 genuine gates as `0.66.0+`, where backticks are ordinary typography rather than quotation. Applying the rule there would hide every one. vNEXT is a placeholder token prose quotes; a version number is a value prose formats.

Fenced blocks are NOT exempt, even though the same "code means quotation" argument seems to apply. I implemented fence-stripping first, and the positive control immediately dropped from 16 to 14: CLAUDE.md's ## All CLI Commands section is one giant fence carrying real agent-facing gates. Reverted. The trade is asymmetric — a false positive is a loud CI failure someone fixes in a minute; a false negative is precisely the bug the check exists to prevent. A doc wanting to show the placeholder uses inline backticks. Both asymmetries are pinned by tests so a future "cleanup" cannot quietly undo them.

Arming

Residue is INFO on every run and fatal only under --release, because a feature PR is supposed to carry the placeholder.

CI arms it with --release-if-newer-than <base version> on any PR that raises pyproject.toml's version. The comparison is load-bearing: a two-dot diff (all a shallow checkout can do — there is no merge base) also fires for a stale feature branch whose base has since been released, where the version moved down. Arming there would tell a contributor to delete a placeholder the process requires them to write. PEP 440 ordering via packaging (already a runtime dep) also keeps 0.10.0 above 0.9.0 and reads 0.90.0b1 as a release.

base version arms? case
0.89.0 yes release PR
0.90.0 no ordinary feature PR
0.91.0 no stale branch behind a released main
`` (unreadable) no fails open, never blocks a normal PR

make vnext-check is the local form. It is deliberately not in make check — that would fail every feature PR run locally.

Changes

  • scripts/check_version_gates.pyfind_vnext_residue(), is_release_mode(), --release / --release-if-newer-than, residue count in --list
  • .github/workflows/ci.yml — conditional step in the check job
  • Makefilemake vnext-check
  • CONTRIBUTING.md — step 4 rewritten (with the historical note on why the grep was unsatisfiable), plugin sync map row for gotchas.md, CI-workflows section, local-CI target list, commit-conventions bullet
  • CLAUDE.md — release flow: writing vNEXT in a feature PR stays green; the release PR's replacement is now CI-enforced
  • tests/test_check_version_gates.py19 new tests (21 → 40 in the file)

What this does NOT fix

Whether a behavior deserves a gate at all is still judgement, so a missing gotchas.md entry still ships silently — the sync-map row now says exactly that instead of implying full coverage. That half stays with /kbagent:review.

Verification

  • make check6048 passed, 12 skipped, clean lint/format/typecheck/all gates
  • Numeric gate count unchanged at 468 across 73 versions — no regression to the existing check
  • Positive control: 16/16 real gates detected against 1860a86; the fence variant caught only 14, which is why it was reverted
  • make vnext-check green on this branch

Open in Devin Review

Release checklist step 4 told you to resolve every `(since vNEXT)` and
verify with a grep that "MUST come back empty". It never could: the
process docs have to mention the placeholder they describe, so every
release forced a fresh eyeball-classification of each hit -- with a
silent-ship failure mode when one was missed. An unresolved gate is
worse than no gate: the agent refuses a command the user actually has.

check_version_gates.py gains a vNEXT residue check that separates the
two mechanically -- a placeholder inside an inline-code span is prose
quoting the token, outside one it is a live gate. Validated against the
pre-0.90.0 tree: 16 real gates found, 4 prose mentions ignored, no
allowlist needed.

Two deliberate asymmetries, both measured rather than assumed:

- The backtick rule is NOT applied to numeric gates. docs/sdk.md writes
  14 genuine gates as `0.66.0+`, where backticks are typography rather
  than quotation; stripping code spans there would hide every one.
- Fenced blocks are NOT exempt. CLAUDE.md's command list is one giant
  fence carrying real gates -- exempting fences dropped 2 of the 16.
  A false positive is a loud CI failure; a false negative is the bug
  the check exists to prevent.

Residue is INFO on every run and fatal only under --release, because a
feature PR is supposed to carry the placeholder. CI arms it via
--release-if-newer-than <base version>, comparing with PEP 440 ordering
rather than trusting the diff: a two-dot diff (all a shallow checkout
can do) also fires for a stale branch whose base has since been
released, and arming there would tell a contributor to delete a
placeholder the process requires.

- new `make vnext-check`; deliberately not in `make check`, which would
  then fail every feature PR run locally
- CONTRIBUTING.md step 4, plugin sync map, CI workflows and local-CI
  sections updated; CLAUDE.md release flow notes the new enforcement
- 19 new tests, including a positive control pinning both asymmetries

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread scripts/check_version_gates.py Outdated
Comment thread .github/workflows/ci.yml
padak added 2 commits August 23, 2026 23:53
…base

Review follow-ups from PR #670:

- find_vnext_residue returned a bare (path, line, text) tuple, which
  CONTRIBUTING's Code Quality Patterns forbid beyond two values. Now a
  frozen VnextResidue dataclass; call sites and tests read named fields.
- The CI step's fail-open path (unreadable base version -> gate never
  arms) now emits a ::warning:: annotation naming the consequence and
  the local fallback, instead of disarming silently. Verified live on
  PR #670's own run that the depth-1 fetch populates the base ref under
  the default shallow checkout (log: "base branch version: 0.90.0"), so
  the warning firing at all means the environment changed.
Friction log from running the 0.89.0 -> 0.90.0 release end-to-end, folded
back into the checklist per its own closing rule:

- NEW step 7: web/frontend/src/whatsnew.ts was in no checklist at all --
  only docs/web-server.md and the file's own header mention it. Skipping
  it errors nowhere and the release's UI work ships dark (the popup falls
  back to the previous, already-dismissed reel). Also added to the plugin
  sync map as a silent-drift row.
- Step 3: document the 160-char headline cap and the test that enforces
  it; 2 of 0.90.0's 13 bullets hit it blind.
- Step 4: keep version tags out of markdown headings -- resolving vNEXT
  inside a heading changes its anchor slug and breaks inbound links
  (bit 0.90.0's What's-new section).
- Steps 15-16 (was 14): "tag and publish the release" read as if manual
  publishing followed the tag. It doesn't: the tag push IS the release,
  everything else is release-kbagent.yml. Spelled out the exact command,
  the pyproject-must-match-tag gate, the do-not-pre-create rule (a
  hand-made release silently discards the changelog-rendered notes), and
  a post-tag verification step with the two historical half-release
  failure modes (v0.66.1 empty body, v0.64.0 missing wheel) as the reason
  to look.
- CLAUDE.md release flow mirrors the same three additions in brief.
@padak

padak commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Scope note: the last commit (5f695da) folds in the release-checklist gaps that running the 0.90.0 release end-to-end surfaced -- same theme (making the release process enforceable and accurately documented), and it edits the same CONTRIBUTING sections this PR already touches, so a separate PR would just conflict. Summary: new step 7 (whatsnew.ts was in no checklist; skipping it ships the release's UI work dark), the 160-char headline cap in step 3, the version-tags-out-of-headings rule in step 4, and steps 15-16 rewritten to say plainly that the tag push IS the release (with the v0.66.1/v0.64.0 half-release history as the reason the verify step exists).

@padak
padak merged commit 48847f1 into main Aug 23, 2026
4 checks passed
@padak
padak deleted the feat/vnext-residue-gate branch August 23, 2026 22:10
padak added a commit that referenced this pull request Aug 23, 2026
* fix(ci): stop the live vNEXT gate turning main and every PR red

`TestLiveRepositoryVnext::test_no_unresolved_placeholder_survives_a_release`
asserted that the live tree carries no `(since vNEXT)` placeholder. That
is not an invariant of this repo -- it is the opposite of the documented
process.

Since #648, a feature PR that documents version-gated behaviour writes
the literal `vNEXT` placeholder and only the release PR rewrites it.
CLAUDE.md says so directly ("Writing `vNEXT` in a feature PR is correct
and stays green"), and TestReleaseModeSelection, added by the same PR as
this assertion, explains that arming the gate outside a release PR
"would demand a contributor delete a placeholder the process requires
them to write".

So main legitimately carries placeholders for most of a release cycle.
The assertion was green only because #670 landed days after 0.90.0 had
just rewritten every one of them. #675 added the first placeholder of
the next cycle and the assertion went red -- on main, and on every open
PR, since a PR run tests the merge commit.

The release-time requirement itself is real and stays enforced where it
can see the base branch's version: the "Unresolved vNEXT placeholder
check" step in ci.yml (`--release-if-newer-than`) and its local twin
`make vnext-check`. A unit test cannot see that version, so it cannot
make this call.

What a test CAN assert is that the live scan works, and that replaces
it: every gate the scanner reports must point at a real line that really
contains the placeholder. Zero hits and ten hits are both valid answers
depending on where in the cycle the tree sits. The neighbouring
"guards the guard" test already covers the globs resolving at all.

* fix(test): anchor the live vNEXT scan on REPO_ROOT, not the cwd

`VnextResidue.path` is repo-root-relative -- find_vnext_residue stores
`path.relative_to(REPO_ROOT).as_posix()` -- so reading it back as a bare
`Path(gate.path)` resolves against the current working directory. The
loop body only executes when the tree actually carries a placeholder,
which is precisely the state main is in now, so running pytest from
anywhere but the repo root raised FileNotFoundError.

Reproduced from tests/ before the change and confirmed green after, from
both the repo root and a subdirectory.

`REPO_ROOT / gate.path` also covers the one fallback branch, where a path
outside the repo is stored absolute: joining an absolute right-hand side
discards the left, yielding that path unchanged.

Raised by Devin review on #678.
@padak padak mentioned this pull request Aug 23, 2026
13 tasks
padak added a commit that referenced this pull request Aug 23, 2026
Batches the nine PRs merged since v0.90.0 (#670, #671, #672, #673, #674, #675, #676, #677, #678) into one version bump and one changelog entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant