feat(ship-it): add tea-ci-watch and background CI verification phases #12
No files matched your search
@@ -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,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" }
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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}'")
|
||||||
|
|||||||
Reference in new issue
Block a user