Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,32 @@ jobs:
fi
uv run python scripts/check_version_gates.py --release-if-newer-than "$base"

- name: Release-scope check (release PRs only)
# Proves the new changelog entry covers every PR the tag will CONTAIN,
# not just the scope collected when the release PR was opened. Those
# differ whenever a feature PR merges while the release PR is open --
# a structural window, since a release PR stays open for as long as its
# CI runs. It bit v0.91.0: #625 merged nine minutes before the release
# PR and landed inside the tag with no release note. `changelog-check`
# cannot see it (it proves every released VERSION has an entry, never
# that an entry covers every COMMIT under the tag).
#
# Armed exactly like the vNEXT gate above -- only for a PR that RAISES
# the version. The check needs tags and real history, which the default
# shallow checkout lacks, so the deepening is done ONLY when the version
# differs; the script fails open (warns, exits 0) if git still cannot
# answer, so an ordinary PR can never go red because of this.
if: github.event_name == 'pull_request'
run: |
base=$(git show "origin/$GITHUB_BASE_REF:pyproject.toml" 2>/dev/null \
| sed -n 's/^version = "\(.*\)"/\1/p' | head -1)
head=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -1)
if [ -n "$base" ] && [ "$base" != "$head" ]; then
echo "version changed ($base -> $head); deepening checkout for the scope check"
git fetch --unshallow --tags --quiet 2>/dev/null || git fetch --tags --quiet || true
fi
uv run python scripts/check_release_scope.py --only-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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ The one exception is a **beta/pre-release** (below): there the bump deliberately
rides the feature branch, because the pre-release tag and GitHub Release are cut
from that branch -- the branch temporarily *is* the release PR.

### Beta / pre-release versions (since 0.43.3)
### Beta / pre-release versions

Beta and release-candidate versions follow **PEP 440**: `0.44.0b1`, `0.44.0rc1`, ... -- **not** the SemVer `-beta.1` form (hatchling + uv require PEP 440 syntax in `pyproject.toml`). Three independent gates keep stable users safe from accidentally landing on a beta:

Expand Down
87 changes: 80 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,10 +625,18 @@ silent-drift risks summarized in the
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. 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:
4. **Replace every `vNEXT` placeholder** left behind by the feature PRs with the version being released. Do it mechanically -- never by hand, and never with a repo-wide `sed`:
```bash
make vnext-resolve VERSION=X.Y.Z
make vnext-check
```
`vnext-resolve` reuses the same scanner `vnext-check` does, so it rewrites
exactly the live gates and leaves every backticked mention of the token
alone -- including a line that carries both at once, which a line-level
`sed` corrupts. It refuses any `VERSION` that disagrees with
`pyproject.toml` (bump that first, in step 2): `packaging` happily parses
`v0.91` and `0.91`, so a typo can look valid and then be stamped into every
gate in the tree at once.
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.
Expand All @@ -651,12 +659,61 @@ silent-drift risks summarized in the
> 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 --
Version tags must stay **out of markdown headings**: a
`### Foo *(since vNEXT)*` heading changes its generated anchor slug when the
placeholder resolves, 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.

**This is CI-enforced on EVERY PR**, not just at release time -- a `vNEXT`
inside an ATX heading in a `.md` file fails `make version-gate-check`
(already part of `make check`). It is deliberately armed everywhere rather
than only under `--release`, because the rule used to be a hand-run
`grep -rn '^##.*vNEXT' plugins/` at release time and that grep **lost a
merge race in 0.91.0**: PR #697 ran it two minutes before #694 and #696
landed headings of their own, so all three shipped and had to be cleaned up
after the tag. Any rule of the form "run this grep when releasing" loses
that race eventually, because a release is exactly when parallel branches
converge. Already-numeric headings are *not* flagged -- a resolved tag never
changes again, so its slug is stable.
4b. **Retire gates below the floor** (periodic, not every release):
```bash
make gate-floor-report # what is below the current floor
```
A version gate earns its place only while some live install predates it.
kbagent self-updates on startup, so that population shrinks to roughly
nothing: pip/uv installs upgrade themselves, and only a standalone binary
(brew/choco/apt/dnf, which self-update is disabled for), an explicit
`KBAGENT_AUTO_UPDATE=false`, a dev tree, or a pip install stranded below
0.62.0 by the #424 rename can sit on an old version. Meanwhile the stale
gate keeps making the agent refuse a command the user actually has -- which
this file already calls strictly worse than no gate.

The two failure modes are asymmetric, and that is the whole argument for
pruning: a **kept-too-long** gate fails silently and permanently (the user
never learns the command exists), while a **removed-too-early** gate fails
loudly and self-correctingly (`No such command 'x'`, and `kbagent context` /
`--help` on the user's own install are authoritative anyway).

**The floor is 0.80.0** as of the 0.91.0 cleanup. Retiring a gate means
deleting the *tag*, never the content -- the guidance under it is almost
always still true, and 0.91.0's pass kept every word while removing 223 tags.

Four things are deliberately out of scope:

- `changelog.py` -- the historical record; the version IS the content.
- `src/**/*.py` except `commands/context.py` -- developer comments
(`# DEPRECATED (since 0.43.4)`) are provenance, and no agent reads them.
- `X+` written inside a sentence -- often load-bearing prose
(`created by < 0.66.1 stay dormant until re-run on 0.66.1+`).
- **Safety gates, at any age.** Keep the tag wherever not knowing the
version causes silent data loss or a false assurance rather than an error
message -- e.g. `sync pull --force` (pre-0.53.0 it silently stranded local
edits), the `sync status` / `doctor` plaintext-secret audit (a false
all-clear on a leaked credential), the manage-token default-deny, and the
`--deny-writes` firewall.

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. **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.
Expand All @@ -670,17 +727,33 @@ silent-drift risks summarized in the
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:
15. **Re-verify the scope against the commit you are about to tag:**
```bash
make release-scope-check # in the release PR, before merging
make release-scope-check SCOPE_ARGS="--head origin/main --ignore-pr <release-PR>"
```
Step 1 collected the scope when the release PR was *opened*; this proves
the changelog entry covers every PR the **tag will actually contain**. The
two differ whenever a feature PR merges while the release PR is open --
which is a structural window, not bad luck, since a release PR stays open
for as long as its CI runs. It shipped in v0.91.0: #625 merged nine minutes
before the release PR did, landing inside the tag's tree with no release
note, and was caught only because the tag happened to be deferred.
`make changelog-check` cannot see this: it proves every *released version*
has an entry, never that an entry covers every *commit* under the tag.
Run before merging and nothing needs ignoring -- the release PR's own
number is not in the log until its merge commit exists.
16. **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<X.Y.Z> <merge-commit-sha> && git push origin v<X.Y.Z>
```
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:
17. **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<X.Y.Z>` 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.
17. **After the tag: merge the ai-kit publish PR.** The `ai-kit-marketplace` job opens
18. **After the tag: merge the ai-kit publish PR.** The `ai-kit-marketplace` job opens
`chore(kbagent): publish vX.Y.Z` against `keboola/ai-kit`, bumping the `kbagent`
entry in the `keboola-claude-kit` marketplace to this tag. Until that PR merges,
`/plugin install kbagent@keboola-claude-kit` still serves the PREVIOUS version --
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

.PHONY: help install install-server sync test test-unit test-integration test-e2e test-e2e-local test-e2e-invite test-e2e-feature test-e2e-stream test-e2e-auth test-file test-cov lint lint-fix format format-check typecheck typecheck-warn skill-check skill-gen version-sync version-check version-gate-check changelog changelog-check check-error-codes check-sentinel-guards loc-check loc-report loc-baseline command-sync-check gen-command-reference endpoints-gen endpoints-check check clean hooks web-install web-dev-backend web-dev-frontend web-build web-clean
.PHONY: help install install-server sync test test-unit test-integration test-e2e test-e2e-local test-e2e-invite test-e2e-feature test-e2e-stream test-e2e-auth test-file test-cov lint lint-fix format format-check typecheck typecheck-warn skill-check skill-gen version-sync version-check version-gate-check vnext-check vnext-resolve gate-floor-report release-scope-check changelog changelog-check check-error-codes check-sentinel-guards loc-check loc-report loc-baseline command-sync-check gen-command-reference endpoints-gen endpoints-check check clean hooks web-install web-dev-backend web-dev-frontend web-build web-clean

help: ## Show this help message
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
Expand Down Expand Up @@ -124,6 +124,16 @@ version-gate-check: ## Reject a (since vX.Y.Z) / X.Y.Z+ marker naming an unrelea
vnext-check: ## Reject an unresolved version-gate placeholder -- run in the RELEASE PR
uv run python scripts/check_version_gates.py --release

vnext-resolve: ## Rewrite every live vNEXT gate to pyproject's version (RELEASE PR step 4)
@test -n "$(VERSION)" || { echo "usage: make vnext-resolve VERSION=X.Y.Z"; exit 2; }
uv run python scripts/check_version_gates.py --resolve $(VERSION)

gate-floor-report: ## List version gates below the retirement floor (default 0.80.0)
uv run python scripts/check_version_gates.py --list-below $(or $(FLOOR),0.80.0)

release-scope-check: ## Prove the changelog entry covers every PR the tag will contain
uv run python scripts/check_release_scope.py $(SCOPE_ARGS)

check-sentinel-guards: ## Reject an unguarded kbc-session:// sentinel path (silent-drift gate)
uv run python scripts/check_sentinel_guards.py

Expand Down
6 changes: 3 additions & 3 deletions docs/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Flags worth knowing:
The command is **idempotent**: running it again skips projects that
are already registered. Safe to re-run after adding new project IDs.

**Security note (since v0.29.0)**: `KBC_MANAGE_API_TOKEN` is **ignored
**Security note**: `KBC_MANAGE_API_TOKEN` is **ignored
by default** -- the env var is read only when the top-level
`--allow-env-manage-token` flag is passed. Without the flag, kbagent
prompts on stdin (hidden input). kbagent never accepts the token as a
Expand Down Expand Up @@ -810,7 +810,7 @@ footguns the platform does not surface as errors:
platform transitions `created -> stopped -> starting -> running`, so a
naive poll that exits on `stopped` reports a phantom failure.

`kbagent data-app` (since 0.27.0) encodes all four in the service layer,
`kbagent data-app` encodes all four in the service layer,
so the `--json` output you see at the CLI is what would have happened if
you had done everything right at the raw HTTP level. The eight
subcommands -- `list`, `detail`, `create`, `deploy`, `start`, `stop`,
Expand Down Expand Up @@ -923,7 +923,7 @@ kbagent --json data-app create \
deploy. To retrieve it:

```bash
# Manage API token: interactive prompt by default (since v0.29.0). For CI,
# Manage API token: interactive prompt by default. For CI,
# add `--allow-env-manage-token` and set KBC_MANAGE_API_TOKEN in env.
kbagent --json data-app password \
--project prod --app-id 12345678 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Every task carries an `action` envelope with `type` + `params`:
`cli` accepts `claude`, `codex`, or `gemini`. The chosen CLI must be on
the server's `PATH` when the task fires (cron or `agent run`).

**`extra_args` are ignored unless the serve operator opts in (since v0.60.2).**
**`extra_args` are ignored unless the serve operator opts in.**
They are passed verbatim to the AI CLI and can disable its safety rails, so
`kbagent serve` drops them with a warning unless it was started with a truthy
`KBAGENT_ALLOW_AI_EXTRA_ARGS` (e.g. `KBAGENT_ALLOW_AI_EXTRA_ARGS=1`). The
Expand Down Expand Up @@ -98,7 +98,7 @@ kbagent agent list
kbagent agent show <task_id>
```

> **ID forms (since v0.44.0):** every subcommand that takes a task/run ID
> **ID forms:** every subcommand that takes a task/run ID
> accepts it positionally (`agent show <task_id>`) or via a named flag
> (`--id` / `--task-id`, plus `--run-id` for `run-detail` / `run-events`) --
> matching the rest of the CLI (`--job-id`, `--config-id`, ...). Examples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Every task has an `action` block with `type` + `params`:
`cli` accepts `claude`, `codex`, or `gemini`. The chosen CLI must be on
the server's `PATH`.

**`extra_args` are ignored unless the serve operator opts in (since v0.60.2).**
**`extra_args` are ignored unless the serve operator opts in.**
They are forwarded verbatim to the AI CLI and can disable its safety rails, so
`kbagent serve` drops them with a warning unless it was started with a truthy
`KBAGENT_ALLOW_AI_EXTRA_ARGS`. The `["--print"]` above takes effect only when
Expand Down
Loading