feat(ship-it): add tea-ci-watch and background CI verification phases #12

Merged
rootiest merged 2 commits from feat/ship-it-ci-watch into main 2026-10-01 00:14:41 +00:00
5 changed files with 165 additions and 4 deletions

No files matched your search

+5
View File
@@ -359,6 +359,7 @@ generator. A plugin can bundle any subset of:
plugins/<name>/ plugins/<name>/
├── plugin.json # required: name, description, version, author ├── plugin.json # required: name, description, version, author
├── skills/<skill>/SKILL.md # 0+ skills (YAML frontmatter: name, description, version, author) ├── skills/<skill>/SKILL.md # 0+ skills (YAML frontmatter: name, description, version, author)
├── skills/<skill>/scripts/ # optional: helper scripts, shipped with the skill
├── hooks.json # optional: lifecycle hooks, Claude-shaped event → matcher groups ├── hooks.json # optional: lifecycle hooks, Claude-shaped event → matcher groups
├── mcp.json # optional: {"mcpServers": {...}} ├── mcp.json # optional: {"mcpServers": {...}}
├── rules/AGENTS.md # optional: agy-only, always-on project rules ├── rules/AGENTS.md # optional: agy-only, always-on project rules
@@ -366,6 +367,10 @@ plugins/<name>/
└── agents/*.md # optional: Claude Code-only subagents └── 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 `hooks.json` and `mcp.json` are translated per target rather than copied
verbatim where the two tools' schemas diverge: verbatim where the two tools' schemas diverge:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ship-it", "name": "ship-it",
"description": "Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR.", "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" } "author": { "name": "Rootiest" }
} }
+31 -2
View File
@@ -1,14 +1,14 @@
--- ---
name: ship-it name: ship-it
description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. 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 user-invocable: true
author: Rootiest author: Rootiest
--- ---
# /ship-it # /ship-it
Instructions: ## Instructions
Execute the following phases sequentially. Do not proceed to the next phase unless the current one completes successfully. 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 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). - Stage and commit all pending changes (including the newly updated README from Phase 1).
- Push the branch to the remote repository. - Push the branch to the remote repository.
- Generate a Pull Request (PR) from the new branch into 'main'. - 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 `<owner>/<repo>` and the platform from `git remote get-url origin`.
- 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
bash "<skill-dir>/scripts/tea-ci-watch.sh" "<owner>/<repo>" "$(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 <pr-number> --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/<owner>/<repo>/pulls/<n>" | jq -r .merge_commit_sha`
- GitHub: `gh pr view <n> --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`
+116
View File
@@ -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] <owner/repo> <commit-sha> [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
<owner/repo> Target repository path (e.g. rootiest/ship-it)
<commit-sha> 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
+12 -1
View File
@@ -7,7 +7,9 @@ A "plugin" is a directory under `plugins/<name>/` that may bundle any mix of:
optional targets: ["claude-code", "agy", ...] — restricts the optional targets: ["claude-code", "agy", ...] — restricts the
WHOLE plugin, including its hooks/mcp/rules/commands/agents, to WHOLE plugin, including its hooks/mcp/rules/commands/agents, to
only the listed targets; default is every target in manifest.yaml) only the listed targets; default is every target in manifest.yaml)
- skills/<name>/SKILL.md (0+ skills; a skill's own frontmatter may also declare - skills/<name>/SKILL.md (0+ skills; the whole skill directory is shipped verbatim,
so helper scripts belong in skills/<name>/scripts/; a
skill's own frontmatter may also declare
`targets:` to further restrict just that one skill, independent `targets:` to further restrict just that one skill, independent
of its sibling skills — narrowed to, never wider than, the of its sibling skills — narrowed to, never wider than, the
plugin's own `targets`) plugin's own `targets`)
@@ -76,6 +78,9 @@ REQUIRED_SKILL_FIELDS = ("name", "description")
AGY_GROUPED_EVENTS = ("PreToolUse", "PostToolUse") AGY_GROUPED_EVENTS = ("PreToolUse", "PostToolUse")
AGY_FLAT_EVENTS = ("PreInvocation", "PostInvocation", "Stop") 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): class ValidationError(Exception):
pass pass
@@ -197,6 +202,12 @@ def discover_plugins(merged_root: Path, valid_targets: list[str]) -> list[dict]:
raise ValidationError( raise ValidationError(
f"{manifest_path}: name '{meta.get('name')}' does not match directory name '{plugin_dir.name}'" 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/<skill>/ instead"
)
for field in REQUIRED_PLUGIN_FIELDS: for field in REQUIRED_PLUGIN_FIELDS:
if not meta.get(field): if not meta.get(field):
raise ValidationError(f"{manifest_path}: missing required field '{field}'") raise ValidationError(f"{manifest_path}: missing required field '{field}'")