feat: update
This commit is contained in:
@@ -612,8 +612,23 @@ def origin_forge(project: Path) -> str:
|
||||
check=False,
|
||||
)
|
||||
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 ""
|
||||
@@ -646,6 +661,17 @@ def check_ready_toolchain(
|
||||
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)'}")
|
||||
|
||||
@@ -11,6 +11,7 @@ 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)
|
||||
#
|
||||
# Exit:
|
||||
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
|
||||
|
||||
@@ -7,6 +7,11 @@ set -euo pipefail
|
||||
# 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
|
||||
@@ -14,20 +19,20 @@ set -euo pipefail
|
||||
# 3 forge CLI missing, or it cannot read this project
|
||||
|
||||
usage() {
|
||||
sed -n '2,16p' "$0"
|
||||
sed -n '2,18p' "$0"
|
||||
}
|
||||
|
||||
sha=
|
||||
root=
|
||||
timeout_sec=1200
|
||||
poll_sec=10
|
||||
appear_sec=90
|
||||
poll_sec=${BUILDER_CI_POLL_SEC:-10}
|
||||
appear_sec=${BUILDER_CI_APPEAR_SEC:-90}
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--sha) sha=$2; shift 2 ;;
|
||||
--root) root=$2; shift 2 ;;
|
||||
--timeout) timeout_sec=$2; shift 2 ;;
|
||||
--sha) [[ $# -ge 2 ]] || { echo "Error: --sha needs a value" >&2; exit 2; }; sha=$2; shift 2 ;;
|
||||
--root) [[ $# -ge 2 ]] || { echo "Error: --root needs a value" >&2; exit 2; }; root=$2; shift 2 ;;
|
||||
--timeout) [[ $# -ge 2 ]] || { echo "Error: --timeout needs a value" >&2; exit 2; }; timeout_sec=$2; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -46,41 +51,67 @@ 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:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
[[ -z "$value" ]] && continue
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
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
|
||||
|
||||
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)"
|
||||
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
|
||||
@@ -94,30 +125,42 @@ gitlab_jobs() {
|
||||
glab api "projects/${encoded}/pipelines/${pid}/jobs"
|
||||
}
|
||||
|
||||
export WAIT_CI_PATH=$forge_path
|
||||
github_runs() {
|
||||
gh run list --repo "$forge_path" --commit "$sha" --limit 20 \
|
||||
--json databaseId,status,conclusion,url,headSha
|
||||
}
|
||||
|
||||
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
|
||||
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="/"))')
|
||||
|
||||
started=$(date +%s)
|
||||
first_seen=0
|
||||
empty_since=$started
|
||||
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"
|
||||
else
|
||||
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
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, os, sys
|
||||
raw = sys.stdin.read()
|
||||
import json, sys
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
data = json.loads(sys.stdin.read())
|
||||
except json.JSONDecodeError:
|
||||
print("error")
|
||||
raise SystemExit(0)
|
||||
@@ -146,11 +189,6 @@ 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
|
||||
@@ -171,9 +209,143 @@ 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
|
||||
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
|
||||
|
||||
started=$(date +%s)
|
||||
first_seen=0
|
||||
empty_since=$started
|
||||
|
||||
probe_auth() {
|
||||
local out rc kind
|
||||
if [[ "$forge_kind" == github ]]; then
|
||||
local out rc
|
||||
set +e
|
||||
out=$(github_runs 2>&1)
|
||||
rc=$?
|
||||
@@ -185,20 +357,35 @@ probe_auth() {
|
||||
fi
|
||||
return
|
||||
fi
|
||||
local out rc
|
||||
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=$(gitlab_pipelines 2>&1)
|
||||
out=$(gitea_runs 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
|
||||
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
|
||||
local kind
|
||||
kind=$(printf '%s' "$out" | summarize_gitlab | awk '{print $1}')
|
||||
kind=$(printf '%s' "$out" | summarize_gitea_runs | awk '{print $1}')
|
||||
if [[ "$kind" == auth ]]; then
|
||||
echo "Error: glab is authenticated but cannot read $forge_path" >&2
|
||||
echo "Error: Gitea API rejected access to $forge_path" >&2
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
@@ -208,8 +395,10 @@ probe_auth
|
||||
status_line() {
|
||||
if [[ "$forge_kind" == github ]]; then
|
||||
github_runs | summarize_github
|
||||
else
|
||||
elif [[ "$forge_kind" == gitlab ]]; then
|
||||
gitlab_pipelines | summarize_gitlab
|
||||
else
|
||||
gitea_status_line
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -224,7 +413,7 @@ while true; do
|
||||
kind=${1:-empty}
|
||||
|
||||
if [[ "$kind" == empty ]]; then
|
||||
if (( now - empty_since > appear_sec )); 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
|
||||
@@ -240,6 +429,7 @@ while true; do
|
||||
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
|
||||
@@ -248,18 +438,17 @@ while true; do
|
||||
|
||||
case "$st" in
|
||||
success)
|
||||
echo "OK pipeline=$url sha=$sha id=$pid"
|
||||
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
|
||||
exit 0
|
||||
;;
|
||||
failed|canceled|cancelled|skipped)
|
||||
jobs_s=
|
||||
if [[ "$forge_kind" != github ]]; then
|
||||
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
|
||||
exit 1
|
||||
;;
|
||||
created|pending|running|waiting_for_resource|preparing|waiting|queued|in_progress|waiting_for_resource)
|
||||
created|pending|running|waiting_for_resource|preparing|waiting|queued|in_progress|requested)
|
||||
sleep "$poll_sec"
|
||||
;;
|
||||
*)
|
||||
|
||||
Reference in New Issue
Block a user