Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae2fbe21b | ||
|
|
94ce5c4de2 | ||
|
|
bb7ebb4a27 | ||
|
|
ecf126d98d | ||
|
|
ab88e59c21 |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: cc-ci-cleanup
|
||||
description: Tidy the fleet's open recipe PRs. Reconciles every mirror from TRUE upstream first (which alone closes PRs upstream already merged), then surveys every open PR deterministically, CLOSES the ones that can no longer be merged or were never meant to be (CI sweep artifacts, obsolete bumps, superseded duplicates) with a reason, and reports prioritised action items for the ones that SHOULD merge — what specifically is blocking each. NEVER merges a recipe PR. Invoke as /cc-ci-cleanup [recipe ...] [--dry-run].
|
||||
---
|
||||
|
||||
# cc-ci-cleanup
|
||||
|
||||
Open recipe PRs accumulate and rot. Some were never meant to merge (CI sweep artifacts), some were
|
||||
overtaken (upstream merged the same change, or a newer PR supersedes them), and some genuinely should
|
||||
land but are quietly blocked. Left alone the list becomes noise, and a real CVE fix hides in it.
|
||||
|
||||
This skill separates those three, acts on the first two, and hands you a short list for the third.
|
||||
|
||||
**Boundaries.** It **CLOSES** irrelevant PRs and **NEVER MERGES** any recipe PR — those change what
|
||||
deploys on other people's infrastructure, so a human merges them (see AGENTS.md). Closing is the only
|
||||
write it performs, always with a comment saying why.
|
||||
|
||||
## Arguments
|
||||
- `<recipe> …` — limit to these recipes (else every recipe in `cc-ci-plan/used-recipes.md`).
|
||||
- `--dry-run` — classify and report, close nothing.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Reconcile every mirror from TRUE upstream — MANDATORY, FIRST
|
||||
```
|
||||
cc-ci-plan/reconcile-upstream.sh --all # or: reconcile-upstream.sh <recipe>...
|
||||
```
|
||||
**Do not skip this and do not reorder it.** Every signal in step 2 is measured against the mirror's
|
||||
`main`; against a stale mirror they are all wrong. This step also does a chunk of the cleanup by
|
||||
itself — it closes any PR whose changes upstream has already merged.
|
||||
|
||||
> On the first real run (2026-08-11) this alone closed **three** PRs that looked pending and were
|
||||
> already merged upstream: discourse #6 (carrying **140 CVEs**), keycloak #6 (**12 CVEs**), n8n #5.
|
||||
> All three had been reported to the operator as outstanding work. mailu #6 went the same way earlier
|
||||
> the same day. Reconciling is not hygiene, it is how you avoid recommending work that is already done.
|
||||
|
||||
### 2. Survey every open PR (deterministic — no judgement yet)
|
||||
```
|
||||
python3 cc-ci-plan/pr-survey.py [recipe ...] # add --json for the raw facts
|
||||
```
|
||||
Per PR it measures: `behind_main`, `ahead`, `mergeable`, `diff_files`, the images it **adds**, which
|
||||
of those are **already in main**, `obsolete`, the newest `!testme` verdict + build, `branch_kind`,
|
||||
and age/idle days. It decides nothing — that is this skill's job.
|
||||
|
||||
### 3. Classify
|
||||
|
||||
**CLOSE — cannot merge, or was never meant to.** Each needs a *positive* reason, not an absence:
|
||||
|
||||
| signal | why it is closeable |
|
||||
|---|---|
|
||||
| `branch_kind: ci-artifact` (`ci/*`) | regall/cfold sweeps and `!testme` probes — harness artifacts, never intended to merge |
|
||||
| `obsolete: true` | every image it adds is **already pinned in main** — it has nothing left to contribute |
|
||||
| superseded | a newer PR on the same recipe makes the same bump (name both numbers in the comment) |
|
||||
| `diff_files: 0` | genuinely empty diff — nothing to merge |
|
||||
|
||||
**NEVER close on:**
|
||||
- `DIFF-UNREADABLE` — the diff could not be fetched, which is NOT an empty diff. gitea #4 reads that
|
||||
way (force-pushed branch) while being a verified, green, needed fix.
|
||||
- any field that came back `null`/unknown.
|
||||
- a PR that carries a **CVE fix** and is the only thing carrying it, even if it looks stale — report it
|
||||
instead. Losing a security fix to tidiness is far worse than a long PR list.
|
||||
- `--dry-run`.
|
||||
|
||||
**NEEDS WORK — should merge, something blocks it.** Give the *specific* next action:
|
||||
| signal | action item |
|
||||
|---|---|
|
||||
| `mergeable: false` | conflicts — rebase the branch on `main` and re-run `!testme` |
|
||||
| `behind_main > 0` | out of date — rebase, then re-verify (a green from before main moved proves nothing) |
|
||||
| `ci: failed` | diagnose via `/ci-test-review`; classify recipe-bug vs stale test |
|
||||
| `ci: never-run` | run `!testme` |
|
||||
| blocked on the operator | say exactly what is needed (a secret, an upstream release, a decision) |
|
||||
|
||||
**READY — green, current, no conflicts.** Action item is simply: review and merge.
|
||||
|
||||
### 4. Close the CLOSE set (skip entirely under `--dry-run`)
|
||||
Comment first, then close. The comment must say **which signal** made it closeable and **what to do
|
||||
if that is wrong** ("reopen if …"), so a wrong call is cheap to undo. Never close silently.
|
||||
|
||||
### 5. Report
|
||||
Order by what deserves attention, not by recipe name:
|
||||
|
||||
1. **CVE-carrying PRs that should merge** — most severe first, with the CVE ids.
|
||||
2. Other **READY** PRs (green + current).
|
||||
3. **NEEDS WORK**, each with its one specific action.
|
||||
4. **CLOSED this run**, with the reason for each.
|
||||
5. Anything **deliberately left alone** despite looking stale, and why.
|
||||
|
||||
End with a one-line summary: `N open → C closed, R ready to merge, W need work`.
|
||||
|
||||
## Guardrails
|
||||
- **Never merge a recipe PR.** Create/verify/close only; the operator merges.
|
||||
- **Reconcile first, always.** Judging a PR against a stale mirror is how you close good work or
|
||||
recommend work that is already done.
|
||||
- **Close only on a positive signal**, never on "looks old". Age alone is not a reason — several
|
||||
60-day-old PRs here are green and mergeable.
|
||||
- **Never close a lone CVE fix.** Report it, however stale.
|
||||
- Every close gets a comment with its reason and a reopen hint.
|
||||
@@ -89,6 +89,19 @@ For each real (non-flaky) finding, write the actual fix and open a PR. **Never m
|
||||
branch in a SEPARATE clone — **never push `main`, never touch the build loops' working clones**
|
||||
(`/cc-ci`, `/cc-ci-adv`) or their in-flight state.
|
||||
|
||||
> ### ⚠️ RECONCILE FROM UPSTREAM FIRST — always, before any PR work or upgrade check
|
||||
> ```
|
||||
> cc-ci-plan/reconcile-upstream.sh <recipe>... # or --all
|
||||
> ```
|
||||
> Deterministic, idempotent, and safe (recipe work lives in branches, never on mirror `main`). It
|
||||
> force-syncs each mirror to coopcloud's **default branch — resolved from the API, `main` OR
|
||||
> `master`** — and closes any mirror PR whose changes upstream already merged. Skipping it has cost
|
||||
> us three distinct ways: mailu #6 was reported as the fix for two internet-facing CVEs while
|
||||
> upstream had already merged AND released it; a stale mirror makes a survey report "no upgrades
|
||||
> available" so the recipe drops out of the weekly run; and reading the wrong branch on a recipe with
|
||||
> a stale `main` beside a live `master` (gitea) manufactures a false "three releases behind, missing
|
||||
> two CVSS-9.8 RCEs" finding.
|
||||
|
||||
### 5. VERIFY each PR on the CI server (deterministic; still never merge)
|
||||
A PR is only "working" once **cc-ci verifies it green** (operator rule) — dogfood the CI that found
|
||||
the bug. Verification is deterministic (the harness), not an AI judgement.
|
||||
|
||||
@@ -57,7 +57,8 @@ This is `/recipe-upgrade` step 1's research, stopping before it implements anyth
|
||||
> vectorchord/pgvectors versions immich-server is built against, so taking the newest would break
|
||||
> the deploy. `all_resolved: false` means an image could NOT be resolved — that is a `?`, never a 0.
|
||||
|
||||
**Reconcile the mirror from true upstream FIRST — ALWAYS, no exceptions.** This is the same reconcile
|
||||
**Reconcile the mirror from true upstream FIRST — ALWAYS, no exceptions** — one command,
|
||||
`cc-ci-plan/reconcile-upstream.sh <recipe>... | --all`. This is the same reconcile
|
||||
`/upgrade-all` does. Do not skip it in the name of keeping the sweep read-only: skipping it makes you
|
||||
research a stale checkout, and on the first real run that produced **two recipes with no survey output
|
||||
at all**, which is indistinguishable from "no upgrades" unless you check. It is safe — recipe work
|
||||
|
||||
@@ -73,6 +73,29 @@ done
|
||||
# 5) Stray exited containers (debug one-shots) — best-effort prune.
|
||||
docker container prune -f >/dev/null 2>&1 || true
|
||||
|
||||
# 6) Unused IMAGES — the one that actually took CI down. Every run pulls each recipe's images and
|
||||
# nothing ever removed the old ones: on 2026-08-11 they had grown to 72GB (63GB of it unused),
|
||||
# the root filesystem hit 100% under two concurrent runs, and the harness died at startup with
|
||||
# `OSError: [Errno 28] No space left on device: '/var/lib/cc-ci-runs/<build>'`. Every !testme
|
||||
# from build 1236 to 1242 failed that way — with no results.json, so the PR badges just read
|
||||
# "failure" and looked like recipe regressions.
|
||||
#
|
||||
# Only prune above a threshold, so a healthy host keeps its layer cache and runs stay fast.
|
||||
# `image prune -a` removes only images no container references, so anything deployed (infra +
|
||||
# warm-* canonicals) is untouched; anything else is re-pulled on demand.
|
||||
#
|
||||
# Volumes are deliberately NOT pruned here — see the KEEP_RE guard in (3): warm-* canonicals are
|
||||
# data-warm and their volumes are legitimately dangling between runs.
|
||||
DISK_PRUNE_PCT="${DISK_PRUNE_PCT:-60}"
|
||||
used_pct="$(df --output=pcent / 2>/dev/null | tail -1 | tr -dc '0-9')"
|
||||
if [ -n "$used_pct" ] && [ "$used_pct" -ge "$DISK_PRUNE_PCT" ]; then
|
||||
echo " disk ${used_pct}% >= ${DISK_PRUNE_PCT}% -> pruning unused images"
|
||||
freed="$(docker image prune -af 2>/dev/null | awk '/Total reclaimed space/ {print $4, $5}')"
|
||||
echo " reclaimed: ${freed:-0B}; disk now $(df -h / | tail -1 | awk '{print $5" used, "$4" free"}')"
|
||||
else
|
||||
echo " disk ${used_pct:-?}% < ${DISK_PRUNE_PCT}% -> keeping image cache"
|
||||
fi
|
||||
|
||||
if [ "$removed" -eq 0 ]; then
|
||||
echo "== orphan sweep: clean (nothing to remove) =="
|
||||
else
|
||||
|
||||
@@ -78,8 +78,38 @@ ssh cc-ci 'systemctl --failed --no-legend; df -h / | tail -1; docker service ls
|
||||
systemctl --failed --no-legend; df -h / | tail -1; tmux ls
|
||||
```
|
||||
- Failed units, core swarm services not 1/1 (warm-* spares flapping is a known benign pattern —
|
||||
note, don't page), disk >80% (server) / >85% (orchestrator) → findings. Server unreachable →
|
||||
note, don't page), disk **>65% (server)** / >85% (orchestrator) → findings. Server unreachable →
|
||||
HIGH: recommend `hetzner-server-recovery`.
|
||||
> **65%, not 80%, on the server — it is not a steady-state measure.** Two concurrent recipe runs
|
||||
> pull images and write volumes worth tens of GB, so a host sitting at 73% still hits 100% mid-run.
|
||||
> That is exactly what happened on 2026-08-11: 63GB of unused images had accumulated (nothing ever
|
||||
> pruned them), the filesystem filled during a run, and the harness died at startup with
|
||||
> `OSError: [Errno 28] No space left on device`. Remedy: `docker image prune -af` on cc-ci — it
|
||||
> spares anything a container references, so infra and warm-* canonicals are untouched. Do NOT
|
||||
> `docker volume prune`: warm-* canonical volumes are data-warm and legitimately dangling.
|
||||
- **!testme actually produces results** (the check that would have caught the above days earlier):
|
||||
the newest few `/var/lib/cc-ci-runs/<build>/` dirs must each contain `results.json`. A build that
|
||||
dies before the harness writes one leaves an EMPTY dir — and the PR badge still says "failure", so
|
||||
it reads as a recipe regression rather than a sick host. Builds 1236–1242 all failed that way.
|
||||
Finding: *"N recent builds produced no results.json — the harness is dying at startup, check disk
|
||||
and the drone step log"*. The step log lives in drone's sqlite
|
||||
(`/var/lib/docker/volumes/drone_ci_commoninternet_net_data/_data/database.sqlite`) — copy it and
|
||||
read `logs.log_data` for the failing `steps.step_id`; the bridge's drone token is not extractable
|
||||
(distroless container, swarm secret).
|
||||
> **If the error is ENOSPC but the disk is fine**, it is not disk. Seen 2026-08-11: builds 1244-1249
|
||||
> died on `mkdir /var/lib/cc-ci-runs/<build>` with **110GB free and 16% inodes**, while the identical
|
||||
> mkdir succeeded as root over ssh, inside the runner's own mount namespace, and 61/61 times in a
|
||||
> stress loop — and the same harness run by hand with a numeric run id worked fine. Restarting
|
||||
> `drone-runner-exec` did NOT help, and neither did recreating the runs directory with a fresh
|
||||
> inode (it recurred afterwards — that apparent fix was coincidence).
|
||||
>
|
||||
> **It is INTERMITTENT and tracks concurrent activity**, which is the useful signal: every failure
|
||||
> landed while a second run or a manual deploy was in flight (1252 was triggered while 1251 was
|
||||
> still finishing), and every build on a quiet host succeeded (1243, 1250, 1251, 1253). Free space
|
||||
> never moved during a failing build. So on ENOSPC-with-free-disk: **wait for the host to go quiet
|
||||
> and re-trigger** before treating it as a recipe failure. Root cause is still NOT established;
|
||||
> `DRONE_RUNNER_CAPACITY=2` allows the overlap, so lowering it to 1 is the obvious next experiment
|
||||
> if it becomes disruptive.
|
||||
- **Bridge / !testme path**: `docker service ls` shows `ccci-bridge_app 1/1` AND the bridge log
|
||||
has no auth errors (`docker service logs --since 24h ccci-bridge_app 2>&1 | grep -ci "401\|user does not exist"` == 0).
|
||||
A silently-401ing bridge drops `!testme` (seen 2026-08-03, stale rotated Gitea secret) →
|
||||
@@ -113,7 +143,8 @@ minutes, no PRs). If it is instead that a known CVE is sitting unpatched, recomm
|
||||
`/cve-check` over waiting for the next weekly run whenever the question is "are we exposed?".
|
||||
|
||||
`ALL HEALTHY` requires: recent successful weekly run + published report, no stale tests, no
|
||||
CVE PR open >14 days, both hosts <30 days behind their channel, zero failed units, disk under
|
||||
CVE PR open >14 days, both hosts <30 days behind their channel, zero failed units, recent builds all
|
||||
producing results.json, disk under
|
||||
thresholds, bridge clean, maintained-set consistent. Anything else is a finding — even minor
|
||||
ones get a recommended next step. Order findings by priority (CVE/unreachable-host first).
|
||||
|
||||
|
||||
@@ -83,6 +83,19 @@ failure (AI — this is the `ci-test-review` step-3 diagnosis):
|
||||
changed upstream, what the test currently asserts.
|
||||
- **FLAKY** → re-run once or twice; if it passes, drop it (not stale, just flaky).
|
||||
|
||||
> ### ⚠️ RECONCILE FROM UPSTREAM FIRST — always, before any PR work or upgrade check
|
||||
> ```
|
||||
> cc-ci-plan/reconcile-upstream.sh <recipe>... # or --all
|
||||
> ```
|
||||
> Deterministic, idempotent, and safe (recipe work lives in branches, never on mirror `main`). It
|
||||
> force-syncs each mirror to coopcloud's **default branch — resolved from the API, `main` OR
|
||||
> `master`** — and closes any mirror PR whose changes upstream already merged. Skipping it has cost
|
||||
> us three distinct ways: mailu #6 was reported as the fix for two internet-facing CVEs while
|
||||
> upstream had already merged AND released it; a stale mirror makes a survey report "no upgrades
|
||||
> available" so the recipe drops out of the weekly run; and reading the wrong branch on a recipe with
|
||||
> a stale `main` beside a live `master` (gitea) manufactures a false "three releases behind, missing
|
||||
> two CVSS-9.8 RCEs" finding.
|
||||
|
||||
### 2. For each stale test — author the minimal test update (AI; never weaken)
|
||||
|
||||
> **Read `tests/STYLE.md` in the cc-ci repo before writing the update.** It is the rulebook for test
|
||||
|
||||
@@ -31,6 +31,12 @@ Then present the roster grouped as follows, and close with the situation guide.
|
||||
PR). `--with-tests` also fixes that recipe's stale test.
|
||||
- **/recipe-report** — (re)generate the weekly report page for report.ci.commoninternet.net.
|
||||
|
||||
**Keeping the PR list honest**
|
||||
- **/cc-ci-cleanup** — reconciles every mirror from true upstream (which alone closes PRs upstream
|
||||
already merged), then closes the open recipe PRs that can no longer merge or were never meant to
|
||||
(CI sweep artifacts, obsolete bumps, superseded duplicates) and reports what is actually blocking
|
||||
the ones that should land. Never merges.
|
||||
|
||||
**Security (CVEs)**
|
||||
- **/cve-check** — fleet-wide CVE sweep with **no upgrading**: for every recipe, work out what
|
||||
upgrade is available (per image, sidecars included), scan it for CVEs, and publish a CVE report.
|
||||
@@ -81,6 +87,7 @@ ARM skills never touch cc-ci infra. After a submodule bump run `scripts/gen-ccte
|
||||
| "Run the weekly upgrades now" | `/upgrade-all` (or `systemctl start cc-ci-upgrade-all.service`) |
|
||||
| "Upgrade just <recipe>" | `/recipe-upgrade <recipe>` |
|
||||
| "The report site is stale/missing a week" | `/recipe-report` |
|
||||
| "The open PR list is a mess / what should I merge?" | `/cc-ci-cleanup` |
|
||||
| "What CVEs are we exposed to right now?" | `/cve-check` (read-only, no PRs) |
|
||||
| "A CVE just dropped — check and patch it" | `/cve-check-and-upgrade` (add `--min-severity high` to skip the noise) |
|
||||
| "Is <recipe> vulnerable?" | `/cve-check <recipe>` |
|
||||
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pr-survey — deterministic facts about every open recipe PR, for /cc-ci-cleanup to judge.
|
||||
|
||||
Open recipe PRs rot in specific, detectable ways. This gathers the evidence; it does NOT decide
|
||||
anything — closing a PR is a judgement the skill makes, with these facts in hand.
|
||||
|
||||
RUN `reconcile-upstream.sh --all` FIRST. Every signal below is measured against the mirror's `main`,
|
||||
and an unreconciled mirror makes all of them wrong: on 2026-08-11 three PRs (discourse #6 carrying
|
||||
140 CVEs, keycloak #6 carrying 12, n8n #5) looked pending against a stale mirror while upstream had
|
||||
already merged them. This tool refuses to guess about that — see `reconciled_recently`.
|
||||
|
||||
Per PR:
|
||||
behind_main commits on main not in the branch — the "out of date" measure
|
||||
ahead commits on the branch not on main
|
||||
mergeable gitea's own verdict (false = conflicts, needs a rebase)
|
||||
diff_files files the PR touches (0 = nothing left to merge)
|
||||
adds_images the `+ image:` lines it introduces
|
||||
already_in_main those `+ image:` lines ALREADY present in main -> the bump landed another way
|
||||
obsolete true when every image it adds is already in main (nothing to contribute)
|
||||
ci newest `!testme` verdict + build number parsed from the PR comments
|
||||
branch_kind upgrade / fix / ci-artifact (`ci/*` sweep + probe branches) / other
|
||||
age_days, stale_days (since last update)
|
||||
|
||||
pr-survey.py [recipe ...] [--json]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
USED_RECIPES = os.path.join(HERE, "used-recipes.md")
|
||||
TESTENV = os.environ.get("CCCI_TESTENV", "/srv/cc-ci/.testenv")
|
||||
NS = "recipe-maintainers"
|
||||
|
||||
|
||||
def _env() -> dict:
|
||||
e = {}
|
||||
try:
|
||||
for ln in open(TESTENV):
|
||||
ln = ln.strip()
|
||||
if "=" in ln and not ln.startswith("#"):
|
||||
k, v = ln.split("=", 1)
|
||||
e[k] = v.strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return e
|
||||
|
||||
|
||||
ENV = _env()
|
||||
GITEA = os.environ.get("GITEA_URL") or ENV.get("GITEA_URL", "git.autonomic.zone")
|
||||
_AUTH = base64.b64encode(
|
||||
f"{os.environ.get('GITEA_USERNAME') or ENV.get('GITEA_USERNAME','')}:"
|
||||
f"{os.environ.get('GITEA_PASSWORD') or ENV.get('GITEA_PASSWORD','')}".encode()
|
||||
).decode()
|
||||
|
||||
|
||||
def _get(path: str, raw: bool = False):
|
||||
req = urllib.request.Request(
|
||||
f"https://{GITEA}{path}",
|
||||
headers={"Authorization": f"Basic {_AUTH}", "User-Agent": "cc-ci-pr-survey"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
body = r.read()
|
||||
return body.decode(errors="replace") if raw else json.loads(body)
|
||||
|
||||
|
||||
def _days(iso: str | None) -> int | None:
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return (datetime.now(timezone.utc) - d).days
|
||||
|
||||
|
||||
def _branch_kind(ref: str) -> str:
|
||||
if ref.startswith("ci/"):
|
||||
return "ci-artifact" # regall/cfold sweeps + testme probes; never meant to merge
|
||||
if ref.startswith("upgrade"):
|
||||
return "upgrade"
|
||||
if re.match(r"^(fix|feat|chore|revert)", ref):
|
||||
return "fix"
|
||||
return "other"
|
||||
|
||||
|
||||
def _main_images(recipe: str) -> set[str]:
|
||||
"""Image refs pinned on the mirror's main — the baseline a PR is judged against."""
|
||||
out = set()
|
||||
for f in ("compose.yml",):
|
||||
try:
|
||||
txt = _get(f"/{NS}/{recipe}/raw/branch/main/{f}", raw=True)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for m in re.finditer(r"^\s*image:\s*[\"']?([^\"'\s]+)", txt, re.M):
|
||||
out.add(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def _ci_verdict(recipe: str, number: int) -> dict:
|
||||
"""Newest cc-ci !testme outcome recorded on the PR."""
|
||||
try:
|
||||
cs = _get(f"/api/v1/repos/{NS}/{recipe}/issues/{number}/comments?limit=100")
|
||||
except Exception: # noqa: BLE001
|
||||
return {"verdict": "unknown", "build": None}
|
||||
for c in reversed(cs):
|
||||
b = c.get("body") or ""
|
||||
if "cc-ci:testme" not in b:
|
||||
continue
|
||||
m = re.search(r"/cc-ci/(\d+)", b)
|
||||
if "✅" in b or "passed" in b:
|
||||
return {"verdict": "passed", "build": m.group(1) if m else None}
|
||||
if "❌" in b or "failure" in b:
|
||||
return {"verdict": "failed", "build": m.group(1) if m else None}
|
||||
if "⏳" in b or "in progress" in b:
|
||||
return {"verdict": "running", "build": m.group(1) if m else None}
|
||||
return {"verdict": "never-run", "build": None}
|
||||
|
||||
|
||||
def survey_pr(recipe: str, pr: dict, main_images: set[str]) -> dict:
|
||||
n = pr["number"]
|
||||
head = pr["head"]["ref"]
|
||||
row = {
|
||||
"recipe": recipe, "number": n, "title": pr.get("title", ""), "head": head,
|
||||
"url": pr.get("html_url"), "branch_kind": _branch_kind(head),
|
||||
"age_days": _days(pr.get("created_at")), "stale_days": _days(pr.get("updated_at")),
|
||||
"mergeable": pr.get("mergeable"),
|
||||
}
|
||||
try:
|
||||
row["behind_main"] = _get(
|
||||
f"/api/v1/repos/{NS}/{recipe}/compare/{urllib.parse.quote(head, safe='')}...main"
|
||||
).get("total_commits", 0)
|
||||
row["ahead"] = _get(
|
||||
f"/api/v1/repos/{NS}/{recipe}/compare/main...{urllib.parse.quote(head, safe='')}"
|
||||
).get("total_commits", 0)
|
||||
except Exception: # noqa: BLE001
|
||||
row["behind_main"], row["ahead"] = None, None
|
||||
# A FAILED diff fetch must never look like an empty diff: gitea#4 404s on .diff (force-pushed
|
||||
# branch) and would otherwise be flagged EMPTY-DIFF and closed — while being a verified, green,
|
||||
# needed fix. Unknown is its own state.
|
||||
diff = None
|
||||
try:
|
||||
body = _get(f"/{NS}/{recipe}/pulls/{n}.diff", raw=True)
|
||||
if body.lstrip().startswith(("diff --git", "From ")) or not body.strip():
|
||||
diff = body
|
||||
except Exception: # noqa: BLE001
|
||||
diff = None
|
||||
row["diff_files"] = None if diff is None else len(re.findall(r"^diff --git ", diff, re.M))
|
||||
adds = re.findall(r"^\+\s*image:\s*[\"']?([^\"'\s]+)", diff or "", re.M)
|
||||
row["adds_images"] = sorted(set(adds))
|
||||
row["already_in_main"] = sorted({i for i in set(adds) if i in main_images})
|
||||
# Nothing left to contribute: it touches files but every image it introduces is already pinned.
|
||||
# Only claim obsolete when the diff was actually READ. No diff, no verdict.
|
||||
row["obsolete"] = diff is not None and bool(adds) and set(adds).issubset(main_images)
|
||||
row["ci"] = _ci_verdict(recipe, n)
|
||||
return row
|
||||
|
||||
|
||||
def all_recipes() -> list[str]:
|
||||
out = []
|
||||
for ln in open(USED_RECIPES):
|
||||
p = ln.split()
|
||||
if len(p) >= 2 and not ln.startswith(("#", "`")) and p[1] in ("weekly", "external"):
|
||||
out.append(p[0])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("recipes", nargs="*")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = []
|
||||
for r in (a.recipes or all_recipes()):
|
||||
try:
|
||||
prs = _get(f"/api/v1/repos/{NS}/{r}/pulls?state=open&limit=50")
|
||||
except urllib.error.HTTPError as e:
|
||||
rows.append({"recipe": r, "error": f"HTTP {e.code}"})
|
||||
continue
|
||||
if not prs:
|
||||
continue
|
||||
mi = _main_images(r)
|
||||
for pr in prs:
|
||||
rows.append(survey_pr(r, pr, mi))
|
||||
|
||||
if a.json:
|
||||
print(json.dumps(rows, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"{len(rows)} open PR(s)\n")
|
||||
for x in sorted(rows, key=lambda z: (z.get("recipe", ""), z.get("number", 0))):
|
||||
if x.get("error"):
|
||||
print(f" {x['recipe']}: {x['error']}")
|
||||
continue
|
||||
flags = []
|
||||
if x["obsolete"]:
|
||||
flags.append("OBSOLETE(images already in main)")
|
||||
if x["branch_kind"] == "ci-artifact":
|
||||
flags.append("CI-ARTIFACT")
|
||||
if x["diff_files"] == 0:
|
||||
flags.append("EMPTY-DIFF")
|
||||
if x["diff_files"] is None:
|
||||
flags.append("DIFF-UNREADABLE(do not close on this)")
|
||||
if x["mergeable"] is False:
|
||||
flags.append("CONFLICTS")
|
||||
if (x["behind_main"] or 0) > 0:
|
||||
flags.append(f"BEHIND-{x['behind_main']}")
|
||||
print(f" {x['recipe']}#{x['number']:<3} {x['title'][:52]}")
|
||||
print(f" {x['branch_kind']:12} age={x['age_days']}d idle={x['stale_days']}d "
|
||||
f"ci={x['ci']['verdict']}({x['ci']['build'] or '-'}) files={x['diff_files'] if x['diff_files'] is not None else '?'}")
|
||||
if x["adds_images"]:
|
||||
print(f" adds: {', '.join(i.split('/')[-1] for i in x['adds_images'][:4])}")
|
||||
if flags:
|
||||
print(f" >> {' | '.join(flags)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# reconcile-upstream — sync recipe mirrors from TRUE upstream. Run this FIRST, always.
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Every recipe we maintain is a MIRROR of a coopcloud recipe. Work done against a stale
|
||||
# mirror is wasted or wrong, in three ways we have actually hit:
|
||||
#
|
||||
# 1. A PR whose changes upstream ALREADY MERGED. mailu #6 (2024.06.57 + redis 8.10,
|
||||
# two internet-facing Roundcube CVEs) sat open and was reported as the fix for
|
||||
# those CVEs — while upstream had merged and released it as 3.1.3+2024.06.57. The
|
||||
# work was done; only our mirror was behind.
|
||||
# 2. A survey that reads the stale mirror and reports "no upgrades available", so a
|
||||
# recipe silently drops out of the weekly run.
|
||||
# 3. Reading the WRONG BRANCH. Several coopcloud recipes keep a stale `main` beside
|
||||
# the real default `master` — gitea's `main` is at 1.24.2-rootless while `master`
|
||||
# has 1.27.1-rootless plus the merged PRs. Reading `main` there says the recipe is
|
||||
# three releases behind and missing two CVSS-9.8 RCE fixes, which reads exactly
|
||||
# like a real finding. open-recipe-pr.sh resolves the default branch itself
|
||||
# (main OR master) — never hand-pick one.
|
||||
#
|
||||
# This is deterministic: it force-syncs each mirror's `main` to upstream's default
|
||||
# branch and closes any mirror PR whose changes are already upstream. No AI judgement.
|
||||
#
|
||||
# reconcile-upstream.sh <recipe>... # specific recipes
|
||||
# reconcile-upstream.sh --all # every recipe in used-recipes.md
|
||||
#
|
||||
# Safe to run repeatedly; a mirror already in sync is a no-op. Recipe work lives in
|
||||
# BRANCHES, never on mirror `main`, so force-syncing `main` discards nothing.
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ORCH="$(dirname "$HERE")"
|
||||
SSH="${SSH:-cc-ci}"
|
||||
TESTENV="${TESTENV:-/srv/cc-ci/.testenv}"
|
||||
RECONCILE="${RECONCILE:-$ORCH/.claude/skills/recipe-upgrade/open-recipe-pr.sh}"
|
||||
USED_RECIPES="${USED_RECIPES:-$HERE/used-recipes.md}"
|
||||
|
||||
[ -f "$RECONCILE" ] || { echo "ERROR: reconcile helper not found: $RECONCILE" >&2; exit 1; }
|
||||
set -a; . "$TESTENV"; set +a
|
||||
: "${GITEA_USERNAME:?}"; : "${GITEA_PASSWORD:?}"; : "${GITEA_URL:?}"
|
||||
|
||||
if [ "${1:-}" = "--all" ]; then
|
||||
mapfile -t RECIPES < <(awk '!/^[[:space:]]*#/ && ($2=="weekly" || $2=="external") {print $1}' "$USED_RECIPES")
|
||||
else
|
||||
[ "$#" -gt 0 ] || { echo "usage: reconcile-upstream.sh <recipe>... | --all" >&2; exit 2; }
|
||||
RECIPES=("$@")
|
||||
fi
|
||||
|
||||
synced=0; closed=0; failed=0
|
||||
for r in "${RECIPES[@]}"; do
|
||||
echo "── $r"
|
||||
if out="$(ssh "$SSH" "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GITEA_URL='$GITEA_URL' bash -s $r --reconcile-only" < "$RECONCILE" 2>&1)"; then
|
||||
printf '%s\n' "$out" | grep -E "Force-syncing|already in sync|closed PR|still open|✓" | sed 's/^/ /' || true
|
||||
synced=$((synced + 1))
|
||||
closed=$((closed + $(printf '%s' "$out" | grep -c "closed PR" || true)))
|
||||
else
|
||||
printf '%s\n' "$out" | tail -3 | sed 's/^/ /'
|
||||
echo " ✗ FAILED — do NOT proceed against this mirror until it reconciles"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "reconcile-upstream: ${synced} mirror(s) synced, ${closed} already-upstream PR(s) closed, ${failed} failed"
|
||||
[ "$failed" -eq 0 ]
|
||||
Reference in New Issue
Block a user