#!/usr/bin/env bash set -euo pipefail # Wait until the forge pipeline for an exact commit SHA finishes. # Does not trigger a pipeline. Does not print tokens. # # Usage: # wait_ci.sh --sha SHA [--root DIR] [--timeout SEC] # # Exit: # 0 pipeline succeeded; prints "OK pipeline= 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 usage() { sed -n '2,16p' "$0" } sha= root= timeout_sec=1200 poll_sec=10 appear_sec=90 while (($#)); do case "$1" in --sha) sha=$2; shift 2 ;; --root) root=$2; shift 2 ;; --timeout) timeout_sec=$2; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done if [[ -z "$root" ]]; then root=$(git rev-parse --show-toplevel 2>/dev/null || true) fi if [[ -z "$root" || ! -d "$root/.git" && ! -f "$root/.git" ]]; then echo "Error: --root must be a git project" >&2 exit 2 fi if [[ -z "$sha" ]]; then echo "Error: --sha is required" >&2 exit 2 fi sha=$(git -C "$root" rev-parse "$sha") 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 python_json() { python3 -c "$1" } parse_origin() { python_json ' import os, re, sys url = os.environ["WAIT_CI_ORIGIN"] url = url.strip() url = re.sub(r"^git@", "ssh://git@", url) url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url) url = re.sub(r"^https?://", "ssh://git@", url) # ssh://git@host/group/repo.git m = re.match(r"ssh://git@([^/]+)/(.+?)(?:\.git)?$", url) if not m: print("", "", "") raise SystemExit(0) host, path = m.group(1), m.group(2) kind = "github" if host == "github.com" or host.endswith(".github.com") else "gitlab" print(kind, host, path) ' } export WAIT_CI_ORIGIN=$origin read -r forge_kind forge_host forge_path <<<"$(parse_origin)" if [[ -z "$forge_kind" || -z "$forge_path" ]]; then echo "Error: cannot parse origin: $origin" >&2 exit 2 fi 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" } export WAIT_CI_PATH=$forge_path 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 else 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 fi started=$(date +%s) first_seen=0 empty_since=$started summarize_gitlab() { python_json ' import json, os, sys raw = sys.stdin.read() try: data = json.loads(raw) 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", "")) ' } 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)) ' } github_runs() { gh run list --repo "$forge_path" --commit "$sha" --limit 20 \ --json databaseId,status,conclusion,url,headSha } 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", "")) ' } probe_auth() { if [[ "$forge_kind" == github ]]; then local out rc 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 local out rc 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 local kind 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 } probe_auth status_line() { if [[ "$forge_kind" == github ]]; then github_runs | summarize_github else gitlab_pipelines | summarize_gitlab 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 empty_since=$now if [[ "$first_seen" -eq 0 ]]; then echo "Watching pipeline $pid status=$st $url" >&2 first_seen=1 fi case "$st" in success) echo "OK pipeline=$url sha=$sha id=$pid" exit 0 ;; failed|canceled|cancelled|skipped) jobs_s= if [[ "$forge_kind" != github ]]; then jobs_s=$(gitlab_jobs "$pid" | summarize_jobs || true) fi 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|waiting_for_resource) sleep "$poll_sec" ;; *) sleep "$poll_sec" ;; esac done