From 7620413a257e54e4e80cabbc46786bcc2480f116 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 18:19:39 -0400 Subject: [PATCH 1/2] feat(ship-it): add tea-ci-watch and background CI verification phases Add tea-ci-watch, a blocking watcher for Gitea commit status that polls the combined status endpoint via `tea api` and exits with a distinct code per outcome (0 pass, 1 fail, 2 API error, 3 no CI, 124 timeout). Replace the free-standing CI protocol in the skill with Phase 3 (wait for PR CI via a single background watcher, then stop for the user to merge) and Phase 4 (post-merge sync, only when the user says the PR is merged or asks for a merge). Use `gh pr checks --watch` on GitHub and take the merge SHA from the PR rather than origin/main. --- plugins/ship-it/plugin.json | 2 +- plugins/ship-it/scripts/tea-ci-watch.sh | 116 ++++++++++++++++++++++++ plugins/ship-it/skills/ship-it/SKILL.md | 33 ++++++- 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100755 plugins/ship-it/scripts/tea-ci-watch.sh diff --git a/plugins/ship-it/plugin.json b/plugins/ship-it/plugin.json index 512d9b0..b10aa77 100644 --- a/plugins/ship-it/plugin.json +++ b/plugins/ship-it/plugin.json @@ -1,6 +1,6 @@ { "name": "ship-it", "description": "Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR.", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "Rootiest" } } diff --git a/plugins/ship-it/scripts/tea-ci-watch.sh b/plugins/ship-it/scripts/tea-ci-watch.sh new file mode 100755 index 0000000..509da7f --- /dev/null +++ b/plugins/ship-it/scripts/tea-ci-watch.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026 Christopher Robert Laprade (Rootiest) +# Licensed under the MIT License. +# + +set -euo pipefail + +show_help() { + cat <<'EOF' +NAME + tea-ci-watch - Block execution until Gitea Actions/commit checks complete + +SYNOPSIS + tea-ci-watch [OPTIONS] [timeout_seconds] + +DESCRIPTION + Polls the Gitea combined commit status API (via 'tea api') and blocks until + every check on the commit has finished. Designed for automated CI pipelines + and agent workflows: run it once, in the background, and act on the exit + code instead of sleep-polling. + + The combined status reports the latest state of each check, so earlier + 'pending' entries for re-run jobs do not affect the result. Skipped jobs + count as passing. + +ARGUMENTS + Target repository path (e.g. rootiest/ship-it) + Commit SHA to observe + [timeout_seconds] Maximum wait duration in seconds (default: 1200) + +OPTIONS + -h, --help Display this help documentation and exit + +ENVIRONMENT VARIABLES + TEA_CI_WATCH_GRACE Seconds to wait for any check to register before + concluding the commit has no CI (default: 60) + +AUTHENTICATION + Uses the default 'tea' login. Run 'tea login add' if none is configured. + +RETURNS + 0 All checks passed (success, warning, or skipped) + 1 One or more checks failed; failing jobs are printed + 2 Usage, dependency, or API error (bad repo, auth, network) + 3 No checks registered within the grace period + 124 Timeout reached with checks still pending + +EXAMPLES + tea-ci-watch rootiest/rootiest-ai a1b2c3d4e5f6 + tea-ci-watch rootiest/rootiest-ai "$(git rev-parse HEAD)" 300 +EOF +} + +# Parse help flag before positional parameters +if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then + show_help + exit 0 +fi + +# Validate positional inputs +if [ "$#" -lt 2 ]; then + echo "Error: Missing required arguments." >&2 + echo "Run 'tea-ci-watch --help' for usage." >&2 + exit 2 +fi + +# Ensure required runtime dependencies exist in PATH +for bin in jq tea; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "Error: Required binary '$bin' is not installed or not in PATH." >&2 + exit 2 + fi +done + +REPO="$1" +SHA="$2" +TIMEOUT="${3:-1200}" +GRACE="${TEA_CI_WATCH_GRACE:-60}" +INTERVAL=5 + +echo "Waiting for CI completion on ${REPO}@${SHA:0:7}..." >&2 + +while ((SECONDS < TIMEOUT)); do + # 'tea api' exits 0 even on HTTP errors, so a missing .state is the error signal + if ! RESPONSE=$(tea api "/repos/${REPO}/commits/${SHA}/status" 2>&1) || + ! STATE=$(jq -er '.state' <<<"$RESPONSE" 2>/dev/null); then + echo "ERROR: Status request failed for ${REPO}@${SHA:0:7}: ${RESPONSE}" >&2 + exit 2 + fi + COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + + case "$STATE" in + success | warning) + echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + ;; + failure | error) + echo "ERROR: One or more checks failed for ${SHA:0:7}:" >&2 + jq -r '.statuses[] | select(.status == "failure" or .status == "error") + | " \(.context): \(.description) (\(.target_url))"' <<<"$RESPONSE" >&2 + exit 1 + ;; + esac + + # A commit with no workflows reports "pending" with zero checks forever + if ((COUNT == 0 && SECONDS >= GRACE)); then + echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 + exit 3 + fi + + sleep "$INTERVAL" +done + +echo "ERROR: Timeout reached (${TIMEOUT}s) waiting for CI on ${SHA:0:7}." >&2 +exit 124 diff --git a/plugins/ship-it/skills/ship-it/SKILL.md b/plugins/ship-it/skills/ship-it/SKILL.md index dd1a6f3..6e82ace 100644 --- a/plugins/ship-it/skills/ship-it/SKILL.md +++ b/plugins/ship-it/skills/ship-it/SKILL.md @@ -1,14 +1,14 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.1.0 +version: 1.2.0 user-invocable: true author: Rootiest --- # /ship-it -Instructions: +## Instructions Execute the following phases sequentially. Do not proceed to the next phase unless the current one completes successfully. 1. Phase 1: Documentation Sync & Code Audit @@ -23,3 +23,32 @@ Execute the following phases sequentially. Do not proceed to the next phase unle - Stage and commit all pending changes (including the newly updated README from Phase 1). - Push the branch to the remote repository. - Generate a Pull Request (PR) from the new branch into 'main'. + +3. Phase 3: CI Verification + - Wait for CI on the PR head commit with ONE blocking watcher. Never poll with `sleep` or repeated status checks. + - Run the watcher as a background command (Claude Code: Bash `run_in_background: true`); you are re-invoked when it exits. Without background support, run it in the foreground with a tool timeout longer than the watcher's. + - Derive `/` and the platform from `git remote get-url origin`. + - Gitea: + ```bash + tea-ci-watch "/" "$(git rev-parse HEAD)" + ``` + | Exit | Meaning | Action | + |------|---------|--------| + | 0 | All checks passed | Continue | + | 1 | A check failed (jobs printed) | Read the failing job's logs, report, stop | + | 2 | API/usage error | Report the error, stop | + | 3 | No CI registered | Report "no CI on this PR", continue | + | 124 | Timed out | Re-run the watcher once; if it times out again, report and stop | + - GitHub: + ```bash + gh pr checks --watch --fail-fast + ``` + If it reports no checks yet, retry once with another background run, not a sleep loop. + - Report the PR link and CI result, then STOP. The user merges. + +4. Phase 4: Post-Merge Sync — ONLY when the user says the PR is merged, or explicitly asks you to merge it. + - Get the merge commit from the PR, not from `origin/main`: + - Gitea: `tea api "/repos///pulls/" | jq -r .merge_commit_sha` + - GitHub: `gh pr view --json mergeCommit -q .mergeCommit.oid` + - Watch it the same way as Phase 3. Exit 3 is normal here. + - Pull any automated commits: `git switch main && git pull --ff-only origin main` -- 2.54.0 From d23651850dbe1dc4914e64a376f6c34c6d58d531 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 30 Sep 2026 20:13:47 -0400 Subject: [PATCH 2/2] fix(ship-it): ship tea-ci-watch inside the skill directory The generator copies only known plugin entries into dist/, so a plugin-root scripts/ directory never reached either target and the skill depended on a manual ~/.local/bin symlink. Move the script to skills/ship-it/scripts/, which the skill copytree already ships to claude-code and agy (plugin and bundle), and have the skill run it by path relative to its own SKILL.md. Make --check reject unknown plugin-root entries so a dropped file fails the PR gate. --- README.md | 5 +++++ plugins/ship-it/skills/ship-it/SKILL.md | 4 ++-- .../{ => skills/ship-it}/scripts/tea-ci-watch.sh | 0 scripts/generate_plugins.py | 13 ++++++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) rename plugins/ship-it/{ => skills/ship-it}/scripts/tea-ci-watch.sh (100%) diff --git a/README.md b/README.md index 1ba0066..890af16 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,7 @@ generator. A plugin can bundle any subset of: plugins// ├── plugin.json # required: name, description, version, author ├── skills//SKILL.md # 0+ skills (YAML frontmatter: name, description, version, author) +├── skills//scripts/ # optional: helper scripts, shipped with the skill ├── hooks.json # optional: lifecycle hooks, Claude-shaped event → matcher groups ├── mcp.json # optional: {"mcpServers": {...}} ├── rules/AGENTS.md # optional: agy-only, always-on project rules @@ -366,6 +367,10 @@ plugins// └── agents/*.md # optional: Claude Code-only subagents ``` +Anything else at the plugin root fails validation, since the generator would +not ship it. Each skill directory is copied whole, so a skill reaches its +helper scripts by a path relative to its own `SKILL.md`. + `hooks.json` and `mcp.json` are translated per target rather than copied verbatim where the two tools' schemas diverge: diff --git a/plugins/ship-it/skills/ship-it/SKILL.md b/plugins/ship-it/skills/ship-it/SKILL.md index 6e82ace..956f142 100644 --- a/plugins/ship-it/skills/ship-it/SKILL.md +++ b/plugins/ship-it/skills/ship-it/SKILL.md @@ -28,9 +28,9 @@ Execute the following phases sequentially. Do not proceed to the next phase unle - Wait for CI on the PR head commit with ONE blocking watcher. Never poll with `sleep` or repeated status checks. - Run the watcher as a background command (Claude Code: Bash `run_in_background: true`); you are re-invoked when it exits. Without background support, run it in the foreground with a tool timeout longer than the watcher's. - Derive `/` and the platform from `git remote get-url origin`. - - Gitea: + - Gitea: run the bundled watcher, `scripts/tea-ci-watch.sh` in this skill's directory (the directory holding this SKILL.md). Use its absolute path; it is not on `PATH`. Requires `tea` (logged in) and `jq`. ```bash - tea-ci-watch "/" "$(git rev-parse HEAD)" + bash "/scripts/tea-ci-watch.sh" "/" "$(git rev-parse HEAD)" ``` | Exit | Meaning | Action | |------|---------|--------| diff --git a/plugins/ship-it/scripts/tea-ci-watch.sh b/plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh similarity index 100% rename from plugins/ship-it/scripts/tea-ci-watch.sh rename to plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh diff --git a/scripts/generate_plugins.py b/scripts/generate_plugins.py index 1db2445..071f694 100755 --- a/scripts/generate_plugins.py +++ b/scripts/generate_plugins.py @@ -7,7 +7,9 @@ A "plugin" is a directory under `plugins//` that may bundle any mix of: optional targets: ["claude-code", "agy", ...] — restricts the WHOLE plugin, including its hooks/mcp/rules/commands/agents, to only the listed targets; default is every target in manifest.yaml) - - skills//SKILL.md (0+ skills; a skill's own frontmatter may also declare + - skills//SKILL.md (0+ skills; the whole skill directory is shipped verbatim, + so helper scripts belong in skills//scripts/; a + skill's own frontmatter may also declare `targets:` to further restrict just that one skill, independent of its sibling skills — narrowed to, never wider than, the plugin's own `targets`) @@ -76,6 +78,9 @@ REQUIRED_SKILL_FIELDS = ("name", "description") AGY_GROUPED_EVENTS = ("PreToolUse", "PostToolUse") AGY_FLAT_EVENTS = ("PreInvocation", "PostInvocation", "Stop") +# Everything else at a plugin's root would be silently dropped from dist/, so reject it. +PLUGIN_ENTRIES = {"plugin.json", "skills", "hooks.json", "mcp.json", "rules", "commands", "agents"} + class ValidationError(Exception): pass @@ -197,6 +202,12 @@ def discover_plugins(merged_root: Path, valid_targets: list[str]) -> list[dict]: raise ValidationError( f"{manifest_path}: name '{meta.get('name')}' does not match directory name '{plugin_dir.name}'" ) + unknown = sorted(e.name for e in plugin_dir.iterdir() if e.name not in PLUGIN_ENTRIES) + if unknown: + raise ValidationError( + f"plugins/{plugin_dir.name}: unsupported entries {unknown} would not be shipped; " + "put helper files inside skills// instead" + ) for field in REQUIRED_PLUGIN_FIELDS: if not meta.get(field): raise ValidationError(f"{manifest_path}: missing required field '{field}'") -- 2.54.0