feat(builder): query CI via project status script

When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
This commit is contained in:
2026-10-15 09:56:51 +08:00
parent 0c9a530390
commit 9ba9b3da0f
17 changed files with 743 additions and 595 deletions
+49 -52
View File
@@ -551,8 +551,8 @@ def configured_value(
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve COPY_SCRIPT without allowing arbitrary project-relative paths."""
def resolve_builder_local_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve a project script; relative paths must stay under .pouch/builder/."""
configured = Path(script).expanduser()
if configured.is_absolute():
return configured, None
@@ -561,10 +561,17 @@ def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str |
allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root)
except ValueError:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return None, "relative script must be under .pouch/builder/"
return resolved, None
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
path, error = resolve_builder_local_script(project, script)
if error:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return path, None
def check_copy_script(
report: Report,
project: Path,
@@ -591,6 +598,9 @@ def check_copy_script(
def read_venue(project: Path) -> str:
env = (os.environ.get("BUILD_VENUE") or "").strip()
if env in ("local", "ci"):
return env
result = run_make(project, "venue")
if not has_no_rule(result):
echoed = parse_dry_echo(result)
@@ -604,34 +614,35 @@ def read_venue(project: Path) -> str:
return raw or "local"
def origin_forge(project: Path) -> str:
result = subprocess.run(
["git", "-C", str(project), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
def check_ci_status_script(
report: Report,
project: Path,
env_values: dict[str, str],
make_values: dict[str, str],
) -> None:
if read_venue(project) != "ci":
report.add(SKIP, 15, "CI_STATUS_SCRIPT 可执行", "BUILD_VENUE is not ci")
return
script = configured_value("CI_STATUS_SCRIPT", env_values, make_values)
problems = []
if not script:
problems.append("BUILD_VENUE=ci requires CI_STATUS_SCRIPT")
path = None
resolve_error = None
else:
path, resolve_error = resolve_builder_local_script(project, script)
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("CI_STATUS_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("CI_STATUS_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
15,
"CI_STATUS_SCRIPT 可执行",
"\n".join(problems) if problems else f"ci status script: {script}",
)
url = (result.stdout or "").strip()
configured = env_file_values(project).get("BUILDER_CI_FORGE", "").strip().lower()
forced = (os.environ.get("BUILDER_CI_FORGE") or configured).strip().lower()
if forced in ("github", "gitlab", "gitea"):
return forced
if "github.com" in url:
return "github"
host_match = re.search(r"(?:https?://|ssh://git@|git@)([^/:]+)", url)
host = host_match.group(1).lower() if host_match else ""
builder_keys = env_file_keys(project)
if (
"gitea" in host
or "GITEA_TOKEN" in builder_keys
or "GITEA_API_URL" in builder_keys
or os.environ.get("GITEA_TOKEN")
or os.environ.get("GITEA_API_URL")
):
return "gitea"
if url:
return "gitlab"
return ""
def check_ready_toolchain(
@@ -655,28 +666,7 @@ def check_ready_toolchain(
lines.append(
f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}"
)
forge = origin_forge(project) if project is not None else ""
if forge == "github":
gh_ok = shutil.which("gh") is not None
lines.append(f"gh: {'found' if gh_ok else 'MISSING (blocks ci venue)'}")
if not gh_ok:
problems.append("install gh and authenticate for this GitHub repo")
elif forge == "gitlab":
glab_ok = shutil.which("glab") is not None
lines.append(f"glab: {'found' if glab_ok else 'MISSING (blocks ci venue)'}")
if not glab_ok:
problems.append("install glab and authenticate for this GitLab project")
elif forge == "gitea":
curl_ok = shutil.which("curl") is not None
lines.append(f"curl: {'found' if curl_ok else 'MISSING (blocks ci venue)'}")
lines.append("Gitea API: /api/v1; GITEA_TOKEN optional for public repositories")
if not curl_ok:
problems.append("install curl to read Gitea Actions for this repository")
else:
glab_ok = shutil.which("glab") is not None
lines.append(f"glab: {'found' if glab_ok else 'MISSING (blocks ci venue)'}")
if not glab_ok:
problems.append("install glab and authenticate for this GitLab project")
lines.append("CI status: project CI_STATUS_SCRIPT (tea/glab/gh/curl)")
elif docker_project:
docker_ok = shutil.which("docker") is not None
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
@@ -867,6 +857,7 @@ def main(argv: list[str] | None = None) -> int:
check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project
)
check_ci_status_script(report, project, env_file_values(project), makefile_values(project) if (project / BUILDER_MAKEFILE).is_file() else {})
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
@@ -963,6 +954,12 @@ def main(argv: list[str] | None = None) -> int:
docker_project=docker_project,
copy_project=copy_project,
)
check_ci_status_script(
report,
project,
env_file_values(project),
makefile_values(project),
)
total_fail = report.failures
print()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Copy a CI status template into the project.
# Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl
usage() {
echo "Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl" >&2
}
root=
kind=
while (($#)); do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root=$2; shift 2 ;;
--kind) [[ $# -ge 2 ]] || { usage; exit 2; }; kind=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$root" || -z "$kind" ]]; then
usage
exit 2
fi
case "$kind" in
tea|glab|gh|curl) ;;
*) echo "Error: --kind must be tea, glab, gh, or curl" >&2; exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
src=$script_dir/../templates/ci-status/${kind}.sh
if [[ ! -f "$src" ]]; then
echo "Error: missing template: $src" >&2
exit 2
fi
dest=$root/.pouch/builder/ci-status.sh
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
chmod +x "$dest"
echo "$dest"
+2 -3
View File
@@ -10,14 +10,13 @@ set -euo pipefail
#
# BUILD_VENUE (makefile.builder, default local):
# local HEAD must be SHA; run `make -f makefile.builder docker` or `deb`
# ci wait for the forge pipeline of this SHA, then inspect the image
# (GitHub Actions, GitLab CI, or Gitea Actions)
# ci wait for this SHA via the project CI_STATUS_SCRIPT, then inspect the image
#
# Exit:
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
# 1 no pipeline, job failed, inspect failed
# 2 usage / config
# 3 forge CLI cannot read the project (ci venue)
# 3 CI status script cannot query (ci venue)
usage() {
sed -n '2,20p' "$0"
+90 -364
View File
@@ -1,25 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# Wait until the forge pipeline for an exact commit SHA finishes.
# Poll the project CI status script until the exact SHA finishes.
# Does not trigger a pipeline. Does not print tokens.
#
# Usage:
# wait_ci.sh --sha SHA [--root DIR] [--timeout SEC]
#
# Supported forges:
# GitHub Actions (gh), GitLab CI (glab), and Gitea Actions (curl + REST API).
# Set BUILDER_CI_FORGE=gitea for a custom Gitea host that does not contain
# "gitea" in its hostname. GITEA_API_URL may override the /api/v1 endpoint.
#
# Exit:
# 0 pipeline succeeded; prints "OK pipeline=<url> sha=<sha>"
# 1 no pipeline for this SHA, or a job failed/canceled
# 2 usage / bad arguments
# 3 forge CLI missing, or it cannot read this project
# The project script is CI_STATUS_SCRIPT (makefile.builder, default
# .pouch/builder/ci-status.sh). See references/ci-status.md.
usage() {
sed -n '2,18p' "$0"
sed -n '2,14p' "$0"
}
sha=
@@ -51,7 +43,6 @@ if [[ -z "$sha" ]]; then
fi
sha=$(git -C "$root" rev-parse "$sha")
# Load only builder configuration. Shell values win and values are never echoed.
if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
@@ -67,391 +58,126 @@ if [[ -f "$root/.env.builder" ]]; then
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi
python_json() {
python3 -c "$1" "${@:2}"
}
parse_origin() {
python_json '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url)
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "")
else:
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url)
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "")
path = re.sub(r"\.git$", "", path)
override = os.environ.get("BUILDER_CI_FORGE", "").strip().lower()
if override in ("github", "gitlab", "gitea"):
kind = override
elif host == "github.com" or host.endswith(".github.com"):
kind = "github"
elif ("gitea" in host.lower() or os.environ.get("GITEA_TOKEN") or
os.environ.get("GITEA_API_URL")):
kind = "gitea"
else:
kind = "gitlab"
print(kind, host, path, (scheme or "https") + "://" + host)
'
}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
if [[ -z "$origin" ]]; then
echo "Error: no origin remote" >&2
exit 2
fi
export WAIT_CI_ORIGIN=$origin
read -r forge_kind forge_host forge_path forge_base <<<"$(parse_origin)"
if [[ -z "$forge_kind" || -z "$forge_path" ]]; then
echo "Error: cannot parse origin: $origin" >&2
exit 2
fi
export WAIT_CI_PATH=$forge_path
export WAIT_CI_SHA=$sha
gitlab_pipelines() {
local encoded
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20"
}
gitlab_jobs() {
local encoded pid=$1
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
glab api "projects/${encoded}/pipelines/${pid}/jobs"
}
github_runs() {
gh run list --repo "$forge_path" --commit "$sha" --limit 20 \
--json databaseId,status,conclusion,url,headSha
}
gitea_api_base=${GITEA_API_URL:-$forge_base/api/v1}
gitea_api_base=${gitea_api_base%/}
if [[ "$gitea_api_base" != */api/v1 ]]; then
gitea_api_base+="/api/v1"
fi
gitea_repo_path=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
local url=$1
if [[ -n "${GITEA_TOKEN:-}" ]]; then
# Process substitution keeps the token out of curl's argument list and logs.
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$url"
resolve_status_script() {
local configured=${CI_STATUS_SCRIPT:-}
if [[ -z "$configured" && -f "$root/makefile.builder" ]]; then
configured=$(make -s --no-print-directory -C "$root" -f makefile.builder print-CI_STATUS_SCRIPT 2>/dev/null | tail -n 1 || true)
fi
configured=${configured:-.pouch/builder/ci-status.sh}
local path
if [[ "$configured" == /* ]]; then
path=$configured
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$url"
path=$root/$configured
case "$configured" in
.pouch/builder/*) ;;
*)
echo "Error: relative CI_STATUS_SCRIPT must be under .pouch/builder/: $configured" >&2
exit 2
;;
esac
fi
if [[ ! -f "$path" ]]; then
echo "Error: CI status script missing: $path" >&2
echo "Run builder init with BUILD_VENUE=ci to write .pouch/builder/ci-status.sh" >&2
exit 2
fi
if [[ ! -x "$path" ]]; then
echo "Error: CI status script is not executable: $path" >&2
exit 2
fi
printf '%s' "$path"
}
gitea_runs() {
gitea_curl "${gitea_api_base}/repos/${gitea_repo_path}/actions/runs?head_sha=${sha}&limit=20&page=1"
}
gitea_jobs() {
gitea_curl "${gitea_api_base}/repos/${gitea_repo_path}/actions/runs/$1/jobs?limit=100&page=1"
}
summarize_gitlab() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if not isinstance(data, list):
print("error")
raise SystemExit(0)
if not data:
print("empty")
raise SystemExit(0)
p = data[0]
print(p.get("id"), p.get("status"), p.get("web_url", ""))
parse_status() {
python3 -c '
import sys
text = sys.stdin.read()
values = {}
for raw in text.replace("\n", " ").split():
if "=" in raw:
key, value = raw.split("=", 1)
values[key.upper()] = value
print(values.get("STATUS", "error").lower())
print(values.get("ID", "-"))
print(values.get("PIPELINE", "-"))
print(values.get("JOBS", ""))
'
}
summarize_jobs() {
python_json '
import json, sys
data = json.loads(sys.stdin.read())
parts = []
for j in data if isinstance(data, list) else []:
parts.append("%s=%s" % (j.get("name"), j.get("status")))
print(",".join(parts))
'
}
status_script=$(resolve_status_script)
export BUILDER_PROJECT_ROOT=$root
summarize_github() {
python_json '
import json, sys
data = json.loads(sys.stdin.read())
if not data:
print("empty")
raise SystemExit(0)
r = data[0]
status = r.get("status")
conc = r.get("conclusion")
if status == "completed" and conc == "success":
st = "success"
elif status == "completed":
st = conc or "failed"
else:
st = status or "running"
print(r.get("databaseId"), st, r.get("url", ""))
'
}
summarize_gitea_runs() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if isinstance(data, dict):
data = data.get("workflow_runs", data.get("runs", []))
if not isinstance(data, list):
print("error")
raise SystemExit(0)
want = sys.argv[1] if len(sys.argv) > 1 else ""
for run in data:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if not head or head.lower() != want.lower():
continue
run_id = run.get("id") or run.get("database_id")
if run_id is None:
continue
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
print("%s\t%s\t%s\t%s" % (run_id, status, conclusion, url))
' "$sha"
}
summarize_gitea_jobs() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not data.get("jobs"):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if isinstance(data, dict):
data = data.get("jobs", [])
if not isinstance(data, list):
print("error")
raise SystemExit(0)
want = sys.argv[1] if len(sys.argv) > 1 else ""
parts = []
state = "success" if data else "empty"
for job in data:
head = job.get("head_sha") or job.get("headSha")
if head and want and head.lower() != want.lower():
continue
name = str(job.get("name") or job.get("id") or "job").replace(" ", "_")
status = str(job.get("status") or "").lower()
result = str(job.get("conclusion") or job.get("result") or "").lower()
value = result or status or "unknown"
parts.append("%s=%s" % (name, value))
if value in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out", "action_required"):
state = "failed"
elif value not in ("success", "succeeded", "completed"):
state = "running"
if not parts:
state = "empty"
print("%s\t%s" % (state, ",".join(parts)))
' "$sha"
}
gitea_status_line() {
local raw runs
if ! raw=$(gitea_runs); then
echo "error"
return 0
fi
runs=$(printf '%s' "$raw" | summarize_gitea_runs)
if [[ "$runs" == auth* || "$runs" == error ]]; then
printf '%s\n' "$runs"
return 0
fi
if [[ -z "$runs" ]]; then
echo "empty"
return 0
fi
local overall=success ids= urls= jobs_all= first=1
while IFS=$'\t' read -r id run_status conclusion url; do
[[ -z "$id" ]] && continue
local jobs job_line job_state job_summary
if ! jobs=$(gitea_jobs "$id"); then
echo "error"
return 0
query() {
local out err rc
err=$(mktemp)
set +e
out=$(BUILDER_PROJECT_ROOT="$root" "$status_script" --sha "$sha" 2>"$err")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: CI status script exited $rc" >&2
sed '/token/Id; /Bearer/d; /Authorization/Id' "$err" >&2
rm -f "$err"
if [[ $rc -eq 2 ]]; then
exit 2
fi
job_line=$(printf '%s' "$jobs" | summarize_gitea_jobs "$sha")
job_state=${job_line%%$'\t'*}
job_summary=${job_line#*$'\t'}
if [[ "$job_state" == auth* || "$job_state" == error ]]; then
printf '%s\n' "$job_state"
return 0
fi
if [[ "$first" -eq 0 ]]; then
ids+=","; urls+=","; jobs_all+=","
fi
ids+="$id"; urls+="$url"; jobs_all+="$job_summary"; first=0
if [[ "$run_status" =~ ^(failure|failed|cancelled|canceled|skipped|timed_out|action_required)$ || "$conclusion" =~ ^(failure|failed|cancelled|canceled|skipped|timed_out|action_required)$ || "$job_state" == failed ]]; then
overall=failed
elif [[ "$overall" == success && ( "$job_state" == running || "$job_state" == empty || ( "$run_status" != completed && "$run_status" != success ) ) ]]; then
overall=running
fi
done <<<"$runs"
printf '%s %s %s %s\n' "$ids" "$overall" "$urls" "$jobs_all"
}
if [[ "$forge_kind" == github ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required to watch GitHub Actions for $forge_host" >&2
exit 3
fi
elif [[ "$forge_kind" == gitlab ]]; then
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required to watch GitLab CI for $forge_host" >&2
exit 3
fi
else
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required to watch Gitea Actions for $forge_host" >&2
exit 3
fi
fi
rm -f "$err"
printf '%s' "$out"
}
started=$(date +%s)
first_seen=0
empty_since=$started
probe_auth() {
local out rc kind
if [[ "$forge_kind" == github ]]; then
set +e
out=$(github_runs 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $forge_path" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
return
fi
if [[ "$forge_kind" == gitlab ]]; then
set +e
out=$(gitlab_pipelines 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $forge_path on $forge_host" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
kind=$(printf '%s' "$out" | summarize_gitlab | awk '{print $1}')
if [[ "$kind" == auth ]]; then
echo "Error: glab is authenticated but cannot read $forge_path" >&2
exit 3
fi
return
fi
set +e
out=$(gitea_runs 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $forge_path on $forge_host" >&2
echo "$out" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
fi
kind=$(printf '%s' "$out" | summarize_gitea_runs | awk '{print $1}')
if [[ "$kind" == auth ]]; then
echo "Error: Gitea API rejected access to $forge_path" >&2
exit 3
fi
}
probe_auth
status_line() {
if [[ "$forge_kind" == github ]]; then
github_runs | summarize_github
elif [[ "$forge_kind" == gitlab ]]; then
gitlab_pipelines | summarize_gitlab
else
gitea_status_line
fi
}
while true; do
now=$(date +%s)
if (( now - started > timeout_sec )); then
echo "FAIL reason=timeout sha=$sha" >&2
exit 1
fi
line=$(status_line)
set -- $line
kind=${1:-empty}
if [[ "$kind" == empty ]]; then
if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha origin=$forge_host/$forge_path" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
fi
if [[ "$kind" == error || "$kind" == auth ]]; then
echo "Error: unexpected forge response for $forge_path" >&2
exit 3
fi
pid=$1
st=$2
url=$3
jobs_s=${4:-}
empty_since=$now
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
raw=$(query)
mapfile -t fields < <(printf '%s' "$raw" | parse_status)
st=${fields[0]:-error}
pid=${fields[1]:--}
url=${fields[2]:--}
jobs_s=${fields[3]:-}
case "$st" in
empty)
if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1
fi
sleep "$poll_sec"
continue
;;
error)
echo "Error: unexpected CI status response" >&2
echo "$raw" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
;;
success)
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
exit 0
;;
failed|failure|canceled|cancelled|skipped)
if [[ "$forge_kind" == gitlab ]]; then
jobs_s=$(gitlab_jobs "$pid" | summarize_jobs || true)
fi
echo "FAIL reason=job_$st sha=$sha pipeline=$url jobs=$jobs_s" >&2
echo "FAIL reason=job_${st} sha=$sha pipeline=$url jobs=$jobs_s" >&2
exit 1
;;
created|pending|running|waiting_for_resource|preparing|waiting|queued|in_progress|requested)
running|pending|queued|waiting|in_progress|created|preparing)
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
empty_since=$now
sleep "$poll_sec"
;;
*)
empty_since=$now
sleep "$poll_sec"
;;
esac