From 30ebef6f30d37c13e8ff143118f20ae3fccf51eb Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 1 Oct 2026 02:22:03 -0400 Subject: [PATCH] fix(ship-it): wait for every workflow run in tea-ci-watch Gitea only posts an Actions job's commit status once a runner picks the job up, so queued workflows were invisible to the combined status. With multiple workflows on a commit, the watcher returned success as soon as the first one finished. Also poll the Actions runs API (head_sha filter), which lists runs from the moment they are triggered. Succeed only when no run is pending and the combined status is terminal; fail fast on failed/cancelled runs or failed statuses. Bump ship-it to 1.2.1 and document the CI phases in the README. --- README.md | 13 +++- .../rootiest-ai-all/skills/ship-it/SKILL.md | 2 +- .../skills/ship-it/scripts/tea-ci-watch.sh | 67 +++++++++++++------ dist/agy/ship-it/skills/ship-it/SKILL.md | 2 +- .../skills/ship-it/scripts/tea-ci-watch.sh | 67 +++++++++++++------ .../rootiest-ai-all/skills/ship-it/SKILL.md | 2 +- .../skills/ship-it/scripts/tea-ci-watch.sh | 67 +++++++++++++------ .../ship-it/.claude-plugin/plugin.json | 2 +- .../ship-it/skills/ship-it/SKILL.md | 2 +- .../skills/ship-it/scripts/tea-ci-watch.sh | 67 +++++++++++++------ plugins/ship-it/plugin.json | 2 +- plugins/ship-it/skills/ship-it/SKILL.md | 2 +- .../skills/ship-it/scripts/tea-ci-watch.sh | 67 +++++++++++++------ 13 files changed, 249 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 890af16..cf7f417 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ or want an auditable record of what changed and why. **Purpose:** Run a comprehensive pre-flight audit and publish changes to a new PR in a single command. -Two sequential phases — Phase 2 is blocked until Phase 1 succeeds: +Sequential phases — each is blocked until the previous one succeeds: 1. **Documentation Sync & Code Audit** — acts as `/docs-sync-audit`: scans all file changes since the last documentation edit, updates the docs/README to @@ -177,6 +177,17 @@ Two sequential phases — Phase 2 is blocked until Phase 1 succeeds: descriptively named branch, commits all pending changes (including the README updates from Phase 1), pushes to the remote, and opens a Pull Request against `main`. +3. **CI Verification** — waits on the PR head commit with a single background + watcher instead of sleep-polling. On GitHub it uses + `gh pr checks --watch --fail-fast`; on Gitea it runs the bundled + `scripts/tea-ci-watch.sh` (requires `tea` and `jq`), which waits until every + Actions run for the commit — including runs still queued, which Gitea does + not yet report as commit statuses — has completed and the combined commit + status is no longer pending. Fails fast on the first failed or cancelled + check. Then reports the PR link and CI result and stops; you merge. +4. **Post-Merge Sync** — only when you say the PR is merged (or ask it to + merge): watches CI on the merge commit the same way, then fast-forwards + local `main` to pick up any automated commits. **Use when:** you say "Ship this," "Publish my changes," or invoke `/ship-it`. diff --git a/dist/agy/rootiest-ai-all/skills/ship-it/SKILL.md b/dist/agy/rootiest-ai-all/skills/ship-it/SKILL.md index 956f142..7b17d8e 100644 --- a/dist/agy/rootiest-ai-all/skills/ship-it/SKILL.md +++ b/dist/agy/rootiest-ai-all/skills/ship-it/SKILL.md @@ -1,7 +1,7 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.2.0 +version: 1.2.1 user-invocable: true author: Rootiest --- diff --git a/dist/agy/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh b/dist/agy/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh index 509da7f..9a4a69d 100755 --- a/dist/agy/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh +++ b/dist/agy/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh @@ -15,10 +15,16 @@ 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. + Polls Gitea (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. + + Two sources are combined. Gitea posts an Actions job's commit status only + once a runner picks the job up, so queued workflows are invisible to the + combined status; the Actions runs API lists them from the moment they are + triggered. The commit is done only when every Actions run for it has + completed AND the combined status (which also covers external CI) is no + longer pending. The first failure in either source fails fast. 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 @@ -41,7 +47,8 @@ AUTHENTICATION RETURNS 0 All checks passed (success, warning, or skipped) - 1 One or more checks failed; failing jobs are printed + 1 One or more checks failed or were cancelled; failing + checks 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 @@ -81,30 +88,48 @@ 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 +# Fetch a Gitea API path as JSON. 'tea api' exits 0 even on HTTP errors, so the +# caller-supplied jq probe (which must succeed on a valid body) is the error signal. +fetch() { + local path="$1" probe="$2" body + if ! body=$(tea api "$path" 2>&1) || ! jq -e "$probe" <<<"$body" >/dev/null 2>&1; then + echo "ERROR: Request ${path} failed for ${REPO}@${SHA:0:7}: ${body}" >&2 exit 2 fi - COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + printf '%s' "$body" +} - case "$STATE" in - success | warning) - echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 - exit 0 - ;; - failure | error) +while ((SECONDS < TIMEOUT)); do + STATUS=$(fetch "/repos/${REPO}/commits/${SHA}/status" '.state | strings') || exit 2 + RUNS=$(fetch "/repos/${REPO}/actions/runs?head_sha=${SHA}&limit=50" '.workflow_runs | arrays') || exit 2 + + STATE=$(jq -r '.state' <<<"$STATUS") + COUNT=$(jq -r '.total_count' <<<"$STATUS") + RUN_COUNT=$(jq -r '.workflow_runs | length' <<<"$RUNS") + RUNS_PENDING=$(jq -r '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$RUNS") + RUNS_FAILED=$(jq -r '[.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled"))] | length' <<<"$RUNS") + + if [[ "$STATE" == failure || "$STATE" == error ]] || ((RUNS_FAILED > 0)); then 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 + | " \(.context): \(.description) (\(.target_url))"' <<<"$STATUS" >&2 + jq -r '.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled")) + | " \(.path) [\(.event)]: \(.conclusion) (\(.html_url))"' <<<"$RUNS" >&2 exit 1 - ;; - esac + fi + + # Done only when no run is queued/running and every posted status is terminal. + # Zero statuses with completed runs means every job was skipped (no status posted). + if ((RUNS_PENDING == 0)) && { [[ "$STATE" == success || "$STATE" == warning ]] || + ((COUNT == 0 && RUN_COUNT > 0)); }; then + echo "SUCCESS: All ${RUN_COUNT} workflow runs and ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + fi # A commit with no workflows reports "pending" with zero checks forever - if ((COUNT == 0 && SECONDS >= GRACE)); then + if ((COUNT == 0 && RUN_COUNT == 0 && SECONDS >= GRACE)); then echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 exit 3 fi diff --git a/dist/agy/ship-it/skills/ship-it/SKILL.md b/dist/agy/ship-it/skills/ship-it/SKILL.md index 956f142..7b17d8e 100644 --- a/dist/agy/ship-it/skills/ship-it/SKILL.md +++ b/dist/agy/ship-it/skills/ship-it/SKILL.md @@ -1,7 +1,7 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.2.0 +version: 1.2.1 user-invocable: true author: Rootiest --- diff --git a/dist/agy/ship-it/skills/ship-it/scripts/tea-ci-watch.sh b/dist/agy/ship-it/skills/ship-it/scripts/tea-ci-watch.sh index 509da7f..9a4a69d 100755 --- a/dist/agy/ship-it/skills/ship-it/scripts/tea-ci-watch.sh +++ b/dist/agy/ship-it/skills/ship-it/scripts/tea-ci-watch.sh @@ -15,10 +15,16 @@ 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. + Polls Gitea (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. + + Two sources are combined. Gitea posts an Actions job's commit status only + once a runner picks the job up, so queued workflows are invisible to the + combined status; the Actions runs API lists them from the moment they are + triggered. The commit is done only when every Actions run for it has + completed AND the combined status (which also covers external CI) is no + longer pending. The first failure in either source fails fast. 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 @@ -41,7 +47,8 @@ AUTHENTICATION RETURNS 0 All checks passed (success, warning, or skipped) - 1 One or more checks failed; failing jobs are printed + 1 One or more checks failed or were cancelled; failing + checks 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 @@ -81,30 +88,48 @@ 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 +# Fetch a Gitea API path as JSON. 'tea api' exits 0 even on HTTP errors, so the +# caller-supplied jq probe (which must succeed on a valid body) is the error signal. +fetch() { + local path="$1" probe="$2" body + if ! body=$(tea api "$path" 2>&1) || ! jq -e "$probe" <<<"$body" >/dev/null 2>&1; then + echo "ERROR: Request ${path} failed for ${REPO}@${SHA:0:7}: ${body}" >&2 exit 2 fi - COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + printf '%s' "$body" +} - case "$STATE" in - success | warning) - echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 - exit 0 - ;; - failure | error) +while ((SECONDS < TIMEOUT)); do + STATUS=$(fetch "/repos/${REPO}/commits/${SHA}/status" '.state | strings') || exit 2 + RUNS=$(fetch "/repos/${REPO}/actions/runs?head_sha=${SHA}&limit=50" '.workflow_runs | arrays') || exit 2 + + STATE=$(jq -r '.state' <<<"$STATUS") + COUNT=$(jq -r '.total_count' <<<"$STATUS") + RUN_COUNT=$(jq -r '.workflow_runs | length' <<<"$RUNS") + RUNS_PENDING=$(jq -r '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$RUNS") + RUNS_FAILED=$(jq -r '[.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled"))] | length' <<<"$RUNS") + + if [[ "$STATE" == failure || "$STATE" == error ]] || ((RUNS_FAILED > 0)); then 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 + | " \(.context): \(.description) (\(.target_url))"' <<<"$STATUS" >&2 + jq -r '.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled")) + | " \(.path) [\(.event)]: \(.conclusion) (\(.html_url))"' <<<"$RUNS" >&2 exit 1 - ;; - esac + fi + + # Done only when no run is queued/running and every posted status is terminal. + # Zero statuses with completed runs means every job was skipped (no status posted). + if ((RUNS_PENDING == 0)) && { [[ "$STATE" == success || "$STATE" == warning ]] || + ((COUNT == 0 && RUN_COUNT > 0)); }; then + echo "SUCCESS: All ${RUN_COUNT} workflow runs and ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + fi # A commit with no workflows reports "pending" with zero checks forever - if ((COUNT == 0 && SECONDS >= GRACE)); then + if ((COUNT == 0 && RUN_COUNT == 0 && SECONDS >= GRACE)); then echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 exit 3 fi diff --git a/dist/claude-code/rootiest-ai-all/skills/ship-it/SKILL.md b/dist/claude-code/rootiest-ai-all/skills/ship-it/SKILL.md index 956f142..7b17d8e 100644 --- a/dist/claude-code/rootiest-ai-all/skills/ship-it/SKILL.md +++ b/dist/claude-code/rootiest-ai-all/skills/ship-it/SKILL.md @@ -1,7 +1,7 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.2.0 +version: 1.2.1 user-invocable: true author: Rootiest --- diff --git a/dist/claude-code/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh b/dist/claude-code/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh index 509da7f..9a4a69d 100755 --- a/dist/claude-code/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh +++ b/dist/claude-code/rootiest-ai-all/skills/ship-it/scripts/tea-ci-watch.sh @@ -15,10 +15,16 @@ 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. + Polls Gitea (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. + + Two sources are combined. Gitea posts an Actions job's commit status only + once a runner picks the job up, so queued workflows are invisible to the + combined status; the Actions runs API lists them from the moment they are + triggered. The commit is done only when every Actions run for it has + completed AND the combined status (which also covers external CI) is no + longer pending. The first failure in either source fails fast. 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 @@ -41,7 +47,8 @@ AUTHENTICATION RETURNS 0 All checks passed (success, warning, or skipped) - 1 One or more checks failed; failing jobs are printed + 1 One or more checks failed or were cancelled; failing + checks 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 @@ -81,30 +88,48 @@ 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 +# Fetch a Gitea API path as JSON. 'tea api' exits 0 even on HTTP errors, so the +# caller-supplied jq probe (which must succeed on a valid body) is the error signal. +fetch() { + local path="$1" probe="$2" body + if ! body=$(tea api "$path" 2>&1) || ! jq -e "$probe" <<<"$body" >/dev/null 2>&1; then + echo "ERROR: Request ${path} failed for ${REPO}@${SHA:0:7}: ${body}" >&2 exit 2 fi - COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + printf '%s' "$body" +} - case "$STATE" in - success | warning) - echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 - exit 0 - ;; - failure | error) +while ((SECONDS < TIMEOUT)); do + STATUS=$(fetch "/repos/${REPO}/commits/${SHA}/status" '.state | strings') || exit 2 + RUNS=$(fetch "/repos/${REPO}/actions/runs?head_sha=${SHA}&limit=50" '.workflow_runs | arrays') || exit 2 + + STATE=$(jq -r '.state' <<<"$STATUS") + COUNT=$(jq -r '.total_count' <<<"$STATUS") + RUN_COUNT=$(jq -r '.workflow_runs | length' <<<"$RUNS") + RUNS_PENDING=$(jq -r '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$RUNS") + RUNS_FAILED=$(jq -r '[.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled"))] | length' <<<"$RUNS") + + if [[ "$STATE" == failure || "$STATE" == error ]] || ((RUNS_FAILED > 0)); then 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 + | " \(.context): \(.description) (\(.target_url))"' <<<"$STATUS" >&2 + jq -r '.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled")) + | " \(.path) [\(.event)]: \(.conclusion) (\(.html_url))"' <<<"$RUNS" >&2 exit 1 - ;; - esac + fi + + # Done only when no run is queued/running and every posted status is terminal. + # Zero statuses with completed runs means every job was skipped (no status posted). + if ((RUNS_PENDING == 0)) && { [[ "$STATE" == success || "$STATE" == warning ]] || + ((COUNT == 0 && RUN_COUNT > 0)); }; then + echo "SUCCESS: All ${RUN_COUNT} workflow runs and ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + fi # A commit with no workflows reports "pending" with zero checks forever - if ((COUNT == 0 && SECONDS >= GRACE)); then + if ((COUNT == 0 && RUN_COUNT == 0 && SECONDS >= GRACE)); then echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 exit 3 fi diff --git a/dist/claude-code/ship-it/.claude-plugin/plugin.json b/dist/claude-code/ship-it/.claude-plugin/plugin.json index 8234dcc..f2ebd81 100644 --- a/dist/claude-code/ship-it/.claude-plugin/plugin.json +++ b/dist/claude-code/ship-it/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ship-it", "description": "Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR.", - "version": "1.2.0", + "version": "1.2.1", "author": { "name": "Rootiest" } diff --git a/dist/claude-code/ship-it/skills/ship-it/SKILL.md b/dist/claude-code/ship-it/skills/ship-it/SKILL.md index 956f142..7b17d8e 100644 --- a/dist/claude-code/ship-it/skills/ship-it/SKILL.md +++ b/dist/claude-code/ship-it/skills/ship-it/SKILL.md @@ -1,7 +1,7 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.2.0 +version: 1.2.1 user-invocable: true author: Rootiest --- diff --git a/dist/claude-code/ship-it/skills/ship-it/scripts/tea-ci-watch.sh b/dist/claude-code/ship-it/skills/ship-it/scripts/tea-ci-watch.sh index 509da7f..9a4a69d 100755 --- a/dist/claude-code/ship-it/skills/ship-it/scripts/tea-ci-watch.sh +++ b/dist/claude-code/ship-it/skills/ship-it/scripts/tea-ci-watch.sh @@ -15,10 +15,16 @@ 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. + Polls Gitea (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. + + Two sources are combined. Gitea posts an Actions job's commit status only + once a runner picks the job up, so queued workflows are invisible to the + combined status; the Actions runs API lists them from the moment they are + triggered. The commit is done only when every Actions run for it has + completed AND the combined status (which also covers external CI) is no + longer pending. The first failure in either source fails fast. 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 @@ -41,7 +47,8 @@ AUTHENTICATION RETURNS 0 All checks passed (success, warning, or skipped) - 1 One or more checks failed; failing jobs are printed + 1 One or more checks failed or were cancelled; failing + checks 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 @@ -81,30 +88,48 @@ 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 +# Fetch a Gitea API path as JSON. 'tea api' exits 0 even on HTTP errors, so the +# caller-supplied jq probe (which must succeed on a valid body) is the error signal. +fetch() { + local path="$1" probe="$2" body + if ! body=$(tea api "$path" 2>&1) || ! jq -e "$probe" <<<"$body" >/dev/null 2>&1; then + echo "ERROR: Request ${path} failed for ${REPO}@${SHA:0:7}: ${body}" >&2 exit 2 fi - COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + printf '%s' "$body" +} - case "$STATE" in - success | warning) - echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 - exit 0 - ;; - failure | error) +while ((SECONDS < TIMEOUT)); do + STATUS=$(fetch "/repos/${REPO}/commits/${SHA}/status" '.state | strings') || exit 2 + RUNS=$(fetch "/repos/${REPO}/actions/runs?head_sha=${SHA}&limit=50" '.workflow_runs | arrays') || exit 2 + + STATE=$(jq -r '.state' <<<"$STATUS") + COUNT=$(jq -r '.total_count' <<<"$STATUS") + RUN_COUNT=$(jq -r '.workflow_runs | length' <<<"$RUNS") + RUNS_PENDING=$(jq -r '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$RUNS") + RUNS_FAILED=$(jq -r '[.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled"))] | length' <<<"$RUNS") + + if [[ "$STATE" == failure || "$STATE" == error ]] || ((RUNS_FAILED > 0)); then 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 + | " \(.context): \(.description) (\(.target_url))"' <<<"$STATUS" >&2 + jq -r '.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled")) + | " \(.path) [\(.event)]: \(.conclusion) (\(.html_url))"' <<<"$RUNS" >&2 exit 1 - ;; - esac + fi + + # Done only when no run is queued/running and every posted status is terminal. + # Zero statuses with completed runs means every job was skipped (no status posted). + if ((RUNS_PENDING == 0)) && { [[ "$STATE" == success || "$STATE" == warning ]] || + ((COUNT == 0 && RUN_COUNT > 0)); }; then + echo "SUCCESS: All ${RUN_COUNT} workflow runs and ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + fi # A commit with no workflows reports "pending" with zero checks forever - if ((COUNT == 0 && SECONDS >= GRACE)); then + if ((COUNT == 0 && RUN_COUNT == 0 && SECONDS >= GRACE)); then echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 exit 3 fi diff --git a/plugins/ship-it/plugin.json b/plugins/ship-it/plugin.json index b10aa77..606a105 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.2.0", + "version": "1.2.1", "author": { "name": "Rootiest" } } diff --git a/plugins/ship-it/skills/ship-it/SKILL.md b/plugins/ship-it/skills/ship-it/SKILL.md index 956f142..7b17d8e 100644 --- a/plugins/ship-it/skills/ship-it/SKILL.md +++ b/plugins/ship-it/skills/ship-it/SKILL.md @@ -1,7 +1,7 @@ --- name: ship-it description: Runs a comprehensive pre-flight audit, syncs the README, and publishes the changes to a new PR. -version: 1.2.0 +version: 1.2.1 user-invocable: true author: Rootiest --- diff --git a/plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh b/plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh index 509da7f..9a4a69d 100755 --- a/plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh +++ b/plugins/ship-it/skills/ship-it/scripts/tea-ci-watch.sh @@ -15,10 +15,16 @@ 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. + Polls Gitea (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. + + Two sources are combined. Gitea posts an Actions job's commit status only + once a runner picks the job up, so queued workflows are invisible to the + combined status; the Actions runs API lists them from the moment they are + triggered. The commit is done only when every Actions run for it has + completed AND the combined status (which also covers external CI) is no + longer pending. The first failure in either source fails fast. 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 @@ -41,7 +47,8 @@ AUTHENTICATION RETURNS 0 All checks passed (success, warning, or skipped) - 1 One or more checks failed; failing jobs are printed + 1 One or more checks failed or were cancelled; failing + checks 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 @@ -81,30 +88,48 @@ 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 +# Fetch a Gitea API path as JSON. 'tea api' exits 0 even on HTTP errors, so the +# caller-supplied jq probe (which must succeed on a valid body) is the error signal. +fetch() { + local path="$1" probe="$2" body + if ! body=$(tea api "$path" 2>&1) || ! jq -e "$probe" <<<"$body" >/dev/null 2>&1; then + echo "ERROR: Request ${path} failed for ${REPO}@${SHA:0:7}: ${body}" >&2 exit 2 fi - COUNT=$(jq -r '.total_count' <<<"$RESPONSE") + printf '%s' "$body" +} - case "$STATE" in - success | warning) - echo "SUCCESS: All ${COUNT} checks passed for ${SHA:0:7}." >&2 - exit 0 - ;; - failure | error) +while ((SECONDS < TIMEOUT)); do + STATUS=$(fetch "/repos/${REPO}/commits/${SHA}/status" '.state | strings') || exit 2 + RUNS=$(fetch "/repos/${REPO}/actions/runs?head_sha=${SHA}&limit=50" '.workflow_runs | arrays') || exit 2 + + STATE=$(jq -r '.state' <<<"$STATUS") + COUNT=$(jq -r '.total_count' <<<"$STATUS") + RUN_COUNT=$(jq -r '.workflow_runs | length' <<<"$RUNS") + RUNS_PENDING=$(jq -r '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$RUNS") + RUNS_FAILED=$(jq -r '[.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled"))] | length' <<<"$RUNS") + + if [[ "$STATE" == failure || "$STATE" == error ]] || ((RUNS_FAILED > 0)); then 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 + | " \(.context): \(.description) (\(.target_url))"' <<<"$STATUS" >&2 + jq -r '.workflow_runs[] | select(.status == "completed" + and (.conclusion == "failure" or .conclusion == "cancelled")) + | " \(.path) [\(.event)]: \(.conclusion) (\(.html_url))"' <<<"$RUNS" >&2 exit 1 - ;; - esac + fi + + # Done only when no run is queued/running and every posted status is terminal. + # Zero statuses with completed runs means every job was skipped (no status posted). + if ((RUNS_PENDING == 0)) && { [[ "$STATE" == success || "$STATE" == warning ]] || + ((COUNT == 0 && RUN_COUNT > 0)); }; then + echo "SUCCESS: All ${RUN_COUNT} workflow runs and ${COUNT} checks passed for ${SHA:0:7}." >&2 + exit 0 + fi # A commit with no workflows reports "pending" with zero checks forever - if ((COUNT == 0 && SECONDS >= GRACE)); then + if ((COUNT == 0 && RUN_COUNT == 0 && SECONDS >= GRACE)); then echo "NO CI: No checks registered for ${SHA:0:7} after ${GRACE}s." >&2 exit 3 fi -- 2.54.0