Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,36 @@ jobs:
git fetch origin "$BASE_REF"
python3 scripts/validate-changelog.py "origin/$BASE_REF"

skills:
# The agent skills ship in every release. Fail a PR that adds a command or
# flag without documenting it. The drift check (code changed since the last
# tag, skills did not) is left to release.sh, where it has a whole release
# to judge rather than one PR.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable

- name: Cache cargo
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Check skills cover every command and flag
env:
SKIP_SKILL_DRIFT: "1"
run: scripts/check-skills.sh --require-version

fmt:
runs-on: ubuntu-latest
steps:
Expand Down
14 changes: 12 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ Releases use a two-phase workflow wrapping [`cargo-release`](https://github.com/
scripts/release.sh prepare <version>
```

Creates a `release/<version>` branch, bumps the version, updates `CHANGELOG.md`, pushes the branch, and opens a pull request.
Runs the skill check (below), then creates a `release/<version>` branch, bumps the version (including the `version:` line in every `SKILL.md`), updates `CHANGELOG.md`, pushes the branch, and opens a pull request.

**Phase 2 — finish**

```sh
scripts/release.sh finish
```

Switches to `main`, pulls latest, tags the release, and triggers the dist workflow.
Switches to `main`, pulls latest, runs the skill check with `--require-version`, tags the release, and triggers the dist workflow.

## Skill check

Both phases run `scripts/check-skills.sh`. The agent skills under `skills/` ship in every release (`skills.tar.gz`, `SKILL.md`), so the script refuses to release when they lag the code:

- **Drift** — if `src/` or `README.md` changed since the last `v*` tag and `skills/` did not, the run fails and lists the commits to review. Update the skills and commit, or set `SKIP_SKILL_DRIFT=1` when the release is verified to be skill-neutral.
- **Coverage** — every subcommand the built binary exposes must appear as `hotdata <group> <sub>` somewhere in `skills/**/*.md`, and every long flag in its `--help` must be mentioned too. CI runs this check on every pull request (with the drift check skipped), so a new command or flag cannot merge undocumented.
- **Version** (`finish` only) — every `SKILL.md` frontmatter `version:` must equal the crate version.

The drift check is a reminder, not a judge of prose. Before `prepare`, read the commits since the last tag and update `skills/hotdata/SKILL.md` and the subskills to match the current `--help` output and behavior.
131 changes: 131 additions & 0 deletions scripts/check-skills.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# check-skills.sh — refuse to release with agent skills that lag the code.
#
# Runs before a release branch is cut and again before the tag is pushed.
# Three checks, all mechanical:
#
# 1. Drift: if src/ changed since the last release tag, skills/ must have
# changed too. The script cannot judge prose, so this is the reminder
# that a code change needs its SKILL.md follow-up. Override with
# SKIP_SKILL_DRIFT=1 when a release is verified to be skill-neutral.
# 2. Coverage: every subcommand the built binary exposes, and every long
# flag in its --help, must be named in skills/**/*.md, so a new command
# or flag cannot ship undocumented. Hidden flags are not in --help and
# are not checked.
# 3. Version: every SKILL.md frontmatter `version:` must equal Cargo.toml
# (finish phase only — prepare runs before cargo-release bumps them).
#
# Usage:
# scripts/check-skills.sh [--require-version]

set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

REQUIRE_VERSION=0
[ "${1:-}" = "--require-version" ] && REQUIRE_VERSION=1

BIN="${HOTDATA_BIN:-target/debug/hotdata}"
SKILL_FILES=(skills/hotdata/SKILL.md skills/hotdata/subskills/*/SKILL.md)
fail=0

# --- 1. drift ---------------------------------------------------------------
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
if [ -z "$LAST_TAG" ]; then
echo "→ skills: no previous v* tag; skipping drift check"
else
src_changes="$(git log --oneline "$LAST_TAG"..HEAD -- src/ README.md | grep -v -E 'chore\(deps\)|chore: Release' || true)"
skill_changes="$(git log --oneline "$LAST_TAG"..HEAD -- skills/ || true)"
if [ -n "$src_changes" ] && [ -z "$skill_changes" ]; then
if [ "${SKIP_SKILL_DRIFT:-}" = "1" ]; then
echo "→ skills: code changed since $LAST_TAG without a skills change (SKIP_SKILL_DRIFT=1, continuing)"
else
echo "error: code changed since $LAST_TAG but skills/ did not." >&2
echo "" >&2
echo "$src_changes" | sed 's/^/ /' >&2
echo "" >&2
echo "Review each commit against skills/hotdata/SKILL.md and the subskills, update them," >&2
echo "and commit. If none of these change agent-visible behavior, rerun with SKIP_SKILL_DRIFT=1." >&2
fail=1
fi
else
echo "→ skills: drift check ok (since $LAST_TAG)"
fi
fi

# --- 2. coverage ------------------------------------------------------------
if [ ! -x "$BIN" ]; then
echo "→ skills: building $BIN for the command inventory..."
cargo build -q

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the script aborts with no message when the build does not produce $BIN (not blocking).

Failure scenario: a maintainer has CARGO_TARGET_DIR set in the environment. cargo build -q then writes the binary elsewhere, so target/debug/hotdata stays absent. The first "$BIN" --help fails with 127, 2>/dev/null hides the "No such file or directory" message, and pipefail plus set -e exit the script at the assignment in walk. The maintainer sees the "building ..." line and nothing else, and release.sh stops.

Re-check $BIN after the build and report the path explicitly:

Suggested change
cargo build -q
cargo build -q
fi
if [ ! -x "$BIN" ]; then
echo "error: $BIN not found after cargo build; set HOTDATA_BIN to the binary path." >&2
exit 1

fi

# Walk the clap tree: "<group> <sub> [<sub>]", leaf commands only.
list_subcommands() {
"$BIN" "$@" --help 2>/dev/null \
| awk '/^Commands:/{f=1;next} /^$/{f=0} f && $1!="help" {print $1}'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the awk parse reads a wrapped description line as a subcommand name (not blocking).

Clap wraps the Commands: descriptions at 100 columns when stdout is not a TTY. The continuation line is indented to the description column, so f && $1!="help" prints its first word as a subcommand. walk then recurses into a name the binary does not have, --help fails, and the word becomes a leaf. The coverage check demands hotdata <word> in the skills, which no maintainer can satisfy.

The margin is already thin. In the root help the description column starts at 14, and the search description is 83 characters, for 97 of the 100 available. One added word to that line trips the parse, and the CI job now blocks every PR on it.

Anchor the match to exactly two leading spaces, which a continuation line never has:

Suggested change
| awk '/^Commands:/{f=1;next} /^$/{f=0} f && $1!="help" {print $1}'
| awk '/^Commands:/{f=1;next} /^$/{f=0} f && /^ [^ ]/ && $1!="help" {print $1}'

}
leaves=()
walk() {
local path=("$@")
local subs
subs="$(list_subcommands "${path[@]}")"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this expansion requires bash 4.4 or newer (not blocking).

The first walk call passes no arguments, so path is an empty array. Bash 3.2 treats "${path[@]}" as unbound under set -u and exits with path[@]: unbound variable. macOS ships bash 3.2 as /bin/bash, and release.sh otherwise stays 3.2-compatible. Fix this if a maintainer releases from macOS system bash:

Suggested change
subs="$(list_subcommands "${path[@]}")"
subs="$(list_subcommands ${path[@]+"${path[@]}"})"

if [ -z "$subs" ]; then
[ ${#path[@]} -gt 0 ] && leaves+=("${path[*]}")
return
fi
local s
for s in $subs; do walk "${path[@]}" "$s"; done
}
walk

skill_text="$(cat "${SKILL_FILES[@]}" skills/hotdata/references/*.md skills/hotdata/subskills/*/references/*.md)"
missing=()
for leaf in "${leaves[@]}"; do
if ! grep -qF "hotdata $leaf" <<<"$skill_text"; then
missing+=("$leaf")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
echo "error: commands the CLI exposes but no skill mentions:" >&2
printf ' hotdata %s\n' "${missing[@]}" >&2
fail=1
else
echo "→ skills: command coverage ok (${#leaves[@]} commands documented)"
fi

# Long flags, per leaf, minus the globals every command carries.
GLOBAL_FLAGS='^--(api-key|no-input|help|output|workspace-id)$'
missing_flags=()
flag_count=0
for leaf in "${leaves[@]}"; do
# shellcheck disable=SC2086
flags="$("$BIN" $leaf --help 2>/dev/null \
| grep -oE '^\s+(-[a-zA-Z], )?--[a-z][a-z0-9-]+' \
| grep -oE -- '--[a-z][a-z0-9-]+' | sort -u | grep -vE "$GLOBAL_FLAGS" || true)"
for f in $flags; do
flag_count=$((flag_count + 1))
grep -qF -- "$f" <<<"$skill_text" || missing_flags+=("hotdata $leaf $f")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: grep -F matches a prefix, so a flag passes while undocumented (not blocking).

-F is a fixed-substring match with no word boundary. Any flag that is a prefix of a longer documented flag is treated as covered. This PR adds --table-path at skills/hotdata/SKILL.md:375, which now silences --table, --table-p, and every other prefix of it across all commands.

Require a boundary after the flag name:

Suggested change
grep -qF -- "$f" <<<"$skill_text" || missing_flags+=("hotdata $leaf $f")
grep -qE -- "$f([^a-z0-9-]|\$)" <<<"$skill_text" || missing_flags+=("hotdata $leaf $f")

done
done
if [ ${#missing_flags[@]} -gt 0 ]; then
echo "error: flags in --help that no skill mentions:" >&2
printf ' %s\n' "${missing_flags[@]}" >&2
fail=1
else
echo "→ skills: flag coverage ok ($flag_count flags documented)"
fi

# --- 3. version -------------------------------------------------------------
if [ "$REQUIRE_VERSION" = 1 ]; then
crate="$(grep -E '^version = ' Cargo.toml | head -1 | sed -E 's/^version = "([^"]+)".*/\1/')"
for f in "${SKILL_FILES[@]}"; do
v="$(sed -n 's/^version: //p' "$f" | head -1)"
if [ "$v" != "$crate" ]; then
echo "error: $f declares version $v, Cargo.toml is $crate" >&2
fail=1
fi
done
[ "$fail" = 0 ] && echo "→ skills: version ok ($crate)"
fi

exit $fail
10 changes: 8 additions & 2 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# release.sh — two-phase release wrapper around cargo-release
#
# Usage:
# scripts/release.sh prepare <version> # branch, bump, changelog PR
# scripts/release.sh finish # tag only (main is branch-protected)
# scripts/release.sh prepare <version> # skill check, branch, bump, changelog PR
# scripts/release.sh finish # skill check, tag only (main is branch-protected)

set -euo pipefail

Expand Down Expand Up @@ -45,6 +45,9 @@ case "$COMMAND" in

require_clean_tree

echo "→ Checking agent skills against the code..."
scripts/check-skills.sh

echo "→ Creating branch $BRANCH"
git checkout -b "$BRANCH"

Expand Down Expand Up @@ -99,6 +102,9 @@ case "$COMMAND" in
echo ""
echo "→ Release version from Cargo.toml: $VERSION (tag $TAG)"

echo "→ Checking agent skills against the code and version..."
scripts/check-skills.sh --require-version

if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "error: tag $TAG already exists locally. Delete it or pick a new version." >&2
exit 1
Expand Down
Loading
Loading