diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f635c533..a2179f05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,35 @@ jobs: # tree only), unlike changelog-check. run: uv run python scripts/check_version_gates.py + - name: Unresolved vNEXT placeholder check (release PRs only) + # A feature PR tags new behavior `(since vNEXT)` because it cannot know + # its release version; the release PR rewrites every one. Miss one and + # the plugin -- served to agents from the repo -- carries a gate no + # installed version can satisfy, so the agent refuses a command the + # user has. That rewrite was checklist-only, and its grep could never + # come back empty (the process docs must mention the placeholder they + # describe); the script separates the two by backticks instead. + # + # Armed only for a PR that RAISES the version. A two-dot diff is used + # deliberately -- a shallow checkout has no merge base -- and the + # script's PEP 440 comparison is what keeps a stale branch behind a + # released main from being mistaken for a release PR. + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF" + base=$(git show "origin/$GITHUB_BASE_REF:pyproject.toml" 2>/dev/null \ + | sed -n 's/^version = "\(.*\)"/\1/p' | head -1) + echo "base branch version: ${base:-}" + if [ -z "$base" ]; then + # Deliberately fail-open (an unreadable base must never block an + # ordinary PR), but LOUDLY: on a release PR this would mean the + # placeholder gate silently never armed. Verified live on PR #670 + # that the fetch above populates the ref under the default shallow + # checkout, so reaching this line means something changed. + echo "::warning title=vNEXT gate disarmed::could not read pyproject version from origin/$GITHUB_BASE_REF; the unresolved-placeholder check will not arm. If this is a release PR, run 'make vnext-check' locally." + fi + uv run python scripts/check_version_gates.py --release-if-newer-than "$base" + - name: Error-code enum check # Rejects raw error_code="LITERAL" string literals (must use ErrorCode). run: uv run python scripts/check_error_codes.py diff --git a/CLAUDE.md b/CLAUDE.md index cc8c686d..97d94b41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,14 +170,25 @@ The flow is instead: the literal placeholder **`vNEXT`** -- `(since vNEXT)` / `vNEXT+`. `make version-gate-check` ignores the placeholder (it only matches numeric versions) but rejects a guessed numeric version with no changelog entry, - so a numeric future tag cannot even pass CI. + so a numeric future tag cannot even pass CI. Writing `vNEXT` in a feature + PR is correct and stays green -- the placeholder only becomes fatal in a + version-raising PR (below). 2. **Release PR** (its only job): bump `pyproject.toml`, review everything merged since the last release (`git log v..origin/main`), write ONE changelog entry covering all of it (one bullet per logical change, with `(#PR)` references), replace every `vNEXT` placeholder with the real - version, run `make version-sync`. Merge, then tag -- the release pipeline - renders the GitHub release notes from the changelog entry - (`scripts/gen_release_notes.py`). Full checklist: `CONTRIBUTING.md` > + version (`make vnext-check` verifies it -- CI runs the same check + automatically on any PR that raises the version, so a missed placeholder + is a red build, not a silent ship), add the curated What's-new entry to + `web/frontend/src/whatsnew.ts` when the release ships UI-visible features + (skipping it errors nowhere -- the popup falls back to the previous reel + and the release's UI work ships dark), run `make version-sync`. Merge, + then tag the merge commit -- **the tag push is the ONLY manual action**: + the pipeline renders the release notes from the changelog entry + (`scripts/gen_release_notes.py`; never write them by hand), publishes + PyPI + native binaries, and creates + fills the GitHub Release. Verify + afterwards: pipeline green, `gh release view` shows a non-empty body and + both wheels. Full checklist: `CONTRIBUTING.md` > "Releasing a new version". **Inside the release PR**: edit `pyproject.toml`, add the changelog entry to `src/keboola_agent_cli/changelog.py`, then run `make version-sync`. Do not edit `__init__.py` or `plugin.json` manually. `make changelog-check` (release-time, needs `gh`) enforces changelog completeness in both directions. @@ -268,7 +279,7 @@ Full author checklist: see `CONTRIBUTING.md` > "Releasing a beta (pre-release) v - `plugins/kbagent/agents/keboola-expert.md` -- **highest risk** (Rule 6 VERSION GATE, tool selection matrix, inline gotchas) - `plugins/kbagent/skills/kbagent/SKILL.md` -- description triggers and workflow links (the auto-generated table is CI-checked, the rest is not) - `plugins/kbagent/skills/kbagent/references/commands-reference.md` - - `plugins/kbagent/skills/kbagent/references/gotchas.md` (every new gotcha **MUST** be tagged with a version -- `(since vNEXT)` in a feature PR, replaced with the real `(since vX.Y.Z)` by the release PR) + - `plugins/kbagent/skills/kbagent/references/gotchas.md` (every new gotcha **MUST** be tagged with a version -- `(since vNEXT)` in a feature PR, replaced with the real `(since vX.Y.Z)` by the release PR; the *replacement* is now CI-enforced on any version-raising PR, the *tagging* still is not) - `plugins/kbagent/skills/kbagent/references/-workflow.md` (e.g. `semantic-layer-workflow.md`, `workspace-workflow.md`, `sync-workflow.md`) Forgetting any of these does not fail tests or lint -- it ships an AI agent that quietly recommends commands that do not exist on the user's installed kbagent version, or refuses commands that do. Treat the change as **not done** until every applicable file has been updated. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dfb4dca..1f48996e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -466,8 +466,9 @@ release checklist below. | `plugins/kbagent/skills/kbagent/SKILL.md` -- table | Auto-generated by `make skill-gen` | YES (`make skill-check`; pre-commit auto-stages) | | `plugins/kbagent/skills/kbagent/SKILL.md` -- description / rules / workflow links | New topic area in `description` triggers; new workflow file added to bottom table | NO | | `plugins/kbagent/skills/kbagent/references/commands-reference.md` | Adding/removing/renaming commands; flag changes | NO | -| `plugins/kbagent/skills/kbagent/references/gotchas.md` | New non-obvious behavior -- always tag with a version (`(since vNEXT)` in feature PRs; the release PR rewrites it to `(since vX.Y.Z)`) | PARTLY (`make version-gate-check` proves a numeric tag names a released version; `vNEXT` leftovers are caught by the release checklist grep, not CI) | +| `plugins/kbagent/skills/kbagent/references/gotchas.md` | New non-obvious behavior -- always tag with a version (`(since vNEXT)` in feature PRs; the release PR rewrites it to `(since vX.Y.Z)`) | PARTLY (`make version-gate-check` proves a numeric tag names a released version, and a version-raising PR additionally fails on any unresolved `vNEXT`; whether the behavior deserves a tag AT ALL is still judgement, so a *missing* entry ships silently) | | `plugins/kbagent/skills/kbagent/references/-workflow.md` | New workflow / topic area introduced | NO | +| `web/frontend/src/whatsnew.ts` | Every release that ships UI-visible features -- a `WhatsNewRelease` entry keyed by the exact new version (release PR only; a feature PR cannot know the version) | NO -- without an entry the release's UI work ships dark: the popup falls back to the previous reel, which returning users have already dismissed | | `plugins/kbagent/skills//` (e.g. `kbagent-promotion-pipeline`) | Adding a **sibling skill** -- a self-contained skill directory next to `kbagent/`, used when the topic ships executable `scripts/` + tests or needs its own `description` triggers rather than being one more `references/*.md`. Must ALSO be linked from `kbagent/SKILL.md`'s bottom table, otherwise an agent already inside the `kbagent` skill can never discover it | NO -- `make skill-check` only regenerates `kbagent/SKILL.md` and never looks at sibling skills | Anything tagged "NO" in the right column is a **silent failure mode**: lint @@ -484,7 +485,8 @@ to catch this before the change ships. PR (see [Releasing a new version](#releasing-a-new-version)). Parallel PRs each bumping the version collide on merge and silently renumber releases. Tag any new version-gated documentation with the `vNEXT` placeholder - (`(since vNEXT)`); the release PR replaces it with the real version. + (`(since vNEXT)`); the release PR replaces it with the real version, and CI + fails that PR if any placeholder survives. - **Conventional commits**: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:` - **One logical change per commit** -- don't mix unrelated fixes - **Pre-commit hook must pass** -- `ruff check` + `ruff format --check`. Install via `make hooks` @@ -596,7 +598,7 @@ set of release notes. Open the release PR from its own branch, walk this checklist end-to-end, merge it, then tag the resulting `main` commit (the release pipeline renders the GitHub release notes from the changelog entry via -`scripts/gen_release_notes.py`). The point of steps 7-10 is that **CI will not +`scripts/gen_release_notes.py`). The point of steps 7-11 is that **CI will not catch you** if you skip them; they are the manual safety net for the silent-drift risks summarized in the [Plugin synchronization map](#plugin-synchronization-map) above. @@ -618,30 +620,64 @@ silent-drift risks summarized in the Those merged PRs are **exactly** the scope of the release: the changelog entry and the release notes must cover each of them, and nothing else. 2. **Edit `pyproject.toml`** -- bump `version = "X.Y.Z"`. Single source of truth; everything else derives from it. This is the release PR's defining change -- if you are doing this in a feature PR, stop and read the section intro above. -3. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- ONE entry for the new version, covering **every PR merged since the last release** (step 1), no exceptions. CI fails (`make changelog-check`) if this is missing. Author it as the file's docstring describes: **one logical change per bullet** (split the release into several list items rather than one mega-paragraph), each starting with a recognised prefix (`BREAKING:`, `New:`, `Fix:`, `Change:`, `Note:`, `Security:`, ...), carrying its `(#PR)` reference, and leading with a self-contained first sentence. `kbagent changelog` shows only that first sentence per version by default (the rest is revealed by `--full`), so a buried headline or a single wall-of-text bullet reads as an unscannable blob. +3. **Add a changelog entry** to `src/keboola_agent_cli/changelog.py` -- ONE entry for the new version, covering **every PR merged since the last release** (step 1), no exceptions. CI fails (`make changelog-check`) if this is missing. Author it as the file's docstring describes: **one logical change per bullet** (split the release into several list items rather than one mega-paragraph), each starting with a recognised prefix (`BREAKING:`, `New:`, `Fix:`, `Change:`, `Note:`, `Security:`, ...), carrying its `(#PR)` reference, and leading with a self-contained first sentence. `kbagent changelog` shows only that first sentence per version by default (the rest is revealed by `--full`), so a buried headline or a single wall-of-text bullet reads as an unscannable blob. The first sentence is also **capped at 160 characters**, enforced by `tests/test_changelog_render.py::TestLiveChangelogHeadlines::test_newest_release_notes_are_not_truncated` (so `make check` in step 12 catches it) -- past the cap the default view and the release page show it cut mid-clause. Write a short self-contained first sentence and put the detail in the sentences after it; 2 of 0.90.0's 13 bullets needed exactly this rewrite. 4. **Replace every `vNEXT` placeholder** left behind by the feature PRs with the version being released, then verify none survive: ```bash - grep -rn "vNEXT" CLAUDE.md docs/ plugins/ src/keboola_agent_cli/commands/context.py + make vnext-check ``` - The grep MUST come back empty before merging. This is not CI-enforced (the - plugin is served from the repo, so a leftover `(since vNEXT)` ships to - agents as an unusable version gate) -- the release PR is the only place it - can be fixed. + A leftover `(since vNEXT)` ships agents a gate no installed version can + ever satisfy -- strictly worse than no gate, because they then refuse a + command the user has. The release PR is the only place it can be fixed. + + **This is now CI-enforced.** The `check` job runs the same check on any PR + that RAISES `pyproject.toml`'s version, so a missed placeholder is a red + build rather than a silent ship. A feature PR is unaffected: writing + `vNEXT` there is required, and the gate stays disarmed until a version + bump. (`make check` deliberately does NOT include it -- it would fail every + feature PR run locally.) + + > This step used to be a hand-run `grep -rn "vNEXT" ...` documented as + > "MUST come back empty". It never could: the process docs (this file, + > `CLAUDE.md`) have to *mention* the placeholder they describe, so every + > release forced a fresh eyeball-classification of each hit. The check + > separates the two mechanically -- **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: 16 real gates found, 4 prose mentions + > ignored, no allowlist. Note the rule is deliberately *not* applied to + > numeric gates -- `docs/sdk.md` writes 14 genuine ones as `` `0.66.0+` ``, + > where backticks are ordinary typography rather than quotation. + + While resolving, keep version tags **out of markdown headings**: a + `### Foo *(since vNEXT)*` heading changes its generated anchor slug at + every release, breaking each inbound `#foo-...` link (this bit 0.90.0 -- + the What's-new section's link broke the moment the placeholder resolved). + Put the tag on the section's first body line instead; the gate checks scan + whole files, not just headings, so nothing is lost. 5. **Run `make version-sync`** -- propagates the new version to `plugins/kbagent/.claude-plugin/plugin.json`. The pre-commit hook does this automatically on `git commit`, but running it explicitly lets you eyeball the diff. 6. **Run `make skill-gen`** -- regenerates the decision table in `SKILL.md`. Idempotent if no commands changed since the previous release. -7. **Manually review `plugins/kbagent/agents/keboola-expert.md`**: +7. **Add a curated What's-new entry** to `web/frontend/src/whatsnew.ts` when the release ships anything UI-visible -- a `WhatsNewRelease` element keyed by the **exact** new version, newest first. This is the reel the web UI shows once per version; it is deliberately *not* derived from `changelog.py` (see `docs/web-server.md` > "What's-new popup"). Skipping it does not error anywhere: `whatsNewFor` falls back to the previous release's reel, which returning users have already dismissed -- so the release's UI work ships **dark**. A release with no UI-visible changes correctly adds nothing. Only the release PR can write this entry (a feature PR cannot know the version), which is why it lives in this checklist and not the per-command one. +8. **Manually review `plugins/kbagent/agents/keboola-expert.md`**: - **§1 Rule 6 VERSION GATE examples** -- if any feature this release shipped (or any feature shipped in a previous release that you missed) was previously missing-and-now-present, document it with the right minimum version. Remove stale "since X.Y.Z" mentions that no longer matter to live users. - **§2 Tool Selection Matrix** -- did you add a new write/destructive command *group* since last release? Is it present with one `First choice / Fallback / NEVER` row (per group, not per command)? Mind the hard 70 000 B prompt budget: trim stale content rather than raising the cap. New commands inside an existing group need no new row. - **§3 Inline Gotchas** -- new behavior the agent would get wrong by default? Add it. -8. **Manually review `plugins/kbagent/skills/kbagent/references/gotchas.md`** -- every behavior introduced or changed this release that an AI agent would not infer from `--help` should have its own `(since vX.Y.Z)` entry (freshly rewritten from `vNEXT` in step 4, or added now if a feature PR forgot one). The version tag is non-optional. -9. **Manually review `CLAUDE.md` `## All CLI Commands`** -- diff against `kbagent --help` output (and against `kbagent context`). Hand-maintained; CI does not catch drift here. -10. **Manually review `plugins/kbagent/skills/kbagent/references/commands-reference.md`** -- same drill. Hand-maintained, no CI coverage. -11. **Run `make check`** -- lint + format + skill freshness + version sync + changelog completeness + error-code enum + full test suite. -12. **Run `make test-e2e`** if any command changed since the last release -- requires `E2E_API_TOKEN` and `E2E_URL`. -13. **Open the release PR** -- link the merged PRs it covers (step 1) and list every plugin file you touched in the description so reviewers can spot what was missed. Plugin files do not auto-show up in CI failures the way Python files do; reviewers are the second line of defence. -14. **Merge via `gh pr merge`**, then tag the resulting `main` commit and publish the release -- never push directly to `main` (the branch is protected; this would fail anyway). - -If any of steps 7-10 reveal "I should have done this in the PR that introduced +9. **Manually review `plugins/kbagent/skills/kbagent/references/gotchas.md`** -- every behavior introduced or changed this release that an AI agent would not infer from `--help` should have its own `(since vX.Y.Z)` entry (freshly rewritten from `vNEXT` in step 4, or added now if a feature PR forgot one). The version tag is non-optional. +10. **Manually review `CLAUDE.md` `## All CLI Commands`** -- diff against `kbagent --help` output (and against `kbagent context`). Hand-maintained; CI does not catch drift here. +11. **Manually review `plugins/kbagent/skills/kbagent/references/commands-reference.md`** -- same drill. Hand-maintained, no CI coverage. +12. **Run `make check`** -- lint + format + skill freshness + version sync + changelog completeness + error-code enum + full test suite. +13. **Run `make test-e2e`** if any command changed since the last release -- requires `E2E_API_TOKEN` and `E2E_URL`. +14. **Open the release PR** -- link the merged PRs it covers (step 1) and list every plugin file you touched in the description so reviewers can spot what was missed. Plugin files do not auto-show up in CI failures the way Python files do; reviewers are the second line of defence. +15. **Merge via `gh pr merge`, then tag -- the tag push IS the release.** Never push directly to `main` (protected). The only manual action after the merge is: + ```bash + git fetch origin && git tag v && git push origin v + ``` + The tag must point at the release PR's merge commit on `main` -- the pipeline's `gate` job fails the whole release if the tag's `pyproject.toml` disagrees with the tag name. Pushing it triggers `.github/workflows/release-kbagent.yml`, which does **everything else**: re-runs the gates, renders the release notes from `changelog.py` (`scripts/gen_release_notes.py` -- never write them by hand), publishes to PyPI, freezes the native binaries for all platforms, packages deb/rpm, creates the GitHub Release with every asset attached and fills its body, and updates Homebrew/Chocolatey/WinGet. Do **not** pre-create the GitHub Release by hand: the pipeline keeps a hand-written body untouched, which silently discards the changelog-rendered notes. +16. **Verify the publish** -- the pipeline guards against half-releases, but both guards exist because each failure shipped once (v0.66.1 went out with an empty body, v0.64.0 without a wheel), so look anyway: + ```bash + gh run watch $(gh run list --workflow release-kbagent.yml --limit 1 --json databaseId --jq '.[0].databaseId') + ``` + then confirm `gh release view v` shows a non-empty body rendered from the changelog and both wheels (`keboola_cli-*` + legacy `keboola_agent_cli-*`) among the assets. A `skipped` winget job is normal; any red job is a real signal. + +If any of steps 8-11 reveal "I should have done this in the PR that introduced the command, not at release time", **also patch the per-command checklist** above so the next contributor catches the gap earlier. @@ -735,6 +771,7 @@ make test-cov # Test suite + informational coverage report (term-missi make command-sync-check # Verify every CLI command is registered + documented make check-sentinel-guards # Verify no kbc-session:// sentinel path is unguarded make version-gate-check # Verify every (since vX.Y.Z) / X.Y.Z+ marker names a released version +make vnext-check # Verify no unresolved placeholder survives -- run in the RELEASE PR make skill-gen # Regenerate SKILL.md from CLI command metadata ``` @@ -768,6 +805,15 @@ Those integration tests skip or pass without credentials -- they never fail offline -- so a green `make check` implies CI's narrower selection passes too. Run it before pushing. +**Unresolved-placeholder gate** (`scripts/check_version_gates.py --release-if-newer-than`): +runs only on a PR that raises `pyproject.toml`'s version -- i.e. a release PR -- +and fails if any `(since vNEXT)` / `vNEXT+` placeholder survives. It compares the +branch's version against the base branch's with PEP 440 ordering rather than +trusting the diff, because a two-dot diff (the only kind a shallow CI checkout +can do) also fires for a stale feature branch whose base has since been +released; arming there would tell a contributor to delete a placeholder the +process requires them to write. + **Command-sync silent-drift gate** (`scripts/check_command_sync.py`): treats the live Typer command tree as the single source of truth and fails if any command is missing from `permissions.py` `OPERATION_REGISTRY`, `CLAUDE.md` diff --git a/Makefile b/Makefile index 177c1c6d..038ca0ac 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,9 @@ command-sync-check: ## Verify every CLI command is registered + documented (sile version-gate-check: ## Reject a (since vX.Y.Z) / X.Y.Z+ marker naming an unreleased version uv run python scripts/check_version_gates.py +vnext-check: ## Reject an unresolved version-gate placeholder -- run in the RELEASE PR + uv run python scripts/check_version_gates.py --release + check-sentinel-guards: ## Reject an unguarded kbc-session:// sentinel path (silent-drift gate) uv run python scripts/check_sentinel_guards.py diff --git a/scripts/check_version_gates.py b/scripts/check_version_gates.py index 3c7962a7..c8f9994f 100644 --- a/scripts/check_version_gates.py +++ b/scripts/check_version_gates.py @@ -22,9 +22,38 @@ that choice is a stale bare mention (``# 0.89.0 MCP-parity flags`` in a test comment) passing silently; those mislead a reader, not the agent's version gate. +A second, related failure mode lives here too: the ``vNEXT`` PLACEHOLDER. +A feature PR does not know its release version, so it tags new behavior +``(since vNEXT)`` and the release PR rewrites every one to the version +shipping. Miss one and the plugin -- served to agents straight from the repo +-- carries a gate no ``kbagent version`` can ever satisfy, which is strictly +worse than no gate at all: the agent refuses a command the user has. That +rewrite was checklist-only until now (``CONTRIBUTING.md`` step 4), and its +grep could never come back empty, because the process docs necessarily +*mention* the placeholder they are describing. + +Backticks settle that. A ``vNEXT`` inside an inline-code span is quoting the +literal token -- prose ABOUT the mechanism; outside one it is a live gate. +Fenced blocks are NOT exempt; see ``find_vnext_residue`` for the measurement +that settled it. +Verified against the pre-0.90.0 tree: 16 real gates detected, 4 prose +mentions ignored, no manual allowlist needed. + +That rule is deliberately NOT applied to the numeric gates above. There, +backticks are ordinary typography: ``docs/sdk.md`` writes 14 genuine gates as +```0.66.0+``, and stripping code spans would hide every one. The asymmetry is +real, not an oversight -- ``vNEXT`` is a placeholder token that prose quotes, +a version number is a value that prose formats. + +Residue is reported as INFO on every run and only FAILS under ``--release``, +because a feature PR is *supposed* to carry ``vNEXT``. CI applies that flag +only to a PR that changes ``pyproject.toml``'s version, i.e. a release PR. + Usage: python scripts/check_version_gates.py # verify, exit 1 on drift python scripts/check_version_gates.py --list # print the gate inventory + python scripts/check_version_gates.py --release # ALSO fail on vNEXT residue + python scripts/check_version_gates.py --release-if-newer-than 0.89.0 """ from __future__ import annotations @@ -32,8 +61,11 @@ import re import sys from collections import defaultdict +from dataclasses import dataclass from pathlib import Path +from packaging.version import Version + REPO_ROOT = Path(__file__).resolve().parent.parent # The silent-drift surfaces from CONTRIBUTING.md's "Plugin synchronization map". @@ -50,6 +82,52 @@ # The negative lookbehind keeps ``0.1.2.3+`` and ``e0.73.0+`` from matching. GATE_RE = re.compile(r"\(since v?(\d+\.\d+\.\d+)\)|(? list[VnextResidue]: + """Return every unresolved ``vNEXT`` gate in the given files. + + A ``vNEXT`` that survives only inside an inline-code span is prose about + the placeholder and is skipped. Pure apart from reading the given files. + """ + residue: list[VnextResidue] = [] + for path in paths: + try: + rel = path.relative_to(REPO_ROOT).as_posix() + except ValueError: + rel = path.as_posix() + for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), start=1): + if VNEXT_TOKEN not in line: + continue + if VNEXT_TOKEN in INLINE_CODE_RE.sub("", line): + residue.append(VnextResidue(path=rel, line=lineno, text=line.strip())) + return residue + def collect_gates(paths: list[Path]) -> dict[str, list[tuple[str, int]]]: """Map each gated version to the ``(relative path, line number)`` naming it. @@ -82,16 +160,57 @@ def resolve_paths() -> list[Path]: return list(seen) +def _pyproject_version() -> str: + """Read the version this working tree declares.""" + text = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if not match: + raise SystemExit("ERROR: could not read version from pyproject.toml") + return match.group(1) + + +def is_release_mode(argv: list[str]) -> bool: + """Decide whether unresolved ``vNEXT`` should be fatal for this invocation. + + ``--release`` forces it. ``--release-if-newer-than BASE`` lets CI hand over + the base branch's version and self-select: only a branch that RAISES the + version is a release PR. + + The comparison matters. A two-dot diff against the base tip also fires for + a stale feature branch whose base has since been released -- there the + version moved DOWN, and failing it would tell a contributor to delete a + placeholder they are required to write. PEP 440 ordering (``packaging``, + already a runtime dependency) also keeps ``0.10.0`` above ``0.9.0`` and + reads ``0.90.0b1`` as a release of ``0.89.0``'s successor. + """ + if "--release" in argv: + return True + if "--release-if-newer-than" not in argv: + return False + index = argv.index("--release-if-newer-than") + 1 + if index >= len(argv): + raise SystemExit("ERROR: --release-if-newer-than needs a version argument") + base = argv[index].strip().lstrip("v") + if not base: + # An unreadable base (new file, first commit) must not silently disarm + # the gate NOR fail an ordinary PR -- treat it as "not a release". + return False + return Version(_pyproject_version()) > Version(base) + + def main() -> int: sys.path.insert(0, str(REPO_ROOT / "src")) from keboola_agent_cli.changelog import CHANGELOG - gates = collect_gates(resolve_paths()) + paths = resolve_paths() + gates = collect_gates(paths) + residue = find_vnext_residue(paths) if "--list" in sys.argv: for version in sorted(gates, key=lambda v: [int(p) for p in v.split(".")]): mark = " " if version in CHANGELOG else " <-- UNKNOWN" print(f"{version:>10} {len(gates[version]):>3} marker(s){mark}") + print(f"{VNEXT_TOKEN:>10} {len(residue):>3} marker(s) <-- unresolved placeholder") return 0 unknown = {v: locs for v, locs in gates.items() if v not in CHANGELOG} @@ -109,8 +228,27 @@ def main() -> int: print(f" {rel}:{lineno}") return 1 + if residue and is_release_mode(sys.argv): + print(f"ERROR: {len(residue)} unresolved '{VNEXT_TOKEN}' version gate(s) in a release.\n") + print( + "A release PR must rewrite every placeholder to the version it ships.\n" + "Left in, the plugin ships agents a gate no installed version can satisfy,\n" + "so they refuse commands the user actually has. Rewrite these, then re-run:\n" + ) + for gate in residue: + print(f" {gate.path}:{gate.line}") + print(f" {gate.text[:100]}") + return 1 + total = sum(len(locs) for locs in gates.values()) print(f"All {total} version gates across {len(gates)} versions resolve to a release.") + if residue: + # INFO, not a failure: a feature PR is SUPPOSED to carry the placeholder. + # `--release` (CI applies it to a version-bumping PR) is what makes it fatal. + print( + f"{len(residue)} unresolved '{VNEXT_TOKEN}' gate(s) awaiting the next release PR " + "(run with --release to list and enforce)." + ) return 0 diff --git a/tests/test_check_version_gates.py b/tests/test_check_version_gates.py index 1971c09e..a2d1fab8 100644 --- a/tests/test_check_version_gates.py +++ b/tests/test_check_version_gates.py @@ -21,6 +21,7 @@ _spec.loader.exec_module(check_version_gates) collect = check_version_gates.collect_gates +residue = check_version_gates.find_vnext_residue def _write(tmp_path: Path, name: str, body: str) -> Path: @@ -92,3 +93,144 @@ def test_the_scan_actually_finds_gates(self) -> None: """Guards the guard: a broken glob would make the check vacuously pass.""" gates = collect(check_version_gates.resolve_paths()) assert len(gates) > 50 + + +class TestVnextResidue: + """``vNEXT`` is legal in a feature PR and fatal in a release PR. + + The separator is backticks: a placeholder quoted as inline code is prose + *about* the mechanism, which is why the old release-checklist grep could + never come back empty. + """ + + def test_bare_placeholder_is_a_gate(self, tmp_path: Path) -> None: + f = _write(tmp_path, "g.md", "- `--flag` (since vNEXT) does a thing\n") + found = residue([f]) + assert len(found) == 1 + assert found[0].line == 1 + + def test_plus_form_is_a_gate(self, tmp_path: Path) -> None: + f = _write(tmp_path, "g.md", "intro\n- **vNEXT+**: resolves to the first project\n") + assert len(residue([f])) == 1 + + def test_inline_code_mention_is_prose(self, tmp_path: Path) -> None: + """The exact shapes CLAUDE.md / CONTRIBUTING.md use to teach the rule.""" + f = _write( + tmp_path, + "process.md", + "tag it with the literal placeholder **`vNEXT`** -- `(since vNEXT)` / `vNEXT+`.\n" + "the release PR replaces every `vNEXT` placeholder with the real version\n", + ) + assert residue([f]) == [] + + def test_gate_and_prose_on_the_same_line_still_flags(self, tmp_path: Path) -> None: + """One quoted mention must not launder a live gate sharing the line.""" + f = _write(tmp_path, "mixed.md", "`vNEXT` is the placeholder; (since vNEXT) is live\n") + assert len(residue([f])) == 1 + + def test_numeric_gate_is_not_residue(self, tmp_path: Path) -> None: + f = _write(tmp_path, "n.md", "- `--flag` (since v0.90.0)\n- other 0.73.0+\n") + assert residue([f]) == [] + + def test_backticks_do_not_hide_numeric_gates(self, tmp_path: Path) -> None: + """The asymmetry is deliberate: docs/sdk.md writes real gates as `0.66.0+`.""" + f = _write(tmp_path, "sdk.md", "### Device-enrollment primitives (`0.66.0+`)\n") + assert list(collect([f])) == ["0.66.0"] + + def test_reports_path_line_and_text(self, tmp_path: Path) -> None: + f = _write(tmp_path, "d.md", "x\ny\n### What's-new popup *(since vNEXT)*\n") + gate = residue([f])[0] + assert gate.line == 3 + assert "What's-new popup" in gate.text + + +class TestLiveRepositoryVnext: + def test_no_unresolved_placeholder_survives_a_release(self) -> None: + """``main`` carries a released version, so every placeholder must be rewritten. + + This is the check that used to be a hand-run grep the release + checklist admitted could never come back empty. + """ + found = residue(check_version_gates.resolve_paths()) + assert found == [], ( + "unresolved vNEXT gate(s) shipped -- an agent cannot satisfy them: " + f"{[(gate.path, gate.line) for gate in found[:3]]}" + ) + + def test_prose_mentions_are_still_present_and_ignored(self) -> None: + """Guards the guard: a rule that matched nothing would pass vacuously.""" + paths = check_version_gates.resolve_paths() + mentions = sum( + line.count(check_version_gates.VNEXT_TOKEN) + for path in paths + for line in path.read_text(errors="replace").splitlines() + ) + assert mentions > 0, "no vNEXT mentions at all -- the scan globs are probably broken" + + +class TestReleaseModeSelection: + """Only a version-RAISING branch is a release PR. + + CI hands the base branch's version to ``--release-if-newer-than``. A + two-dot diff also fires for a stale feature branch whose base has since + been released; arming there would demand a contributor delete a + placeholder the process requires them to write. + """ + + def test_explicit_flag_forces_release_mode(self) -> None: + assert check_version_gates.is_release_mode(["--release"]) is True + + def test_no_flag_is_not_release_mode(self) -> None: + assert check_version_gates.is_release_mode([]) is False + + def test_higher_than_base_arms(self) -> None: + """The release PR: pyproject moved above the base branch.""" + base = check_version_gates._pyproject_version() + lower = f"0.0.{int(base.split('.')[-1].rstrip('abrc0123456789') or 0)}" + assert check_version_gates.is_release_mode(["--release-if-newer-than", lower]) is True + + def test_equal_to_base_does_not_arm(self) -> None: + """The ordinary feature PR -- it must stay free to write vNEXT.""" + base = check_version_gates._pyproject_version() + assert check_version_gates.is_release_mode(["--release-if-newer-than", base]) is False + + def test_lower_than_base_does_not_arm(self) -> None: + """A stale branch behind a released main -- the false positive to avoid.""" + assert check_version_gates.is_release_mode(["--release-if-newer-than", "99.0.0"]) is False + + def test_leading_v_is_tolerated(self) -> None: + base = check_version_gates._pyproject_version() + assert check_version_gates.is_release_mode(["--release-if-newer-than", f"v{base}"]) is False + + def test_empty_base_does_not_arm(self) -> None: + """An unreadable base must neither disarm silently nor fail a normal PR.""" + assert check_version_gates.is_release_mode(["--release-if-newer-than", ""]) is False + + def test_missing_argument_is_an_error(self) -> None: + import pytest + + with pytest.raises(SystemExit): + check_version_gates.is_release_mode(["--release-if-newer-than"]) + + +class TestVnextFencedBlocks: + """Fenced blocks are deliberately NOT exempt -- this pins the trade-off. + + ``CLAUDE.md``'s ``## All CLI Commands`` section is one giant fence carrying + real gates: exempting fences dropped 2 of 16 live gates in the pre-0.90.0 + tree, silently. A doc wanting to SHOW the placeholder uses inline backticks. + """ + + def test_gate_inside_a_fence_is_still_a_gate(self) -> None: + """The CLAUDE.md shape: a command list fenced whole, with gates inside.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + f = Path(tmp) / "commands.md" + f.write_text("```\n# component detail (since vNEXT): falls back to ...\n```\n") + found = residue([f]) + assert len(found) == 1, "a fenced gate must not be exempt -- see the class docstring" + + def test_inline_backticks_remain_the_escape_hatch(self, tmp_path: Path) -> None: + f = _write(tmp_path, "doc.md", "Resolve them: run `grep -rn '(since vNEXT)' docs/`\n") + assert residue([f]) == []