chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
@@ -0,0 +1,68 @@
# Woodpecker CI Tool Suite
Interact with Woodpecker CI pipelines (list builds, check status, trigger builds).
## Prerequisites
- `jq` and `curl` installed
- Woodpecker credentials in `~/.config/mosaic/credentials.json`
## Setup
A Woodpecker API token is required. To configure:
1. Go to Woodpecker CI → User Settings → API
2. Generate a personal token
3. Add to `credentials.json`:
```json
{
"woodpecker": {
"url": "https://ci.mosaicstack.dev",
"token": "YOUR_TOKEN_HERE"
}
}
```
## Scripts
| Script | Purpose |
| -------------------------- | -------------------------------------------------------------- |
| `pipeline-list.sh` | List recent pipelines for a repo |
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
| `pipeline-trigger.sh` | Trigger a new pipeline build |
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
## Common Options
- `-r owner/repo` — Repository (auto-detected from git remote if omitted)
- `-f json` — JSON output (default: table)
- `-h` — Show help
## API Reference
- Base URL: `https://ci.mosaicstack.dev`
- API prefix: `/api/`
- Auth: Bearer token in `Authorization` header
## Examples
```bash
# List recent builds
~/.config/mosaic/tools/woodpecker/pipeline-list.sh
# Check latest build status
~/.config/mosaic/tools/woodpecker/pipeline-status.sh
# Trigger a build on a specific branch
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch
# Block until one or more pipelines finish (event-driven CI wait)
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
# Verify the full JSON child-step record; do not use the text summary for this gate
PR_HEAD=<full-40-hex-provider-head>
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
```
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# _lib.sh — Shared helpers for Woodpecker CI tool scripts
#
# Usage: source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
#
# Requires: WOODPECKER_URL and WOODPECKER_TOKEN to be set (via load_credentials)
# Resolve owner/repo name to numeric repo ID (required by Woodpecker v3 API)
# Usage: REPO_ID=$(wp_resolve_repo_id "owner/repo")
wp_resolve_repo_id() {
local full_name="$1"
local response http_code body repo_id
response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to look up repo '${full_name}' (HTTP $http_code)" >&2
if echo "$body" | jq -e '.message' &>/dev/null; then
echo " $(echo "$body" | jq -r '.message')" >&2
fi
return 1
fi
repo_id=$(echo "$body" | jq -r '.id // empty')
if [[ -z "$repo_id" ]]; then
echo "Error: Repo lookup returned no ID for '${full_name}'" >&2
return 1
fi
echo "$repo_id"
}
# Auto-detect repo name from git remote origin
# Usage: REPO=$(wp_detect_repo)
wp_detect_repo() {
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null || true)
if [[ -n "$remote_url" ]]; then
echo "$remote_url" | sed -E 's|\.git$||' | sed -E 's|.*[:/]([^/]+/[^/]+)$|\1|'
else
echo "Error: -r owner/repo required (not in a git repository)" >&2
return 1
fi
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ci-wait.sh — block until one or more Woodpecker pipelines reach terminal state.
#
# Problem it solves: orchestrators hand-author a `while true; curl .../repos/1/pipelines/$n
# ...; sleep` loop for every CI wait. Those loops HARDCODE Woodpecker repo id 1 (only
# correct for whichever repo happens to be id 1), re-implement URL building with raw
# curl, and tend to get armed as tight <300s ScheduleWakeup polls (each poll = a full
# wake+reload+recheck cycle). This encapsulates the loop once, on top of the existing
# `pipeline-status.sh` wrapper (which resolves repo->id correctly and is instance-aware),
# so a CI wait becomes a one-liner.
#
# Intended use: as the COMMAND of a Monitor / event-driven re-invoke (primary), paired
# with a single long (>=1500s) timed fallback — NOT as a tight standalone poll.
#
# Usage:
# ci-wait.sh -r <owner/repo> -n <num> [-n <num> ...] [-a <instance>] [-i <interval>] [-t <timeout>]
# ci-wait.sh -r usc/uconnect -n 3917 -n 3918 # wait for both, infer instance
# ci-wait.sh -r usc/uconnect -n 3922 -a usc -i 30 -t 2400
#
# Instance is inferred from the owner (usc->usc, mosaicstack/mosaic->mosaic) unless -a given.
# Exit: 0 = all pipelines terminal AND all 'success'; 1 = >=1 terminal non-success;
# 2 = usage/precondition error; 3 = timeout before all terminal.
set -euo pipefail
# Resolve pipeline-status.sh as a sibling, matching how the woodpecker tools source
# _lib.sh — works under the installed runtime AND an in-repo checkout, no MOSAIC_HOME dep.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PS="$SCRIPT_DIR/pipeline-status.sh"
REPO="" INSTANCE="" INTERVAL=30 TIMEOUT=3600
NUMS=()
while getopts "r:n:a:i:t:h" opt; do
case "$opt" in
r) REPO="$OPTARG" ;;
n) NUMS+=("$OPTARG") ;;
a) INSTANCE="$OPTARG" ;;
i) INTERVAL="$OPTARG" ;;
t) TIMEOUT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "see -h" >&2; exit 2 ;;
esac
done
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
[[ ${#NUMS[@]} -gt 0 ]] || { echo "FATAL: at least one -n <pipeline-number> required" >&2; exit 2; }
[[ -x "$PS" ]] || { echo "FATAL: pipeline-status.sh not found/executable at $PS" >&2; exit 2; }
# Infer Woodpecker instance from owner unless overridden (matches the git-wrapper convention).
if [[ -z "$INSTANCE" ]]; then
case "${REPO%%/*}" in
usc|USC) INSTANCE=usc ;;
mosaicstack|mosaic) INSTANCE=mosaic ;;
*) echo "FATAL: cannot infer Woodpecker instance for owner '${REPO%%/*}' — pass -a <instance>" >&2; exit 2 ;;
esac
fi
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 2; }
TERMINAL_RE='^(success|failure|error|killed|declined|blocked)$'
declare -A STATE=() # num -> terminal status, once reached
start=$(date +%s 2>/dev/null || echo 0)
echo "ci-wait: $REPO pipelines [${NUMS[*]}] (instance=$INSTANCE, every ${INTERVAL}s, timeout ${TIMEOUT}s)"
while true; do
for n in "${NUMS[@]}"; do
[[ -n "${STATE[$n]:-}" ]] && continue
s=$("$PS" -r "$REPO" -n "$n" -a "$INSTANCE" -f json 2>/dev/null | jq -r '.status // empty' 2>/dev/null || true)
if [[ "$s" =~ $TERMINAL_RE ]]; then
STATE[$n]="$s"
echo " pipeline $n TERMINAL: $s"
fi
done
# all terminal?
if [[ ${#STATE[@]} -eq ${#NUMS[@]} ]]; then
bad=0
for n in "${NUMS[@]}"; do [[ "${STATE[$n]}" == "success" ]] || bad=1; done
if [[ $bad -eq 0 ]]; then echo "ci-wait: ALL SUCCESS"; exit 0; fi
echo "ci-wait: all terminal, NOT all success — $(for n in "${NUMS[@]}"; do printf '%s=%s ' "$n" "${STATE[$n]}"; done)"
exit 1
fi
now=$(date +%s 2>/dev/null || echo 0)
if [[ "$start" != 0 && $((now - start)) -ge $TIMEOUT ]]; then
echo "ci-wait: TIMEOUT after ${TIMEOUT}s — pending: $(for n in "${NUMS[@]}"; do [[ -z "${STATE[$n]:-}" ]] && printf '%s ' "$n"; done)"
exit 3
fi
sleep "$INTERVAL"
done
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# pipeline-list.sh — List Woodpecker CI pipelines
#
# Usage: pipeline-list.sh [-r owner/repo] [-l limit] [-f format] [-a instance]
#
# Options:
# -r repo Repository in owner/repo format (default: current repo)
# -l limit Number of pipelines to show (default: 20)
# -f format Output format: table (default), json
# -a instance Woodpecker instance name (e.g. usc, mosaic)
# -h Show this help
#
# Requires: woodpecker credentials in credentials.json
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
source "$MOSAIC_HOME/tools/_lib/credentials.sh"
source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
REPO=""
LIMIT=20
FORMAT="table"
WP_INSTANCE=""
while getopts "r:l:f:a:h" opt; do
case $opt in
r) REPO="$OPTARG" ;;
l) LIMIT="$OPTARG" ;;
f) FORMAT="$OPTARG" ;;
a) WP_INSTANCE="$OPTARG" ;;
h) head -14 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;;
*) echo "Usage: $0 [-r owner/repo] [-l limit] [-f format] [-a instance]" >&2; exit 1 ;;
esac
done
if [[ -n "$WP_INSTANCE" ]]; then
load_credentials "woodpecker-${WP_INSTANCE}"
else
load_credentials woodpecker
fi
# Auto-detect repo from git remote if not specified
if [[ -z "$REPO" ]]; then
REPO=$(wp_detect_repo) || exit 1
fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list pipelines (HTTP $http_code)" >&2
exit 1
fi
if [[ "$FORMAT" == "json" ]]; then
echo "$body" | jq '.'
exit 0
fi
echo "NUMBER STATUS BRANCH EVENT MESSAGE"
echo "------ -------- -------------------- -------- ----------------------------------------"
echo "$body" | jq -r '.[] | [
(.number | tostring),
.status,
.branch,
.event,
(.message | split("\n")[0])
] | @tsv' | while IFS=$'\t' read -r number status branch event message; do
printf "%-6s %-8s %-20s %-8s %s\n" \
"$number" "$status" "${branch:0:20}" "$event" "${message:0:40}"
done
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# pipeline-status.sh — Check Woodpecker CI pipeline status
#
# Usage: pipeline-status.sh [-r owner/repo] [-n number] [-f format] [-a instance]
#
# Options:
# -r repo Repository in owner/repo format (default: current repo)
# -n number Pipeline number (default: latest)
# -f format Output format: table (default), json
# -a instance Woodpecker instance name (e.g. usc, mosaic)
# -h Show this help
#
# Requires: woodpecker credentials in credentials.json
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
source "$MOSAIC_HOME/tools/_lib/credentials.sh"
source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
REPO=""
NUMBER=""
FORMAT="table"
WP_INSTANCE=""
while getopts "r:n:f:a:h" opt; do
case $opt in
r) REPO="$OPTARG" ;;
n) NUMBER="$OPTARG" ;;
f) FORMAT="$OPTARG" ;;
a) WP_INSTANCE="$OPTARG" ;;
h) head -14 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;;
*) echo "Usage: $0 [-r owner/repo] [-n number] [-f format] [-a instance]" >&2; exit 1 ;;
esac
done
if [[ -n "$WP_INSTANCE" ]]; then
load_credentials "woodpecker-${WP_INSTANCE}"
else
load_credentials woodpecker
fi
if [[ -z "$REPO" ]]; then
REPO=$(wp_detect_repo) || exit 1
fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
_wp_fetch() {
local ep="$1"
local resp http_code body
resp=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"$ep")
http_code=$(echo "$resp" | tail -n1)
body=$(echo "$resp" | sed '$d')
if [[ "$http_code" != "200" ]]; then
echo "Error: HTTP $http_code from $ep" >&2
return 1
fi
echo "$body"
}
if [[ -z "$NUMBER" ]]; then
# Get latest pipeline number from list, then fetch full detail
list_body=$(_wp_fetch "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=1") || exit 1
NUMBER=$(echo "$list_body" | jq -r '.[0].number // empty')
if [[ -z "$NUMBER" ]]; then
echo "Error: No pipelines found" >&2
exit 1
fi
fi
# Always fetch the single-pipeline endpoint (includes workflows/steps)
body=$(_wp_fetch "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines/${NUMBER}") || exit 1
if [[ "$FORMAT" == "json" ]]; then
echo "$body" | jq '.'
exit 0
fi
echo "Pipeline Status"
echo "==============="
echo "$body" | jq -r '
def ts: if . and . > 0 then todate else "—" end;
" Number: \(.number)\n" +
" Status: \(.status)\n" +
" Branch: \(.branch)\n" +
" Event: \(.event)\n" +
" Commit: \(.commit[:12])\n" +
" Message: \(.message | split("\n")[0])\n" +
" Author: \(.author)\n" +
" Started: \(.started | ts)\n" +
" Finished: \(.finished | ts)"
'
# Show step-level details if workflows exist
has_workflows=$(echo "$body" | jq 'has("workflows") and (.workflows | length > 0)')
if [[ "$has_workflows" == "true" ]]; then
echo ""
echo "Steps"
echo "-----"
echo "$body" | jq -r '
.workflows[] | .children[]? |
select(.type != "clone") |
" " +
(if .state == "success" then "OK"
elif .state == "failure" then "FAIL"
elif .state == "running" then "RUN"
elif .state == "skipped" then "SKIP"
elif .state == "pending" then "WAIT"
else .state end) +
" " + .name +
(if .error and .error != "" then " (" + .error + ")" else "" end) +
(if .exit_code and .exit_code != 0 then " [exit " + (.exit_code | tostring) + "]" else "" end)
'
fi
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# pipeline-trigger.sh — Trigger a Woodpecker CI pipeline
#
# Usage: pipeline-trigger.sh [-r owner/repo] [-b branch] [-a instance]
#
# Options:
# -r repo Repository in owner/repo format (default: current repo)
# -b branch Branch to build (default: main)
# -a instance Woodpecker instance name (e.g. usc, mosaic)
# -h Show this help
#
# Requires: woodpecker credentials in credentials.json
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
source "$MOSAIC_HOME/tools/_lib/credentials.sh"
source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh"
REPO=""
BRANCH="main"
WP_INSTANCE=""
while getopts "r:b:a:h" opt; do
case $opt in
r) REPO="$OPTARG" ;;
b) BRANCH="$OPTARG" ;;
a) WP_INSTANCE="$OPTARG" ;;
h) head -14 "$0" | grep "^#" | sed 's/^# \?//'; exit 0 ;;
*) echo "Usage: $0 [-r owner/repo] [-b branch] [-a instance]" >&2; exit 1 ;;
esac
done
if [[ -n "$WP_INSTANCE" ]]; then
load_credentials "woodpecker-${WP_INSTANCE}"
else
load_credentials woodpecker
fi
if [[ -z "$REPO" ]]; then
REPO=$(wp_detect_repo) || exit 1
fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
echo "Triggering pipeline for $REPO on branch $BRANCH..."
response=$(curl -sS -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "201" ]]; then
echo "Error: Failed to trigger pipeline (HTTP $http_code)" >&2
echo "$body" | jq -r '.' 2>/dev/null >&2 || echo "$body" >&2
exit 1
fi
number=$(echo "$body" | jq -r '.number')
echo "Pipeline #$number triggered successfully"
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Regression harness for ci-wait.sh terminal-state aggregation and exit codes.
#
# ci-wait.sh wraps pipeline-status.sh and blocks until every requested pipeline
# reaches a terminal Woodpecker state, then maps the aggregate to an exit code.
# That contract is what callers arm a Monitor/timed-fallback around, so it must be
# exact. This harness drives ci-wait.sh against a stub pipeline-status.sh whose
# per-pipeline status is fixture-controlled, and asserts the full exit matrix:
#
# 0 = every pipeline terminal AND all 'success'
# 1 = every pipeline terminal, at least one non-success
# 2 = usage/precondition error (missing -n)
# 3 = timeout before all pipelines terminal
#
# Non-vacuity: each case pins a DISTINCT exit code to a distinct fixture, so a
# regression in success-aggregation (case 0 vs 1), terminal detection (case 3),
# or arg validation (case 2) flips exactly one assertion RED.
set -euo pipefail
CIW_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ci-wait.sh"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-wait-exit-matrix}"
TOOL_DIR="$WORK_DIR/tool"
rm -rf "$WORK_DIR"
mkdir -p "$TOOL_DIR"
# ci-wait.sh resolves pipeline-status.sh as a sibling ($SCRIPT_DIR/pipeline-status.sh),
# so we run a COPY of ci-wait.sh next to a stub sibling we control.
cp "$CIW_SRC" "$TOOL_DIR/ci-wait.sh"
chmod +x "$TOOL_DIR/ci-wait.sh"
# Stub pipeline-status.sh: emits {"status":"<s>"} where <s> comes from env
# CIW_STATUS_<num> (default "running" = non-terminal, drives the timeout path).
cat > "$TOOL_DIR/pipeline-status.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
num=""
while getopts "r:n:a:f:" opt; do case "$opt" in n) num="$OPTARG" ;; *) : ;; esac; done
var="CIW_STATUS_${num}"
printf '{"status":"%s"}\n' "${!var:-running}"
SH
chmod +x "$TOOL_DIR/pipeline-status.sh"
CIW="$TOOL_DIR/ci-wait.sh"
run_expect() { # $1 = expected exit $2 = label ; rest = args
local want="$1" label="$2"; shift 2
local rc=0
"$CIW" "$@" >/dev/null 2>&1 || rc=$?
if [[ "$rc" -ne "$want" ]]; then
echo "FAIL [$label]: expected exit $want, got $rc" >&2; exit 1
fi
echo "PASS [$label]: exit $rc"
}
# 0 — both pipelines terminal + success
CIW_STATUS_100=success CIW_STATUS_101=success \
run_expect 0 "all-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 1 — both terminal, one failure
CIW_STATUS_100=success CIW_STATUS_101=failure \
run_expect 1 "terminal-not-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 1 — other terminal non-success states still map to 1 (error/killed)
CIW_STATUS_100=error CIW_STATUS_101=killed \
run_expect 1 "terminal-error-killed" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
# 3 — a pipeline never reaches terminal state before timeout
CIW_STATUS_100=success CIW_STATUS_101=running \
run_expect 3 "timeout-pending" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 0
# 2 — usage error: no -n
run_expect 2 "usage-missing-n" -r mosaic/stack -a mosaic
echo "ALL PASS: test-ci-wait-exit-matrix.sh"
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Red-first contract harness for RM-61 / #1000.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
write_fixture() {
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
import json, sys
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
steps = [
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
]
json.dump({
"number": 9999,
"status": pipeline_status,
"commit": "a" * 40,
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
}, open(path, "w"))
PY
}
expect_exit() {
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
set +e
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
actual=$?
set -e
if [[ "$actual" -ne "$expected_exit" ]]; then
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
exit 1
fi
printf 'PASS %s\n' "$label"
printf '%s' "$output"
}
# Ordinary terminal green.
write_fixture "$TMP/green.json" success success 0 '' success
out=$(expect_exit 0 green "$TMP/green.json")
grep -q '"total_steps": 3' <<<"$out"
grep -q '"exempted_steps": 0' <<<"$out"
# Exact, named #1000 teardown artifact: the only permitted non-success child.
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
grep -q '"exempted_steps": 1' <<<"$out"
# Negative controls: both real PostgreSQL failures must remain red.
write_fixture "$TMP/startup.json" failure failure 1 '' failure
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
write_fixture "$TMP/crash.json" failure failure 137 '' failure
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
# The exemption is signature-scoped, not step-scoped.
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
import json, os, sys
record = json.load(open(sys.argv[1]))
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
changed = json.loads(json.dumps(record))
changed["workflows"][0]["children"][1]["exit_code"] = value
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
PY
for label in false true float string null; do
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
done
# Exact artifact cannot mask any independent failure or non-success pipeline.
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
write_fixture "$TMP/skipped.json" success success 0 '' skipped
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
# The scanned pipeline must be bound to an explicit, full PR-head commit.
set +e
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
missing_rc=$?
set -e
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
exit 1
fi
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
import json, sys
record = json.load(open(sys.argv[1]))
record.pop("commit")
json.dump(record, open(sys.argv[2], "w"))
PY
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
printf 'terminal-green contract harness: PASS (17 cases)\n'
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
RM-61 permits one named, signature-scoped exception for issue #1000. The
exception retires when #1000 is fixed; all other non-success states block.
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
It does not fetch, retry, or re-trigger pipelines.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
POD_NOT_FOUND = re.compile(
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
)
def fail_usage(message: str) -> int:
print(f"terminal-green contract input error: {message}", file=sys.stderr)
return 2
def load_record(argument: str | None) -> dict[str, Any]:
if argument in (None, "-"):
value = json.load(sys.stdin)
else:
with Path(argument).open(encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise ValueError("pipeline record must be a JSON object")
return value
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
error = step.get("error")
exit_code = step.get("exit_code")
return (
step.get("name") == "ci-postgres"
and step.get("type") == "service"
and step.get("state") == "failure"
and type(exit_code) is int
and not isinstance(exit_code, bool)
and exit_code == 0
and isinstance(error, str)
and POD_NOT_FOUND.fullmatch(error) is not None
)
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
anomalies: list[dict[str, Any]] = []
candidates: list[dict[str, Any]] = []
steps: list[dict[str, Any]] = []
pipeline_status = record.get("status")
actual_commit = record.get("commit")
if actual_commit != expected_commit:
anomalies.append(
{
"scope": "pipeline",
"name": str(record.get("number", "unknown")),
"state": pipeline_status,
"reason": "pipeline commit does not equal the expected PR head",
"expected_commit": expected_commit,
"actual_commit": actual_commit,
}
)
if pipeline_status != "success":
anomalies.append(
{
"scope": "pipeline",
"name": str(record.get("number", "unknown")),
"state": pipeline_status,
"reason": "pipeline status is not success",
}
)
workflows = record.get("workflows")
if not isinstance(workflows, list) or not workflows:
anomalies.append(
{
"scope": "pipeline",
"name": str(record.get("number", "unknown")),
"state": pipeline_status,
"reason": "workflows are missing or empty",
}
)
workflows = []
for workflow_index, workflow in enumerate(workflows):
if not isinstance(workflow, dict):
anomalies.append(
{
"scope": "workflow",
"name": str(workflow_index),
"state": None,
"reason": "workflow is not an object",
}
)
continue
workflow_name = str(workflow.get("name", workflow_index))
if workflow.get("state") != "success":
anomalies.append(
{
"scope": "workflow",
"name": workflow_name,
"state": workflow.get("state"),
"reason": "workflow state is not success",
}
)
children = workflow.get("children")
if not isinstance(children, list) or not children:
anomalies.append(
{
"scope": "workflow",
"name": workflow_name,
"state": workflow.get("state"),
"reason": "child-step list is missing or empty",
}
)
continue
for child_index, child in enumerate(children):
if not isinstance(child, dict):
anomalies.append(
{
"scope": "step",
"name": f"{workflow_name}[{child_index}]",
"state": None,
"reason": "step is not an object",
}
)
continue
steps.append(child)
if child.get("state") == "success":
continue
if is_issue_1000_artifact(child):
candidates.append(child)
continue
anomalies.append(
{
"scope": "step",
"name": child.get("name"),
"type": child.get("type"),
"state": child.get("state"),
"exit_code": child.get("exit_code"),
"error": child.get("error"),
"reason": "non-success step does not match the #1000 teardown signature",
}
)
if len(candidates) > 1:
anomalies.append(
{
"scope": "exemption",
"name": EXEMPTION_ID,
"state": "invalid",
"reason": "the #1000 exemption may apply to exactly one step",
}
)
exemption_applies = len(candidates) == 1 and not anomalies
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
result: dict[str, Any] = {
"schema_version": "mosaic-terminal-green/v1",
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
"pipeline_number": record.get("number"),
"commit": actual_commit,
"expected_commit": expected_commit,
"pipeline_status": pipeline_status,
"total_steps": len(steps),
"state_counts": dict(sorted(state_counts.items())),
"exempted_steps": 1 if exemption_applies else 0,
"anomalies": anomalies,
}
if exemption_applies:
candidate = candidates[0]
result["exemptions"] = [
{
"exemption_id": EXEMPTION_ID,
"step": candidate.get("name"),
"signature": candidate.get("error"),
"tracking_issue": EXEMPTION_ISSUE,
"retires_when": "issue #1000 is fixed",
}
]
else:
result["exemptions"] = []
return (0 if not anomalies else 1), result
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="verify the full Woodpecker terminal-green child-step contract"
)
parser.add_argument(
"--expect-commit",
required=True,
metavar="FULL_SHA",
help="full 40-hex PR-head commit that the pipeline record must match",
)
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
arguments = parser.parse_args()
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
parser.error("--expect-commit must be a full 40-hex commit")
arguments.expect_commit = arguments.expect_commit.lower()
return arguments
def main() -> int:
arguments = parse_arguments()
try:
record = load_record(arguments.record)
except (OSError, ValueError, json.JSONDecodeError) as error:
return fail_usage(str(error))
code, result = verify(record, arguments.expect_commit)
print(json.dumps(result, indent=2, sort_keys=True))
return code
if __name__ == "__main__":
raise SystemExit(main())