Compare commits

..
Author SHA1 Message Date
autonomic-bot 7c331745d8 Merge pull request 'CVE detection engine — correctness fixes, spec, tests, audit' (#2) from review/1-cve-engine into review/session-base 2026-08-11 19:03:52 +00:00
autonomic-bot 44cb9b6704 advisory-scan: tests, audit, and two real undercounts they found
Adds test-advisory-scan.py (58 offline tests on fixtures + 6 live regressions
against the week-2026-08-07 report) and audit-advisory-scan.py, which re-derives
every count with a SEPARATE semver implementation and its own release fetch and
diffs against the scanner. Both found real defects:

1. Window membership was compared on ragged tuples, so (18,) < (18,0) — a CVE
   patched in 18.0 fell OUTSIDE a window ending at 18. Bare major tags are the
   norm for sidecars (postgres:18, redis:8-alpine). Now zero-padded, which also
   keeps the upper bound conservative (18.5 stays out of a window ending at 18).

2. Advisories with no knowable fix version were silently counted as 'not fixed'.
   Twelve redis advisories say patched_versions 'TBD' or '7.4.X' with an
   open-ended range — six of them high severity. They are now INDETERMINATE:
   not counted, not dismissed, and surfaced in the output.

   All twelve turned out to be genuinely fixed: redis names each in the release
   notes of every branch that got the fix (CVE-2025-32023 -> 6.2.19, 7.2.10,
   7.4.5, 8.0.3, 8.2.0). So a third deterministic method resolves them from
   release notes, with the naming tags recorded as the citation. discourse's
   redis contribution goes 5 -> 17, and its total 128 -> 140.

Pass 2 (--adjudicate) is the model-judged stage for what arithmetic cannot
settle: it hands over each open case's full evidence, plus every verdict pass 1
reached, and takes FIXED/NOT-FIXED/STILL-UNKNOWN with a reason citing that
evidence. It may only raise a count. Vendor-page-only CVEs — the shape of both
gitea CVSS-9.8 RCEs — now reach it instead of being dropped.

Tests cover pass 1 only, by design; pass 2's judgement is a model's. What is
tested there is deterministic: which cases it selects, and that truncation is
announced rather than silent.

SPEC.md rewritten around the two passes.
2026-08-11 01:29:11 +00:00
autonomic-bot 46c4fff1a6 advisory-scan: --image takes NAME=FROM:TO
Restores the single-value form (operator preference) under the --image name.
Repeat the flag per image, all in one call. Malformed values warn on stderr and
are skipped rather than aborting the scan, since it is an additive pre-step.

Counts unchanged: discourse 128 with redis / 123 without, gitea 2.
2026-08-11 00:52:40 +00:00
autonomic-bot 65bf3c095b advisory-scan: --image NAME FROM TO replaces --window KEY=FROM:TO
The image name was packed into the value, so the flag needed a hand-rolled
KEY=FROM:TO parser with its own malformed-input branch, and 'window' named the
wrong thing — the tool has two kinds of window (version ranges and, on the date
fallback, real date windows) and the flag meant only the first.

Now each part is its own argument: --image redis 7.4 8.10, repeatable, all in
one call. argparse enforces the arity, so the string parsing and its error path
are deleted. 'windows' survives internally as the computed-range concept.

Counts unchanged: discourse 128 with redis / 123 without, gitea 2.
2026-08-11 00:51:12 +00:00
autonomic-bot 8d7320f32e recipe-report: scheme changes and sidecars no longer mean '?'
The skill still told the reporter to publish '?' whenever the scan hit a
version-scheme change. The scan now resolves those by advisory publish date, so
that instruction would have re-introduced a '?' for a count it can determine.

Also documents that counts are a union across per-image windows, and that a
sidecar-sourced critical must name its image in the bulletin.
2026-08-10 22:06:38 +00:00
autonomic-bot b5f8543a9b advisory-scan: count sidecar CVEs via per-image windows
A recipe upgrades several images, each through its own version range. The scan
previously classified only the app repo, so sidecar bumps contributed nothing — the
alternative to the earlier bug where sidecars were judged by the APP's window and
produced a false 133.

Now: --window KEY=FROM:TO (repeatable) gives any other source its own range; each
window is classified independently (one may use patched-version ranges while another
falls back to advisory dates) and the count is the UNION. An image with no window is
still not counted — the scan will not guess a range it was not given. If ANY requested
window cannot be ordered, the total is UNKNOWN rather than a partial number.

/recipe-upgrade now instructs passing a --window per bumped sidecar.

Verified on discourse app 3.5.3->2026.7.1 + redis 7.4->8.10: 128 = 123 (app, by
publish date) + 5 (redis, by version range). The redis five are genuine for that bump
(patched 7.4.1 / 7.4.6 / 8.2.3) and include CVE-2025-49844, CRITICAL — previously
invisible. Regressions clean: gitea still 2, discourse without the sidecar window
still 123.
2026-08-10 22:01:28 +00:00
autonomic-bot 78ae2be8ae docs: spec for the advisory scanner's CVE detection
Step-by-step specification of cc-ci-plan/advisory-scan.py: inputs, the three source
classes and why each is ranked where it is, the union, both classification paths
(patched-version ranges, and the advisory-publish-date fallback for version-scheme
changes), the output contract, and how /recipe-report must read it.

Each rule records the production wrong answer that motivated it — the false 133 from
cross-image counting, the n8n misclassification from reading only vulnerabilities[0],
the '?' sprawl from url punctuation and benign-404s, and the 'never emit 0 for an
undetermined count' rule. Claims cross-checked against the implementation.
Keep this file in the same commit as any behaviour change.
2026-08-10 21:55:37 +00:00
autonomic-bot 98a624a13a advisory-scan: paginate, and count by advisory DATE when versions can't be ordered
Answers 'how can the weekly run produce counts like the hand count?' — by doing
exactly what the hand count did, deterministically. Two changes:

1. PAGINATION. The scanner requested per_page=100 and stopped. This endpoint caps at
   100 AND ignores ?page= (it re-returns the same rows — which is how a manual count
   first produced exact triplicates and a bogus 300). Busy projects were silently
   truncated: discourse has 286 advisories, so a single page could not see the window
   at all. Now follows the Link rel=next cursor to exhaustion.

2. DATE-BASED FALLBACK. Version strings cannot be ordered across a scheme change
   (discourse semver 3.5.3 -> calver 2026.7.1), which is why the scan first reported a
   false 133, then correctly refused. Release DATES always order. When the version path
   refuses, the scan now resolves both versions to their git tag dates on the primary
   repo and counts advisories PUBLISHED in that window, labelling the method in the
   output. The version path is still preferred when usable — it is exact rather than
   temporal.

Verified: discourse 3.5.3 -> 2026.7.1 now reports 123, matching the hand count
(1 critical, 16 high, 91 medium, 16 low; window 2025-12-30 -> 2026-07-31); gitea
1.27.0 -> 1.27.1 still reports 2 via the version path.
2026-08-10 21:02:39 +00:00
autonomic-bot fc36d0e10f advisory-scan: report UNKNOWN, never 0, when a count could not be determined
Operator: 'the scanner should not say 0 when it was not able to scan.' Correct — the
previous patch still led with '0 identified' and relegated the caveat to a footnote,
so the headline number was wrong even though the prose was right. A 0 in a security
column is an assertion of safety; it must never be emitted for an undetermined result.

Now: cve_count_fixed is null (not 0) in JSON, a count_known flag distinguishes
'counted zero' from 'could not count', and the markdown headline reads
'CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count' with an
explicit 'This is NOT zero' and instructions to render '?'.

Verified: discourse 3.5.3 -> 2026.7.1 (semver->calver) now reports UNKNOWN; gitea
1.27.0 -> 1.27.1 still reports 2.
2026-08-10 20:52:51 +00:00
autonomic-bot 1daf0fa616 advisory-scan: stop cross-image and cross-scheme miscounting (discourse's false 133)
Operator disbelieved discourse's '133 CVEs fixed' — correctly. Two defects made it
confidently wrong:

1. ONE WINDOW APPLIED TO EVERY IMAGE. The scan queries all source repos in the
   recipe's registry (app + redis/postgres/nginx sidecars) but judged them all with
   the APP's version window. 34 of the 133 were redis advisories, including
   CVE-2021-21309 — patched in redis 6.0.11 back in 2021 — scored as 'fixed by this
   upgrade' purely because 6.0.11 sits numerically inside discourse's 3.5.3 ->
   2026.7.1 range. Only the PRIMARY app repo is now classified; other sources are
   reported as unclassified so they stay visible without inflating the count.

2. VERSION-SCHEME CHANGES BREAK ORDERING. discourse moved semver -> calver
   (3.5.3 -> 2026.7.1), so 2025.12.2 compares 'newer' than 3.5.3 while shipping
   earlier. Numeric comparison cannot order that. The scan now detects a leading-
   component jump >= 100, refuses to classify, and says so in the block: the count
   is '0 by refusal, not by evidence — read the vendor's release notes'.

Refusing to answer beats answering wrongly: a fabricated 133 in a public security
report is worse than an explicit 'cannot determine'.

Verified after the fix: discourse 133 -> 0 (with the refusal caveat), gitea still
exactly 2 (both criticals, patched 1.27.1), keycloak 7 all genuinely from
keycloak/keycloak patched in 26.7.1, plausible 1. No other count changed.
2026-08-10 20:44:27 +00:00
autonomic-bot 318d09bdab advisory-scan: eliminate spurious '?' — url punctuation, stale URL, and ? semantics
The 2026-08-07 regeneration rendered '?' for 5 of 21 recipes. '?' is meant to be a
rare 'we tried and could not tell'; at that rate it is indistinguishable from noise
and hides the real unknowns. Three causes, none of them genuine uncertainty:

1. URL EXTRACTION BUG (mine). The registry is markdown, so urls appear inside
   `backticks` and 'quotes'. The extractor captured the trailing punctuation, so
   it fetched https://docs.n8n.io/release-notes/` and https://git.autonomic.zone'`
   — both 404 on the malformed url, both 200 when clean. Trailing markdown
   punctuation is now stripped. Fixed immich + n8n.
2. STALE REGISTRY URL. mattermost-lts pointed at
   docs.mattermost.com/about/mattermost-changelog.html, which 404s; the page moved
   to /deploy/. Corrected (same class as the pgautoupgrade fix).
3. WRONG SEMANTICS FOR 'NO UPGRADE'. lasuite-docs and custom-html-tiny were
   up-to-date this run, so no scan block existed and the report fell back to '?'.
   But a recipe with no upgrade has nothing an upgrade could have fixed — that is
   0, not unknown. The report skill now says so explicitly, restricts '?' to scans
   that RAN and reported genuinely failed sources, states that benign notes
   (no-advisories-published / template URL) never trigger '?', and instructs that
   many '?' is itself a bug to raise in the Addendum.

Result across all 16 scanned recipes of that run: 0 failed sources (was 5).
Counts also improved with the classifier fix: discourse 130->133, keycloak ->7.
2026-08-10 20:23:10 +00:00
autonomic-bot 3e59924450 advisory-scan: fix version classification (multi-line patches + range expressions)
Exposed by asking whether the scan catches the n8n CVEs (CVE-2026-42231/42232). It
did not — the advisories were fetched correctly but both misclassified as
out-of-window. Two bugs:

1. Only vulnerabilities[0] was read. An advisory carries ONE ENTRY PER PATCHED
   RELEASE LINE: n8n patches three (1.123.32, 2.17.4, 2.18.1), so whichever line
   the deployment is actually on was silently dropped. gitea passed only because it
   patches a single line. Now all entries are kept.
2. patched_versions is a RANGE EXPRESSION ('>= 2.18.1'), not a bare version. Naive
   parsing produced (18,1) instead of (2,18,1), so no comparison could ever match.
   Version tokens are now extracted with a regex and the advisory counts as
   fixed-by-this-upgrade if ANY patched line falls in (from, to].

Verified: n8n 2.17.0 -> 2.18.1 now reports 12 CVEs including both criticals
(CVE-2026-42231 GHSA-q5f4-99jv-pgg5, CVE-2026-42232); gitea 1.27.0 -> 1.27.1 still
reports exactly 2. Note our deployed n8n (2.27.2+) is already past all three patched
lines, so these were never outstanding for us — the bug was in detection, not
exposure.
2026-08-10 18:50:07 +00:00
autonomic-bot 3307bdb0fe advisory-scan: separate benign source absence from real failures; fix pgautoupgrade URLs
Two refinements found by running the scan across all 14 recipes of the 2026-08-07 run:

1. A repo with no advisory feed returns HTTP 404 on /security-advisories (e.g. the
   pgautoupgrade sidecar image). That is a BENIGN ABSENCE, not a failed check.
   Likewise registry entries that are TEMPLATE urls for humans
   (…/changelog/v<VERSION>/, …/<vX.Y.Z>/…) are documentation, not fetchable.
   Counting either as a failure pushed most recipes to '?', which would make the
   unknown-vs-clean distinction meaningless again — the exact signal the ? exists to
   preserve. Both are now recorded in sources_benign; only genuine errors (rate
   limit, network, 5xx, wrong URL) land in sources_failed.

2. upstream/*.md pointed at github.com/pgautoupgrade/pgautoupgrade, which 404s —
   the repo is pgautoupgrade/docker-pgautoupgrade. Corrected in n8n, lasuite-docs,
   lasuite-drive, lasuite-meet. A 404ing registry URL means we were not scanning a
   source we believed we were.

Effect on the 2026-08-07 data: recipes with genuine failed sources 5 -> 3 (the
remainder are really unreachable vendor pages). CVE counts unchanged where they
were already sound: discourse 130, gitea 2, plausible 1.
2026-08-10 18:45:45 +00:00
autonomic-bot cf26ef863a advisory-scan: read-only GitHub token from env or file (rate limit only)
Anonymous GitHub API is 60 req/hr — a full weekly sweep across ~20 recipes exhausts
it and the scan then reports sources as failed (visible, but degraded coverage). A
token lifts it to 5000/hr.

_github_token(): GITHUB_TOKEN env wins, else GITHUB_TOKEN_FILE (default
/srv/cc-ci/.github-token, 0600, gitignored). Reading PUBLIC advisories needs NO
scopes — a classic PAT with nothing ticked, or fine-grained limited to 'Public
repositories: read'. The tool only ever GETs advisories; do not grant write scopes.
A missing token is not an error: the scan runs anonymously and surfaces failures.

Also gitignores .github-token and .hcloud-token.
2026-08-10 18:38:45 +00:00
autonomic-bot 5775fe23f8 security: deterministic advisory scan as an ADDITIVE pre-step
Why: gitea 1.27.1 fixed CVE-2026-60004 + CVE-2026-59774 (both CVSS 9.8). The
2026-08-03 report printed gitea's CVE count as '1', the 2026-08-07 report as
'none'. Cause chain: the upgrade subagent read the GitHub release notes, which
name NEITHER cve (they are announced only in the vendor blog's security section),
so it recorded one unrelated minor item; the report then derived security content
from those notes plus model knowledge, and the model's training predates the CVEs.
Nothing in the pipeline ever queried an advisory source.

cc-ci-plan/advisory-scan.py — deterministic, per recipe, per upgrade window:
  1. GitHub Security Advisories API for every source repo in the upstream registry.
     PRIMARY: CVE + GHSA + severity + vulnerable/patched ranges, so 'fixed by THIS
     upgrade' is computed. Needs no new per-recipe config (134 registry URLs are
     already github.com).
  2. Vendor release/security pages — every registry URL, fetched + regex-scanned.
     This is the source that actually had the gitea CVEs.
  3. OSV where a package mapping exists — supplementary.
Each source reports its own status so 'checked, none found' is never confused with
'not checked'. Source selection was measured, not assumed: for these two CVEs OSV
404'd and NVD's API had them by neither CPE, id, nor keyword — advisory DBs lag the
vendor, hence 1+2 lead.

Wiring is strictly ADDITIVE:
- /recipe-upgrade gains step 2a: run the scan, paste the block into the per-recipe
  log, and report the UNION of it and the existing release-note reading. The scan
  may never lower a count established by reading.
- /recipe-report treats the block as a FURTHER source, prefers its advisory ids /
  severities / fixed-in versions for citation, and must render '?' (not 'none')
  when a scan is absent or has failed sources — the false-clean 'none' is exactly
  what happened on 2026-08-07.
- upstream/gitea.md records blog.gitea.com as the security-announcement URL.

Verified on the real regression: 1.27.0 -> 1.27.1 now yields exactly the 2 missed
criticals with their GHSA ids; the wider 1.26.2 -> 1.27.1 window yields 62.
2026-08-10 18:25:58 +00:00
autonomic-bot 02dbd71b49 recipe-upgrade: stop upgrade-PR branches drifting behind upstream
The extend path grafts HEAD^{tree} WHOLESALE onto the existing upgrade-* branch
(commit-tree -p <branch tip>). Reconcile force-syncs the MIRROR's main to upstream
but never brought the branch — or the local checkout — forward, so each week the
PR base drifted further back and upstream changes made since the branch was cut
were silently absent from the pushed tree. CI then verified a tree that would
never deploy.

Found on gitea PR #5 (2026-08-10): base 0ab323d predated upstream's
'BREAKING CHANGE: remove forgejo' (37ebd22), so the 1.27.1 bump fixing
CVE-2026-60004 + CVE-2026-59774 was !testme-GREEN against a forgejo-bearing tree.

Two changes:
1. Before pushing, if the local work does not contain the freshly-synced upstream
   main, merge upstream in — and FAIL LOUDLY (exit 1, naming the checkout) if that
   cannot auto-merge, rather than pushing a tree that omits upstream changes.
2. The extend commit now also parents on upstream main when the branch predated it,
   so the recorded history matches the pushed tree. Without it the merge-base stays
   stale and a later merge can REVERT upstream's changes. Still no force-push.

Verified against the real gitea drift: detection fires, merge is clean, resulting
tree keeps forgejo removed AND the 1.27.1 pin, history contains upstream.
2026-08-10 16:24:37 +00:00
autonomic-bot 8f85a238cc journal: 2026-08-07 run finished; supervisor-gate, report-pin, and subagent-model bugs fixed 2026-08-10 15:57:25 +00:00
autonomic-bot 80008da80d opencode: drop the misplaced project config
It lived in the orchestrator repo (the parent session's project) and so never
governed the task-tool subagents, which resolve their parent session's directory —
for launcher-started runs that is /srv/cc-ci-orch/cc-ci. The real config now lives
there (cc-ci repo, operator-approved). Extension was never the problem: .jsonc
parsed and resolved fine; the LOCATION was wrong.
2026-08-10 15:56:55 +00:00
autonomic-bot 242a6d9659 report: clear+re-pin the session id at launch (fixes un-watchdogged report runs)
Regression from the 2026-08-04 session-pinning work: lu._session_id() prefers the
pin file, but launch-report.py never cleared or re-established it. A surviving pin
from a PREVIOUS report run points at a session whose last message already carries
RECIPE REPORT COMPLETE, so the shared watchdog evaluates _completed()=True and
exits within one poll ('run completed — exiting'), leaving the CURRENT run
unwatched. Observed live: the 2026-08-07 finish-run's report step was watched by a
watchdog that quit after 3 minutes against an Aug-4 pin, then the report session
ended early with nothing to resume it.

start() now archives stale titles, clears the pin, snapshots existing ids, and
re-pins the new session after launch — the same contract launch-upgrader.start()
already follows. Scopes the shared helpers via UPGRADER_SESSION=<report session>.
2026-08-10 15:41:48 +00:00
autonomic-bot d441c6caaf supervisor: fix 3-day progress-gate deadlock (2026-08-07 run)
Root cause chain, all confirmed on the live system:
1. _run_pids() substring-matched the WHOLE cmdline for the session name. An agent's
   kickoff PROMPT is an argv element, and the supervisor's prompt text contains the
   literal 'cc-ci-upgrader' — so the supervisor's OWN billing-hung agent matched as a
   live upgrader run. Now matches FLAG VALUES only (--title <SESSION> / -s <sid>).
2. The gate treated 'a live proc exists' as progress. A provider-walled run keeps its
   process alive and SPINNING while emitting nothing (verified: 3 days, zero session
   output, still burning CPU). Progress now REQUIRES the session tree to have advanced
   within STALL_MIN; a live-but-idle proc is explicitly logged as stalled.
Result was ~60 consecutive false 'run progressing — leaving it' no-ops while the weekly
run sat unfinished and unreported.

Billing-walled runs are REPORTED, never killed (operator policy 2026-08-10): they may
resume when the wall lifts and their context is the run's state. New _billing_blocked()
detects the wall from the log tail and the gate surfaces
'run BLOCKED on a provider billing/usage wall — NOT killing; operator action required'.

Verified live: _run_pids no longer matches the hung Aug-7 supervisor (1497561) while
still matching the real finisher; gate now reasons 'session advanced Nm ago'.
2026-08-10 15:17:02 +00:00
autonomic-bot d101147b93 upstream(plausible): note v3.2.1 ships with clickhouse 24.12-alpine 2026-08-07 05:14:27 +00:00
autonomic-bot fb1dc7af9c upstream(mattermost-lts): 2026-08-07 re-check (11.10.0 pre-release; ESR=11.7.8) 2026-08-07 04:47:40 +00:00
autonomic-bot 0347511a84 upstream(n8n): release-notes sources 2026-08-07 04:44:40 +00:00
autonomic-bot f8888b2082 upstream(lasuite-drive): fix collabora release-notes URL + note minio AIStor move 2026-08-07 04:21:19 +00:00
autonomic-bot a56734de0b upstream(immich): 2026-08-07 — upstream main at v3.1.0; runner clone-token stale 2026-08-07 03:51:02 +00:00
autonomic-bot 5424954b3f upstream(gitea): note 1.27.1 patch release 2026-08-07 03:15:22 +00:00
autonomic-bot 51b067770c upstream(discourse): re-confirm pg18 newest (2026-08-07) 2026-08-07 02:53:05 +00:00
autonomic-bot 34d62fa049 journal: three pending weekly-run PRs unblocked (keycloak/mailu re-verified, discourse basefloor fix merged) 2026-08-04 17:58:02 +00:00
autonomic-bot 995bcf82d7 journal: 2026-08-04 weekly-run completion + bridge/wordpress/watchdog/naming wrap-up 2026-08-04 17:04:08 +00:00
autonomic-bot 04a04e51ac session naming: archive- prefix convention + cc-ci-report unique-name invariant
- _archive_stale_titles default label is now 'archive-<title> —' (operator
  convention: all archived sessions start with archive- so they sort/filter
  together in the web UI).
- launch-report.py start() archives older cc-ci-report sessions before launch,
  same invariant as upgrader/supervisor (reuses the launch-upgrader helper).
- 33 sessions restyled/archived live; canonical names now unique:
  cc-ci-upgrader (idle finisher), cc-ci-report (generating), cc-ci-supervisor
  (none — only exists during a rescue).
2026-08-04 16:58:30 +00:00
autonomic-bot f750622e3d supervisor: unique cc-ci-supervisor web-UI name (same invariant as the upgrader)
_archive_stale_titles() generalized to (title, label); launch-supervisor's
spawn_supervisor() archives every older 'cc-ci-supervisor' session before
launching, so exactly one session carries the name. 11 historical supervisor
sessions archive-renamed live ('supervisor archive — <date> <time>').
2026-08-04 16:49:03 +00:00
autonomic-bot 0b6cc632d4 launch-upgrader: fix watchdog wrong-session resume + unique web-UI name invariant
Bug (2026-08-04 16:00): _session_id() sorted candidates on (s.time.created) which the
/session API rows DON'T carry — every key was 0, 'newest' degraded to server list
order, and the watchdog resumed the old giant unresumable session, kill_session()ing
the healthy fresh run mid-work.

Fixes:
- Pin the managed session id at launch/resume to LOG_DIR/.{SESSION}-session-id;
  _session_id() prefers the pin, validated via direct GET /session/<id> (the LIST is
  paginated ~100 rows, membership scans lie). Title lookup is only the fallback and
  now sorts on authoritative sqlite time_created.
- _archive_stale_titles() at start: every older top-level session titled
  cc-ci-upgrader is renamed 'upgrader archive — weekly <date>', so EXACTLY ONE
  session ever carries the canonical name in the opencode web UI (easy to find;
  finished runs stay browsable under archive names). 11 historical sessions
  renamed live today; the in-flight finisher pinned.
Verified live: _session_id() returns the pinned running session; tree-idle 0.0min
while subagents active. Full synthetic-stall watchdog confirmation queued post-run
(task #13).
2026-08-04 16:43:26 +00:00
autonomic-bot e8d7d09445 upstream(n8n): add 2.32.x / 2.33.x release-notes coverage
Covers the 2.32.4 -> 2.33.3 range for the 2026-08-03 /upgrade-all run:
2.33.0 minor features (admin-managed instance creds, workflow review
requests + publish/unpublish API, API deprecation of activate/deactivate
endpoints, optional N8N_SCHEDULER_MAX_ATTEMPTS env) and the 2.33.1-2.33.3
patches; notes 2.34.0 exists but is not this run's target.
2026-08-04 16:27:43 +00:00
autonomic-bot b2063b8235 upstream(mattermost-lts): 2026-08-04 re-check — 11.9.0 exists, operator-directed bump 2026-08-04 16:07:53 +00:00
autonomic-bot 0d37a891f7 opencode: subagents (agent.general) on deepseek-v4-pro via ZEN
Operator decision: main driving sessions stay glm-5.2; every task-tool subagent
runs opencode/deepseek-v4-pro (~3-5x cheaper, near-free cache hits) — the weekly
upgrade run burns most of its budget in subagents. Report generator stays glm-5.2
(launch-report.py default). Project-scoped config.
2026-08-04 15:54:55 +00:00
autonomic-bot cb20bea7cd recovery: give the incident tooling a permanent home (scripts/recovery/)
The 2026-08-03 cc-ci outage was recovered with ad-hoc tooling living in /tmp
(leftover from a PREVIOUS incident, half-evaporated). Promoted to the repo:
- scripts/recovery/hetzner.py — Hetzner API helper (status/actions/reboot/reset/
  power/rescue-on|off/console), knows cc-ci=134485294 + orchestrator=134487234 by
  name; token from HCLOUD_TOKEN or /srv/cc-ci/.hcloud-token (0600, never in git).
- scripts/recovery/hetzner-console.sh — shell-only VGA console: fresh console
  session -> websocat bridge -> vncdotool (venv auto-bootstrapped in ~/.cache).
  screenshot / key / type subcommands; encodes the reset-invalidates-session and
  single-connection-bridge gotchas.
- scripts/recovery/README.md — the condensed 10-minute unreachable-server drill,
  incl. the GRUB submenu 1>N ids + clear-grubenv-after-switch rule.
- hetzner-server-recovery skill: console/API sections now point at the repo tools
  instead of describing /tmp rebuilds.
Smoke-tested: hetzner.py cc-ci status OK.
2026-08-04 01:57:34 +00:00
autonomic-bot be7f8bc850 cctest: unify merge policy wording — recipe PRs are never agent-merged, both sides
Operator decision: no policy difference between cc-ci and recipe-maintainer. On
inspection ARM already agrees (recipe-upgrade-cron-all: 'PRs are reviewed and merged
manually by a human afterwards... never merges anything'; 'no human review in the
middle' = skip the mid-run plan confirmation only). Wrappers previously framed this
as a cc-ci override over ARM auto-merge flows — wrong reading; now stated as ONE
unified rule. /help conventions updated to match.
2026-08-04 01:40:36 +00:00
autonomic-bot 15e4e75681 cctest: consolidate onto the existing references/recipe-maintainer submodule
The repo already vendored ARM as the references/recipe-maintainer submodule (old
repo name recipe-maintainers/recipe-maintainer, pinned 460eba0). Rather than carry
two copies, drop the just-added vendor/ duplicate and:
- retarget references/recipe-maintainer to
  ssh://git.autonomic.zone/recipe-maintainers/autonomic-recipe-maintainer.git
  (same lineage — 460eba0 is an ancestor) and bump to latest acd5cfb, which also
  refreshes the parity-test SOURCE reference the tests cite.
- gen-cctest-skills.py + all 30 cctest-* wrappers + /help now point at
  references/recipe-maintainer.
- JOURNAL.md: pending session entries (server-update policy addendum, tests-update,
  orchestrator-update, upgrade-run notes).
2026-08-04 01:37:32 +00:00
autonomic-bot 49854472b8 skills: vendor autonomic-recipe-maintainer + expose all its skills as /cctest-*
One operator interface for both toolkits (operator decision 2026-08-04):
- vendor/autonomic-recipe-maintainer: ARM pinned as a submodule at acd5cfb (latest).
- scripts/gen-cctest-skills.py: generates a cctest-<name> wrapper pair
  (.opencode canonical + .claude thin) for every ARM skill — frontmatter carries ARM's
  own description tagged [recipe-maintainer/cctest]; body points at the canonical
  submodule SKILL.md, sets cwd/sandbox context, and states the policy overrides
  (auto-merge-style ARM flows need per-run operator opt-in; never touch cc-ci infra
  from an ARM skill; submodule is read-only here). Re-run after every submodule bump.
- 30 cctest-* wrappers generated.
- /help: cctest family section + situation-table rows + the cc-ci-vs-cctest rule of thumb.

cctest = the recipe-maintainer test server; these skills run against it + the ARM
sandbox, never against the cc-ci CI server/swarm.
2026-08-04 01:36:11 +00:00
autonomic-bot b462f1f7f1 skills: add /help — operator orientation over the skill roster
Enumerates the live skills directory at runtime (so new skills self-include), merges
with curated grouped descriptions (status / weekly maintenance / tests / cc-ci hosts /
enrollment / recovery), a what-do-you-want-to-do situation table, and the standing
conventions (PR-visibility+direct-merge, test-before-switch, never-weaken,
single-writer, swarm serialization). Read-only.
2026-08-04 01:22:18 +00:00
autonomic-bot 02cc2c29e2 upstream(lasuite-meet): release-notes sources 2026-08-04 01:21:43 +00:00
autonomic-bot 388e7f38c9 upstream(lasuite-docs): record v5.4.0 Bearer-auth removal + redis sidecar note 2026-08-04 00:13:50 +00:00
autonomic-bot 5ade783a50 skills: add /cc-ci-status — comprehensive read-only system status check
Seven check areas: weekly-run recency+outcome, report publishing, stale recipes/tests,
open recipe PRs (CVE-carrying PRs open >14d flagged high-priority, ready-to-merge PRs
listed as normal), server+orchestrator flake-update recency vs channel tip, host/service
health incl. the bridge !testme path (silent-401 stale-secret check from the 2026-08-03
finding), and maintained-set consistency. Ends with ALL HEALTHY or a prioritized findings
list, each mapped to the skill to invoke. Strictly read-only.
2026-08-03 23:12:49 +00:00
autonomic-bot 91179f872c skills: add /recipe-enroll — end-to-end enrollment of a new maintained recipe
Codifies the full path walked for the 2026-08-03 wordpress enrollment (cc-ci PR #14):
survey -> mirror create+sync from coopcloud -> author test suite (health floor +
non-vacuous recipe-specific tests incl. sec4.3 create-an-object round-trip, recipe-local
setup helper, PARITY.md) -> bridge POLL_REPOS -> used-recipes.md weekly row +
upstream/<recipe>.md registry -> full-suite-green verification with the new tests ->
bridge deploy via test-before-switch -> merge-on-green + report (PR-visibility policy).
Includes the traps hit live: creds injection over stdin for cc-ci-side helpers, fresh-
deploy wizard state in HEALTH_OK, repo-dev-shell ruff, swarm serialization, stale bridge
secret 401s silently dropping !testme.
2026-08-03 22:41:16 +00:00
autonomic-bot 6b3a3b1934 upstream(discourse): 2026.1→2026.7 ESR jump notes 2026-08-03 22:35:36 +00:00
autonomic-bot 74a57d37b3 recipes: enroll wordpress as weekly-maintained
used-recipes.md: wordpress weekly row. upstream/wordpress.md: registry entry
(wordpress official image + mariadb; install-wizard/XML-RPC test notes).
Mirror recipe-maintainers/wordpress created + synced (adcd0e9f). Test suite +
bridge enrollment: cc-ci PR #14 (verify + bridge deploy deferred until the
in-flight /upgrade-all completes).
2026-08-03 21:06:20 +00:00
autonomic-bot bab6481171 Merge pull request 'flake: bump nixpkgs (nixos-26.05, 2026-08-03)' (#1) from chore/orchestrator-flake-update-20260803 into main 2026-08-03 20:53:20 +00:00
96 changed files with 4417 additions and 92 deletions
+14
View File
@@ -0,0 +1,14 @@
---
name: cc-ci-status
description: Comprehensive read-only status check of the whole cc-ci system - how the recent weekly upgrade runs went and whether their reports published, which recipes/tests are stale, how long since the server + orchestrator host flake updates, open recipe PRs (flagging CVE-carrying PRs that have been open too long), host health (failed units, disk, timers, bridge/!testme path), ending with a verdict (ALL HEALTHY or a findings list) and recommended next steps mapped to the skills to invoke. Never changes anything - it only reads and reports. Invoke as /cc-ci-status.
---
# cc-ci-status (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cc-ci-status/SKILL.md`**
Read that file for the full procedure. This `.claude/skills/` copy is kept as a
thin pointer for Claude Code compatibility; opencode loads the canonical
definition from `.opencode/skills/` directly.
@@ -0,0 +1,13 @@
---
name: cctest-init-instance
description: "[recipe-maintainer/cctest] Deploy all maintained recipes to the active test instance from scratch (Wraps the autonomic-recipe-maintainer skill /init-instance; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-init-instance.)"
---
# cctest-init-instance (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-init-instance/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/init-instance/SKILL.md`.
+13
View File
@@ -0,0 +1,13 @@
---
name: cctest-intro
description: "[recipe-maintainer/cctest] Explain what this project is and how to get started (Wraps the autonomic-recipe-maintainer skill /intro; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-intro.)"
---
# cctest-intro (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-intro/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/intro/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-new-recipe-guide
description: "[recipe-maintainer/cctest] Guide for developing a new Co-op Cloud recipe from scratch (Wraps the autonomic-recipe-maintainer skill /new-recipe-guide; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-new-recipe-guide.)"
---
# cctest-new-recipe-guide (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-new-recipe-guide/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/new-recipe-guide/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-opencode-sync
description: "[recipe-maintainer/cctest] Ensure every Claude skill has a corresponding OpenCode skill alias (Wraps the autonomic-recipe-maintainer skill /opencode-sync; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-opencode-sync.)"
---
# cctest-opencode-sync (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-opencode-sync/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/opencode-sync/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-check
description: "[recipe-maintainer/cctest] Fetch a Co-op Cloud recipe and check for available upgrades (Wraps the autonomic-recipe-maintainer skill /recipe-check; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-check.)"
---
# cctest-recipe-check (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-check/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-check/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-create-pr
description: "[recipe-maintainer/cctest] Push local recipe commits to git.autonomic.zone and open a PR against an upstream-synced main branch (Wraps the autonomic-recipe-maintainer skill /recipe-create-pr; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-create-pr.)"
---
# cctest-recipe-create-pr (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-create-pr/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-create-pr/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-deploy
description: "[recipe-maintainer/cctest] Deploy the local recipe checkout to the test instance (Wraps the autonomic-recipe-maintainer skill /recipe-deploy; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-deploy.)"
---
# cctest-recipe-deploy (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-deploy/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-deploy/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-guidelines
description: "[recipe-maintainer/cctest] Guidelines for all recipe operations including local change preservation, version format, and secrets (Wraps the autonomic-recipe-maintainer skill /recipe-guidelines; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-guidelines.)"
---
# cctest-recipe-guidelines (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-guidelines/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-guidelines/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-init
description: "[recipe-maintainer/cctest] Create a new test instance and recipe-info for a recipe (Wraps the autonomic-recipe-maintainer skill /recipe-init; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-init.)"
---
# cctest-recipe-init (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-init/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-init/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-logging
description: "[recipe-maintainer/cctest] Logging instructions for maintaining detailed operation logs in the logs directory (Wraps the autonomic-recipe-maintainer skill /recipe-logging; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-logging.)"
---
# cctest-recipe-logging (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-logging/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-logging/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-new-tag
description: "[recipe-maintainer/cctest] Bump the recipe version and create an annotated git tag (Wraps the autonomic-recipe-maintainer skill /recipe-new-tag; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-new-tag.)"
---
# cctest-recipe-new-tag (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-new-tag/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-new-tag/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-overview
description: "[recipe-maintainer/cctest] Check all maintained recipes and recommend what to upgrade (Wraps the autonomic-recipe-maintainer skill /recipe-overview; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-overview.)"
---
# cctest-recipe-overview (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-overview/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-overview/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-review
description: "[recipe-maintainer/cctest] Review a recipe for Co-op Cloud best practices (Wraps the autonomic-recipe-maintainer skill /recipe-review; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-review.)"
---
# cctest-recipe-review (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-review/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-review/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-test-all
description: "[recipe-maintainer/cctest] Run tests for all maintained recipes, deploying each one at a time (Wraps the autonomic-recipe-maintainer skill /recipe-test-all; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-all.)"
---
# cctest-recipe-test-all (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-test-all/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-test-all/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-test-backup
description: "[recipe-maintainer/cctest] Test backing up and restoring a recipe's test instance (Wraps the autonomic-recipe-maintainer skill /recipe-test-backup; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-backup.)"
---
# cctest-recipe-test-backup (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-test-backup/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-test-backup/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-test-new
description: "[recipe-maintainer/cctest] Test a recipe's first-time initialization from scratch (Wraps the autonomic-recipe-maintainer skill /recipe-test-new; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-new.)"
---
# cctest-recipe-test-new (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-test-new/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-test-new/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-test-update
description: "[recipe-maintainer/cctest] Test upgrading a recipe's test instance using abra app deploy (Wraps the autonomic-recipe-maintainer skill /recipe-test-update; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-update.)"
---
# cctest-recipe-test-update (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-test-update/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-test-update/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-test
description: "[recipe-maintainer/cctest] Run all tests for a Co-op Cloud recipe (Wraps the autonomic-recipe-maintainer skill /recipe-test; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test.)"
---
# cctest-recipe-test (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-test/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-test/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-upgrade-apply
description: "[recipe-maintainer/cctest] Execute a planned recipe upgrade — apply changes, deploy, test, commit/tag (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-apply; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-apply.)"
---
# cctest-recipe-upgrade-apply (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-upgrade-apply/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-upgrade-apply/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-upgrade-cron-all
description: "[recipe-maintainer/cctest] Autonomous weekly upgrade run — overview all recipes, upgrade each end-to-end (sequentially by default, parallel with --parallel), open PRs (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-cron-all; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-cron-all.)"
---
# cctest-recipe-upgrade-cron-all (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-upgrade-cron-all/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-upgrade-cron-all/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-upgrade-full
description: "[recipe-maintainer/cctest] Plan and apply a recipe upgrade end-to-end, no human review in the middle (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-full; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-full.)"
---
# cctest-recipe-upgrade-full (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-upgrade-full/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-upgrade-full/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-upgrade-plan
description: "[recipe-maintainer/cctest] Create a detailed upgrade plan for a recipe (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-plan; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-plan.)"
---
# cctest-recipe-upgrade-plan (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-upgrade-plan/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-upgrade-plan/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-recipe-upstream
description: "[recipe-maintainer/cctest] From a git.autonomic.zone review-PR URL, fetch the branch + tag locally and emit the commands to open the upstream PR on git.coopcloud.tech (Wraps the autonomic-recipe-maintainer skill /recipe-upstream; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upstream.)"
---
# cctest-recipe-upstream (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-recipe-upstream/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/recipe-upstream/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-setup-sandbox
description: "[recipe-maintainer/cctest] Guide for setting up a sandboxed environment to run the agent with recipe-maintainer (Wraps the autonomic-recipe-maintainer skill /setup-sandbox; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-setup-sandbox.)"
---
# cctest-setup-sandbox (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-setup-sandbox/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/setup-sandbox/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-switch-default-instance
description: "[recipe-maintainer/cctest] Switch the default test instance (b1cc or t1cc) for all recipe operations (Wraps the autonomic-recipe-maintainer skill /switch-default-instance; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-switch-default-instance.)"
---
# cctest-switch-default-instance (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-switch-default-instance/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/switch-default-instance/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-sync-secrets
description: "[recipe-maintainer/cctest] Sync Docker secrets from the test server into recipe-info/testsecrets/ (Wraps the autonomic-recipe-maintainer skill /sync-secrets; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-sync-secrets.)"
---
# cctest-sync-secrets (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-sync-secrets/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/sync-secrets/SKILL.md`.
+13
View File
@@ -0,0 +1,13 @@
---
name: cctest-t1cc-start
description: "[recipe-maintainer/cctest] Provision the t1cc DigitalOcean test server and deploy Traefik (Wraps the autonomic-recipe-maintainer skill /t1cc-start; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-t1cc-start.)"
---
# cctest-t1cc-start (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-t1cc-start/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/t1cc-start/SKILL.md`.
+13
View File
@@ -0,0 +1,13 @@
---
name: cctest-t1cc-stop
description: "[recipe-maintainer/cctest] Destroy the t1cc DigitalOcean test server via terraform (Wraps the autonomic-recipe-maintainer skill /t1cc-stop; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-t1cc-stop.)"
---
# cctest-t1cc-stop (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-t1cc-stop/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/t1cc-stop/SKILL.md`.
@@ -0,0 +1,13 @@
---
name: cctest-test-context-reset
description: "[recipe-maintainer/cctest] Undeploy all apps from the test server except traefik (Wraps the autonomic-recipe-maintainer skill /test-context-reset; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-test-context-reset.)"
---
# cctest-test-context-reset (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-test-context-reset/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/test-context-reset/SKILL.md`.
+13
View File
@@ -0,0 +1,13 @@
---
name: cctest-test-setup
description: "[recipe-maintainer/cctest] Verify the test environment is configured correctly (Wraps the autonomic-recipe-maintainer skill /test-setup; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-test-setup.)"
---
# cctest-test-setup (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/cctest-test-setup/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/test-setup/SKILL.md`.
+14
View File
@@ -0,0 +1,14 @@
---
name: help
description: Operator orientation - lists every skill available on this orchestrator with what it does and when to reach for it, grouped by purpose (status, weekly maintenance, host updates, tests, enrollment, recovery, reporting), plus a "what do you want to do?" guide for common situations. Read-only. Invoke as /help.
---
# help (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/help/SKILL.md`**
Read that file for the full content. This `.claude/skills/` copy is kept as a
thin pointer for Claude Code compatibility; opencode loads the canonical
definition from `.opencode/skills/` directly.
+21 -48
View File
@@ -57,60 +57,33 @@ tailscale ping -c 3 <host-alias>
If the host still does not come back, continue.
## 3. Request the Hetzner console
## 3. Use the repo recovery tools (permanent home — do NOT rebuild these in /tmp)
Request a remote console session:
The API and console tooling live in **`scripts/recovery/`** (see its README for the condensed
10-minute drill, proven 2026-08-03):
```bash
curl -s -X POST \
-H "Authorization: Bearer ${HCLOUD_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.hetzner.cloud/v1/servers/<SERVER_ID>/actions/request_console"
# API: status / actions / reboot / reset / poweroff / poweron / rescue-on / rescue-off / console
python3 /srv/cc-ci-orch/scripts/recovery/hetzner.py cc-ci status
python3 /srv/cc-ci-orch/scripts/recovery/hetzner.py cc-ci actions 10
# Shell-only console access (fresh console session + websocat bridge + vncdotool,
# venv auto-bootstrapped at ~/.cache/hetzner-console-venv):
bash /srv/cc-ci-orch/scripts/recovery/hetzner-console.sh cc-ci screenshot /tmp/console.png
bash /srv/cc-ci-orch/scripts/recovery/hetzner-console.sh cc-ci key Down Down Return
```
The API returns:
Known server names: `cc-ci` (134485294), `orchestrator` (134487234). Token: `HCLOUD_TOKEN`
env or `/srv/cc-ci/.hcloud-token` (0600, not in git; prefer per-incident revocable tokens,
and never paste tokens into a chat transcript).
- `wss_url`
- `password`
If you have a browser, use the Hetzner console directly.
If you only have shell access, you can still drive it locally because the console is **raw VNC over
websocket**.
## 4. Shell-only console access (websocket VNC bridge)
Install temporary tools:
```bash
nix shell nixpkgs#websocat -c websocat --version
python3 -m venv /tmp/opencode/hetzner-console-venv
/tmp/opencode/hetzner-console-venv/bin/pip install --disable-pip-version-check pillow websocket-client vncdotool
```
Bridge the websocket console to a local VNC TCP port:
```bash
nohup nix shell nixpkgs#websocat -c \
websocat -b -E tcp-l:127.0.0.1:5905 '<WSS_URL>' \
>/tmp/opencode/hetzner-websockify.log 2>&1 &
```
Validate the RFB banner:
```bash
python3 - <<'PY'
import socket
s=socket.socket(); s.settimeout(5); s.connect(('127.0.0.1',5905))
print(repr(s.recv(32)))
PY
```
Expected:
```text
b'RFB 003.008\n'
```
Notes that used to cost time:
- Each console command requests a **fresh** console session — old sessions die on hard reset,
and the websocat bridge is single-connection anyway.
- A GRUB one-shot/default for a NixOS generation needs the **submenu id `1>N`** (top level:
0 = default entry, 1 = the "All configurations" submenu). A bare index silently falls back
to the default entry. Clear any grubenv override after the next `switch` regenerates
grub.cfg — indices shift.
Capture a screenshot from the console:
+14
View File
@@ -0,0 +1,14 @@
---
name: recipe-enroll
description: Add a NEW recipe to cc-ci's maintained set, end to end — create + sync the recipe-maintainers mirror from coopcloud upstream, author a real test suite (health floor + non-vacuous recipe-specific tests incl. a create-an-object round-trip), enroll it in the !testme bridge (POLL_REPOS) and the weekly /upgrade-all inventory (used-recipes.md weekly row + upstream registry), then VERIFY the whole thing: full harness suite green with the new tests, bridge deployed (test-before-switch) and healthy. Opens the cc-ci PR for visibility and merges it directly once verification is green (the skill invocation is the authorization); the report lists merged PR links + what changed. Invoke as /recipe-enroll <recipe>.
---
# recipe-enroll (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/recipe-enroll/SKILL.md`**
Read that file for the full procedure. This `.claude/skills/` copy is kept as a
thin pointer for Claude Code compatibility; opencode loads the canonical
definition from `.opencode/skills/` directly.
+29
View File
@@ -37,6 +37,35 @@ keeps every weekly edition looking the same regardless of which model writes the
- **Security analysis.** Scan the per-recipe `upgrade_notes_md` + the summary (and use your own
knowledge of the version bumps) for upgrades that fix **CVEs / security issues**. For each recipe,
**count the CVEs** the PR fixes — this drives both the table's `cve` column and the priority sort.
- **ADDITIONALLY, and never instead:** each per-recipe log carries an `### Advisory scan
(deterministic pre-step)` block (from `cc-ci-plan/advisory-scan.py` — GitHub Security
Advisories + vendor security pages + OSV, with severities and fixed-in versions). Treat its
CVE list as a **further source** and report the **UNION** of it and what you found by reading.
Its entries are machine-derived with advisory IDs, so prefer them for CVE ids / severities /
fixed-in versions, and cite the GHSA where present in the Security Bulletin. If the block
lists **failed sources**, the count is **not** authoritative: render the cve cell as `?`
(unknown), never `none` — a blank that reads as "clean" is exactly how two CVSS-9.8 gitea
RCEs were reported as "none" on 2026-08-07.
- **`?` must stay RARE — it means "we tried and could not tell", not "we didn't look".** Use it
ONLY when a scan ran and reported genuinely failed sources, **or when the scan block says
COUNT UNKNOWN**. In that case the scan's `0` means *not determined*: publish `?` and say so in
the notes; publishing `0` would assert a clean bill of health nothing supports. Note a
**version-scheme change is no longer a reason for `?`** — the scan resolves semver→calver jumps
(discourse 3.5.3 → 2026.7.1) by falling back to advisory publish dates and reports a real number.
- **A count with undetermined advisories is a FLOOR.** If the scan block says N advisories
"could NOT be judged", report the number but say in the notes that it is a floor — those
advisories are neither fixed nor safe, they are unmeasured. Never round them away.
- **Counts span every image, each judged by its own window.** A scan block lists one line per
image with its version range and classification method; the headline is their union. So a
recipe's count legitimately includes **sidecar** CVEs (discourse's 128 = 123 app + 5 redis).
When a sidecar contributes a critical/high, name the image in the bulletin — CVE-2025-49844 is
a redis flaw, not a discourse one, and an operator reading "discourse" needs to know that.
(The scan headline itself now says `UNKNOWN` rather than a number in that case.)
In particular: a recipe with **no upgrade this run** (up-to-date/skipped) has nothing an
upgrade could have fixed — report `0`, not `?`. A recipe with a clean scan reports its number (including `0`). Benign notes in a scan
block (`no-advisories-published`, `skipped: template URL`) are NOT failures and must not
trigger `?`. If you find yourself rendering `?` for many recipes, that is a bug to report in
the Addendum, not a normal outcome.
Anything **critical/high** also gets a `security` bulletin entry (recipe · CVE id(s) + severity ·
what it fixes · PR link); be specific about severity and what's exposed if not merged.
- **Lead — ONE short paragraph.** A tight, concrete opener in opus's voice: fleet state in a sentence
+50
View File
@@ -157,6 +157,56 @@ On cc-ci's `~/.abra/recipes/<recipe>` (wrap every abra call per the pseudo-TTY b
`open-recipe-pr.sh`). Do **not** push to upstream; the version bump + tag + publish are the operator's
final `abra recipe release` step.
### 2a. Advisory scan (deterministic; ADDITIVE — run it, never skip it)
Run the deterministic scanner for the exact upgrade window and **paste its markdown block verbatim
into the per-recipe log**:
```
python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py <recipe> --from <old-app-version> --to <new-app-version> \
[--image <name>=<old>:<new>]...
```
**Pass an `--image` for EVERY sidecar you upgraded** (redis, postgres, nginx …), not just the app —
each image is judged by its own versions, and an image you don't name is not counted at all. Repeat
the flag for each one and pass them **all in a single call** (the count is a union across images).
e.g. discourse moving app 3.5.3→2026.7.1 *and* redis 7.4→8.10:
```
... --from 3.5.3 --to 2026.7.1 --image redis=7.4:8.10
```
→ 140 CVEs (123 app + 17 redis), where the redis seventeen include a **critical** (CVE-2025-49844)
that is invisible if the sidecar is left out. `<name>` is substring-matched against source repo names,
so make it specific enough to hit exactly one.
**If the scan reports advisories it could NOT judge, re-run it with `--adjudicate`.** That is a second
pass: it collects each open case's full evidence (advisory prose, references, affected ranges, every
release naming the CVE) and asks YOU to decide FIXED / NOT-FIXED / STILL-UNKNOWN with a reason citing
that evidence. The deterministic number is a **floor** — add every FIXED to the count. Say
STILL-UNKNOWN rather than inferring from memory, and never record an undecided CVE as unaffected.
It also shows what pass 1 already decided; if a verdict looks wrong given its evidence, say so.
It queries, per recipe: the **GitHub Security Advisories API** for every source repo in
`cc-ci-plan/upstream/<recipe>.md` (CVE + GHSA + severity + vulnerable/patched ranges, so
"fixed by THIS upgrade" is computed, not guessed), every **vendor release/security URL** in that
registry (fetched + regex-scanned for CVE ids), and **OSV** where a package mapping exists.
**This does NOT replace your own release-note reading — it is an ADDITIONAL evidence source.** Do
exactly what you did before, then union the two: the CVE count you report is the union of the CVEs
you found in the notes and the CVEs the scan found. Never let the scan lower a count you established
by reading.
Why it exists: gitea 1.27.1 fixed CVE-2026-60004 and CVE-2026-59774 (both CVSS 9.8). Both are named
only in the vendor's blog security section — the GitHub *release notes* mention neither — so the
release-note read found one unrelated minor item and the weekly report printed a CVE count of "1",
then "none". Advisory databases lagged too (OSV 404'd on both; NVD's API had neither by CPE, id, or
keyword), which is why the GitHub advisory API and the vendor pages lead.
If the scanner reports **failed sources**, say so in the log — an incomplete scan must not read as
a clean one. If a vendor publishes security notes at a URL the registry lacks (gitea's
`blog.gitea.com`), **add it to `cc-ci-plan/upstream/<recipe>.md`** so the next scan sees it.
### 2b. Direct deploy + inspect on cc-ci — live feedback BEFORE CI (recipe-maintainer style)
Before opening the PR / running `!testme`, deploy the WIP recipe **directly** on the cc-ci server and
watch it converge — the way recipe-maintainer tests on `cctest`. This gives you **live logs +
@@ -102,6 +102,27 @@ if [ "${MODE}" != "--reconcile-only" ]; then
DIVERGED=$(git log --oneline origin/main..HEAD 2>/dev/null || true)
[ -n "${DIVERGED}" ] || { echo "ERROR: HEAD has no commits beyond origin/main. Nothing to PR."; exit 1; }
LATEST_MSG=$(git log -1 --pretty=%s HEAD)
# --- Keep the LOCAL work current with the freshly-synced upstream main (anti-drift) ---
# The push path below grafts `HEAD^{tree}` WHOLESALE onto the PR branch. If this checkout is not
# based on the upstream main we just synced, every upstream change made since the branch was cut
# is silently ABSENT from the pushed tree — the PR (and the CI that verifies it) then describes a
# tree that will never deploy. Observed on gitea PR #5 (2026-08-10): its base predated upstream's
# "BREAKING CHANGE: remove forgejo", so `!testme` verified a forgejo-bearing tree while main had
# dropped it. Merge upstream in FIRST, and fail loudly rather than paper over a conflict.
if ! git merge-base --is-ancestor "${NEW_MAIN_SHA}" HEAD; then
echo "→ Local work predates upstream main (${NEW_MAIN_SHA:0:8}) — merging upstream in first..."
if ! GIT_AUTHOR_NAME="${GITEA_USERNAME}" GIT_AUTHOR_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \
GIT_COMMITTER_NAME="${GITEA_USERNAME}" GIT_COMMITTER_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \
git merge --no-edit "${NEW_MAIN_SHA}" >/dev/null 2>&1; then
git merge --abort 2>/dev/null || true
echo "ERROR: cannot auto-merge upstream main (${NEW_MAIN_SHA:0:8}) into the local ${RECIPE} work."
echo " Upstream changed files this upgrade also touches. Resolve by hand in"
echo " ${RECIPE_DIR}, then re-run. Refusing to push a tree that omits upstream changes."
exit 1
fi
echo " ✓ upstream merged into the local work"
fi
fi
# --- Reconcile open PRs against the freshly-synced upstream main ---
@@ -165,9 +186,19 @@ if git rev-parse --verify --quiet "refs/remotes/gitea/${BRANCH}" >/dev/null; the
if [ "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${EXIST_TIP}^{tree}")" ]; then
echo "→ '${BRANCH}' already has this exact tree — nothing new to push (will still re-test)."
else
# Parent the new commit on the branch tip AND (when the branch predates it) on upstream main, so
# the recorded HISTORY matches the tree we are pushing. Without the second parent the merge-base
# stays stale: git would later treat upstream's post-branch changes as "removed by this PR" and a
# merge could revert them (the gitea #5 / forgejo-removal drift, 2026-08-10). No force-push: this
# is still a fast-forward from the branch tip.
EXTRA_PARENT=()
if ! git merge-base --is-ancestor "${NEW_MAIN_SHA}" "${EXIST_TIP}"; then
EXTRA_PARENT=(-p "${NEW_MAIN_SHA}")
echo " (also parenting on upstream main ${NEW_MAIN_SHA:0:8} — branch predated it)"
fi
ONTOP=$(GIT_AUTHOR_NAME="${GITEA_USERNAME}" GIT_AUTHOR_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \
GIT_COMMITTER_NAME="${GITEA_USERNAME}" GIT_COMMITTER_EMAIL="${GITEA_USERNAME}@git.autonomic.zone" \
git commit-tree "$(git rev-parse 'HEAD^{tree}')" -p "${EXIST_TIP}" -m "${LATEST_MSG}")
git commit-tree "$(git rev-parse 'HEAD^{tree}')" -p "${EXIST_TIP}" "${EXTRA_PARENT[@]}" -m "${LATEST_MSG}")
echo "→ Adding the new work on top of '${BRANCH}' (fast-forward, no force-push)..."
git push gitea "${ONTOP}:refs/heads/${BRANCH}"
fi
+4
View File
@@ -28,3 +28,7 @@ master-age.txt
# Python bytecode cache
__pycache__/
*.pyc
# Local API tokens — never committed (advisory-scan / hetzner recovery)
.github-token
.hcloud-token
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "references/recipe-maintainer"]
path = references/recipe-maintainer
url = https://git.autonomic.zone/recipe-maintainers/recipe-maintainer
url = ssh://git@git.autonomic.zone:2222/recipe-maintainers/autonomic-recipe-maintainer.git
+119
View File
@@ -0,0 +1,119 @@
---
name: cc-ci-status
description: Comprehensive read-only status check of the whole cc-ci system - how the recent weekly upgrade runs went and whether their reports published, which recipes/tests are stale, how long since the server + orchestrator host flake updates, open recipe PRs (flagging CVE-carrying PRs that have been open too long), host health (failed units, disk, timers, bridge/!testme path), ending with a verdict (ALL HEALTHY or a findings list) and recommended next steps mapped to the skills to invoke. Never changes anything - it only reads and reports. Invoke as /cc-ci-status.
---
# cc-ci-status
One comprehensive, **read-only** status pass over the cc-ci system. Output ends with either
**`ALL HEALTHY`** or a prioritized findings list, each finding paired with the skill to invoke.
Nothing here mutates state — no restarts, no deploys, no merges.
## Checks (run all; collect findings, don't stop at the first)
### 1. Weekly upgrade runs — recency + outcome
```
ls -t /srv/cc-ci/.cc-ci-logs/upgrades/upgrade-all-*.md | head -3
head -20 <latest> # the Summary block: Considered/green/stale/Failed/Skipped
systemctl list-timers cc-ci-upgrade-all.timer --no-pager | head -3
```
- **Overdue** if the newest report is >8 days old, or the timer is inactive/missing → recommend
`systemctl start cc-ci-upgrade-all.service` (or investigate the timer) / `/upgrade-all`.
- **Failed entries** in the latest report → recommend `/recipe-upgrade <recipe>` per entry (or
`/ci-test-review` if the failure is harness-side).
- A run currently in flight (tmux `cc-ci-upgrader` session live) is NOT a finding — report it as
in-progress and skip staleness checks that depend on its output.
### 2. Report publishing — report.ci.commoninternet.net
```
curl -s -o /dev/null -w '%{http_code}' https://report.ci.commoninternet.net/
ls -t /var/lib/cc-ci-reports/week-*.html | head -2 # via ssh cc-ci
```
- Index must be 200 and there must be a `week-*.html` at least as new as the last **completed**
upgrade run (a completed run without a matching page = report generation broke → recommend
`/recipe-report` / inspect `launch-report.py`).
### 3. Stale recipes / stale tests
- Latest report's "PRs where a test looks stale" section + carry-over notes.
- Any entries → recommend `/cc-ci-tests-update` (fleet) or `/recipe-upgrade <recipe>
--with-tests` (single).
- Also check `/srv/cc-ci/.cc-ci-logs/tests-update-*.md` recency — if stale tests were reported
weeks ago and no tests-update run since, say so.
### 4. Open recipe PRs — especially CVE-carrying ones that linger
Enumerate open PRs across `recipe-maintainers/*` (Gitea API, creds in `/srv/cc-ci/.testenv`):
```
GET /repos/recipe-maintainers/<repo>/pulls?state=open # repos = the used-recipes.md inventory + cc-ci
```
For each open PR: age (now created_at), and whether the PR title/body/report row mentions
**CVE** / security patch.
- **CVE-carrying PR open >14 days** → HIGH-priority finding: name the PR, the CVE context, and
the blocker (commonly a stale test — check the report row) → recommend the unblocking skill
(`/cc-ci-tests-update`) plus "operator: review + merge <PR>".
- Non-CVE PRs open >30 days → low-priority note ("operator review backlog: N PRs").
- Verified-green PRs awaiting operator merge are normal — list them as "ready to merge", not
as failures.
### 5. Server + orchestrator host update recency
For BOTH hosts:
```
# cc-ci server: ssh cc-ci 'nixos-version; cd /root/cc-ci-deploy && nix flake metadata --json' (or builder-clone)
# orchestrator: nixos-version; cd /srv/cc-ci-orch && nix flake metadata --json
git ls-remote https://github.com/NixOS/nixpkgs <channel> # current tip
ls -t /srv/cc-ci-orch/.cc-ci-logs/server-update-*.md /srv/cc-ci-orch/.cc-ci-logs/orchestrator-update-*.md | head -2
```
- Report: days since last update log + how far the running nixpkgs rev lags the channel tip.
- **Lagging >30 days** (or a NixOS release behind) → recommend `/cc-ci-server-update` /
`/cc-ci-orchestrator-update`.
### 6. Host + service health (both machines)
```
ssh cc-ci 'systemctl --failed --no-legend; df -h / | tail -1; docker service ls --format "{{.Name}} {{.Replicas}}"'
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 →
HIGH: recommend `hetzner-server-recovery`.
- **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) →
recommend refreshing the bridge secret + redeploy (test-before-switch).
### 7. Maintained-set consistency (quick)
- Every `weekly` row in `used-recipes.md` has `tests/<recipe>/` on cc-ci AND a
`recipe-maintainers/<recipe>` mirror AND is in bridge `POLL_REPOS`; mismatches → recommend
finishing enrollment (`/recipe-enroll <recipe>` covers all touchpoints).
## Output format
```
# cc-ci status — <date>
## Verdict: ALL HEALTHY | N findings (M high-priority)
## Weekly upgrades: <last run date + one-line outcome; next timer firing>
## Report site: <ok/broken + latest page>
## Stale tests: <none | list>
## Open PRs: <count; CVE-carrying + age flagged FIRST; ready-to-merge list>
## Host updates: server <rev, N days behind tip> · orchestrator <rev, N days>
## Health: server <failed/disk/services> · orchestrator <failed/disk/sessions> · bridge <ok/401s>
## Recommended next steps
1. <finding> → /<skill> (or operator action)
```
`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
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).
## Guardrails
- **Read-only.** This skill diagnoses and recommends; it invokes nothing and changes nothing.
- Use the pseudo-TTY wrap for any abra call; plain ssh for everything else.
- Don't double-count: a finding that explains another (bridge 401 → !testme "failures") gets
reported once, at the root cause.
@@ -0,0 +1,26 @@
---
name: cctest-init-instance
description: "[recipe-maintainer/cctest] Deploy all maintained recipes to the active test instance from scratch (Wraps the autonomic-recipe-maintainer skill /init-instance; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-init-instance.)"
---
# cctest-init-instance (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/init-instance/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
+26
View File
@@ -0,0 +1,26 @@
---
name: cctest-intro
description: "[recipe-maintainer/cctest] Explain what this project is and how to get started (Wraps the autonomic-recipe-maintainer skill /intro; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-intro.)"
---
# cctest-intro (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/intro/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-new-recipe-guide
description: "[recipe-maintainer/cctest] Guide for developing a new Co-op Cloud recipe from scratch (Wraps the autonomic-recipe-maintainer skill /new-recipe-guide; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-new-recipe-guide.)"
---
# cctest-new-recipe-guide (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/new-recipe-guide/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-opencode-sync
description: "[recipe-maintainer/cctest] Ensure every Claude skill has a corresponding OpenCode skill alias (Wraps the autonomic-recipe-maintainer skill /opencode-sync; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-opencode-sync.)"
---
# cctest-opencode-sync (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/opencode-sync/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-check
description: "[recipe-maintainer/cctest] Fetch a Co-op Cloud recipe and check for available upgrades (Wraps the autonomic-recipe-maintainer skill /recipe-check; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-check.)"
---
# cctest-recipe-check (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-check/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-create-pr
description: "[recipe-maintainer/cctest] Push local recipe commits to git.autonomic.zone and open a PR against an upstream-synced main branch (Wraps the autonomic-recipe-maintainer skill /recipe-create-pr; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-create-pr.)"
---
# cctest-recipe-create-pr (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-create-pr/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-deploy
description: "[recipe-maintainer/cctest] Deploy the local recipe checkout to the test instance (Wraps the autonomic-recipe-maintainer skill /recipe-deploy; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-deploy.)"
---
# cctest-recipe-deploy (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-deploy/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-guidelines
description: "[recipe-maintainer/cctest] Guidelines for all recipe operations including local change preservation, version format, and secrets (Wraps the autonomic-recipe-maintainer skill /recipe-guidelines; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-guidelines.)"
---
# cctest-recipe-guidelines (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-guidelines/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-init
description: "[recipe-maintainer/cctest] Create a new test instance and recipe-info for a recipe (Wraps the autonomic-recipe-maintainer skill /recipe-init; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-init.)"
---
# cctest-recipe-init (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-init/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-logging
description: "[recipe-maintainer/cctest] Logging instructions for maintaining detailed operation logs in the logs directory (Wraps the autonomic-recipe-maintainer skill /recipe-logging; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-logging.)"
---
# cctest-recipe-logging (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-logging/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-new-tag
description: "[recipe-maintainer/cctest] Bump the recipe version and create an annotated git tag (Wraps the autonomic-recipe-maintainer skill /recipe-new-tag; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-new-tag.)"
---
# cctest-recipe-new-tag (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-new-tag/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-overview
description: "[recipe-maintainer/cctest] Check all maintained recipes and recommend what to upgrade (Wraps the autonomic-recipe-maintainer skill /recipe-overview; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-overview.)"
---
# cctest-recipe-overview (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-overview/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-review
description: "[recipe-maintainer/cctest] Review a recipe for Co-op Cloud best practices (Wraps the autonomic-recipe-maintainer skill /recipe-review; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-review.)"
---
# cctest-recipe-review (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-review/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-test-all
description: "[recipe-maintainer/cctest] Run tests for all maintained recipes, deploying each one at a time (Wraps the autonomic-recipe-maintainer skill /recipe-test-all; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-all.)"
---
# cctest-recipe-test-all (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-test-all/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-test-backup
description: "[recipe-maintainer/cctest] Test backing up and restoring a recipe's test instance (Wraps the autonomic-recipe-maintainer skill /recipe-test-backup; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-backup.)"
---
# cctest-recipe-test-backup (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-test-backup/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-test-new
description: "[recipe-maintainer/cctest] Test a recipe's first-time initialization from scratch (Wraps the autonomic-recipe-maintainer skill /recipe-test-new; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-new.)"
---
# cctest-recipe-test-new (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-test-new/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-test-update
description: "[recipe-maintainer/cctest] Test upgrading a recipe's test instance using abra app deploy (Wraps the autonomic-recipe-maintainer skill /recipe-test-update; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test-update.)"
---
# cctest-recipe-test-update (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-test-update/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-test
description: "[recipe-maintainer/cctest] Run all tests for a Co-op Cloud recipe (Wraps the autonomic-recipe-maintainer skill /recipe-test; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-test.)"
---
# cctest-recipe-test (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-test/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-upgrade-apply
description: "[recipe-maintainer/cctest] Execute a planned recipe upgrade — apply changes, deploy, test, commit/tag (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-apply; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-apply.)"
---
# cctest-recipe-upgrade-apply (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-upgrade-apply/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-upgrade-cron-all
description: "[recipe-maintainer/cctest] Autonomous weekly upgrade run — overview all recipes, upgrade each end-to-end (sequentially by default, parallel with --parallel), open PRs (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-cron-all; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-cron-all.)"
---
# cctest-recipe-upgrade-cron-all (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-upgrade-cron-all/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-upgrade-full
description: "[recipe-maintainer/cctest] Plan and apply a recipe upgrade end-to-end, no human review in the middle (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-full; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-full.)"
---
# cctest-recipe-upgrade-full (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-upgrade-full/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-upgrade-plan
description: "[recipe-maintainer/cctest] Create a detailed upgrade plan for a recipe (Wraps the autonomic-recipe-maintainer skill /recipe-upgrade-plan; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upgrade-plan.)"
---
# cctest-recipe-upgrade-plan (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-upgrade-plan/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-recipe-upstream
description: "[recipe-maintainer/cctest] From a git.autonomic.zone review-PR URL, fetch the branch + tag locally and emit the commands to open the upstream PR on git.coopcloud.tech (Wraps the autonomic-recipe-maintainer skill /recipe-upstream; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-recipe-upstream.)"
---
# cctest-recipe-upstream (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/recipe-upstream/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-setup-sandbox
description: "[recipe-maintainer/cctest] Guide for setting up a sandboxed environment to run the agent with recipe-maintainer (Wraps the autonomic-recipe-maintainer skill /setup-sandbox; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-setup-sandbox.)"
---
# cctest-setup-sandbox (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/setup-sandbox/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-switch-default-instance
description: "[recipe-maintainer/cctest] Switch the default test instance (b1cc or t1cc) for all recipe operations (Wraps the autonomic-recipe-maintainer skill /switch-default-instance; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-switch-default-instance.)"
---
# cctest-switch-default-instance (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/switch-default-instance/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-sync-secrets
description: "[recipe-maintainer/cctest] Sync Docker secrets from the test server into recipe-info/testsecrets/ (Wraps the autonomic-recipe-maintainer skill /sync-secrets; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-sync-secrets.)"
---
# cctest-sync-secrets (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/sync-secrets/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-t1cc-start
description: "[recipe-maintainer/cctest] Provision the t1cc DigitalOcean test server and deploy Traefik (Wraps the autonomic-recipe-maintainer skill /t1cc-start; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-t1cc-start.)"
---
# cctest-t1cc-start (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/t1cc-start/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-t1cc-stop
description: "[recipe-maintainer/cctest] Destroy the t1cc DigitalOcean test server via terraform (Wraps the autonomic-recipe-maintainer skill /t1cc-stop; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-t1cc-stop.)"
---
# cctest-t1cc-stop (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/t1cc-stop/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-test-context-reset
description: "[recipe-maintainer/cctest] Undeploy all apps from the test server except traefik (Wraps the autonomic-recipe-maintainer skill /test-context-reset; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-test-context-reset.)"
---
# cctest-test-context-reset (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/test-context-reset/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
@@ -0,0 +1,26 @@
---
name: cctest-test-setup
description: "[recipe-maintainer/cctest] Verify the test environment is configured correctly (Wraps the autonomic-recipe-maintainer skill /test-setup; runs against the cctest test server + ARM sandbox, not cc-ci. Invoke as /cctest-test-setup.)"
---
# cctest-test-setup (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/test-setup/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
+92
View File
@@ -0,0 +1,92 @@
---
name: help
description: Operator orientation - lists every skill available on this orchestrator with what it does and when to reach for it, grouped by purpose (status, weekly maintenance, host updates, tests, enrollment, recovery, reporting), plus a "what do you want to do?" guide for common situations. Read-only. Invoke as /help.
---
# help
Orient the operator: what this orchestrator can do, via which skill, and what's sensible next.
**First, enumerate the live roster** (skills get added — don't trust this file's list blindly):
```
ls /srv/cc-ci-orch/.opencode/skills/ # canonical definitions (one dir per skill)
```
For any skill not described below, read its `SKILL.md` frontmatter description and include it.
Then present the roster grouped as follows, and close with the situation guide.
## The roster (curated descriptions — merge with the live listing)
**Status & orientation**
- **/cc-ci-status** — the comprehensive read-only health/status check: weekly-run outcomes,
report publishing, stale tests, CVE-PR aging, host update recency, service health, bridge
`!testme` path. Ends `ALL HEALTHY` or prioritized findings each mapped to a skill. **Start
here when unsure.**
- **/help** — this orientation.
**Weekly maintenance (recipes)**
- **/upgrade-all** — the weekly sweep: survey every `weekly` recipe, open verified upgrade PRs,
write the summary + report. Runs on a timer (`cc-ci-upgrade-all.timer`, Fri 02:00 UTC);
invoke manually to run it now.
- **/recipe-upgrade <recipe>** — the same pipeline for ONE recipe (plan → bump → verify green →
PR). `--with-tests` also fixes that recipe's stale test.
- **/recipe-report** — (re)generate the weekly report page for report.ci.commoninternet.net.
**Tests**
- **/cc-ci-tests-update** — fleet-wide stale-test cleanup: find tests broken by legitimate
upstream changes, fix without weakening, verify, merge the test PRs.
- **/ci-test-review** — diagnose a specific red CI run: classify recipe bug vs stale test vs
CI-server bug, then fix on the right side.
**cc-ci itself**
- **/cc-ci-server-update** — bump the CI **server** host's nixpkgs/sops-nix, deploy with
build → `nixos-rebuild test` → switch + health gate, PR merged on green.
- **/cc-ci-orchestrator-update** — same for **this** orchestrator host (self-update caveats).
- **/cc-ci-update** — both of the above plus /cc-ci-tests-update in one pass.
- **/ci-dev-workflow** — harness/CI-server development discipline (changing cc-ci itself).
**Enrollment**
- **/recipe-enroll <recipe>** — add a NEW recipe to the maintained set end-to-end: mirror,
test suite, bridge + inventory enrollment, full-suite-green verification, bridge deploy.
**Recovery**
- **hetzner-server-recovery** — when a Hetzner host is unreachable over SSH/tailscale: API
reboot, rescue mode, GRUB generation selection (submenu ids are `1>N`), console access.
**Recipe-maintainer toolkit (`/cctest-*` — the ARM sandbox + cctest test server, NOT cc-ci)**
The full autonomic-recipe-maintainer skill set, vendored as a pinned submodule
(`references/recipe-maintainer`) and exposed with the `cctest-` prefix — ~30 skills for
hands-on recipe work against the recipe-maintainer **cctest** test server and local abra
sandbox: `/cctest-intro` (start here), `/cctest-recipe-overview`, `/cctest-recipe-init`,
`/cctest-recipe-deploy`, `/cctest-recipe-test*`, `/cctest-recipe-upgrade-plan|apply|full`,
`/cctest-new-recipe-guide`, sandbox/instance management (`/cctest-setup-sandbox`,
`/cctest-t1cc-start|stop`), and more — enumerate with `ls .opencode/skills | grep ^cctest-`.
**Rule of thumb:** verifying/shipping against the CI pipeline → the cc-ci skills above;
exploratory or hands-on recipe development on a test instance → `/cctest-*`. Policy is
unified: recipe PRs are never agent-merged on either side (operator reviews + merges), and
ARM skills never touch cc-ci infra. After a submodule bump run `scripts/gen-cctest-skills.py`.
## "What do you want to do?"
| Situation | Do this |
|---|---|
| "How is everything?" | `/cc-ci-status` |
| "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` |
| "Tests are red because upstream changed" | `/cc-ci-tests-update` (fleet) or `/recipe-upgrade <r> --with-tests` |
| "A CI run failed and I don't know why" | `/ci-test-review` |
| "Update the CI server OS/deps" | `/cc-ci-server-update` |
| "Update this orchestrator's OS/deps" | `/cc-ci-orchestrator-update` |
| "Add <recipe> to what we maintain" | `/recipe-enroll <recipe>` |
| "A host is unreachable" | `hetzner-server-recovery` |
| "Hack on / bootstrap a recipe in a sandbox" | `/cctest-recipe-init`, `/cctest-recipe-deploy`, `/cctest-recipe-test` |
| "New to recipe work, where do I start?" | `/cctest-intro` |
| "What needs my review?" | `/cc-ci-status` → its open-PR section lists CVE-urgent + ready-to-merge PRs |
**Standing conventions** (all skills follow these): PRs are opened for visibility and merged
directly once verified (invocation = authorization) — except recipe upgrade PRs, which stay
operator-merged; `nixos-rebuild test` before any `switch`; never weaken a test; single-writer
branches; serialize deploy-heavy work on the shared swarm.
If several things need doing, run `/cc-ci-status` first — its findings come pre-prioritized
with the skill to invoke for each.
+137
View File
@@ -0,0 +1,137 @@
---
name: recipe-enroll
description: Add a NEW recipe to cc-ci's maintained set, end to end — create + sync the recipe-maintainers mirror from coopcloud upstream, author a real test suite (health floor + non-vacuous recipe-specific tests incl. a create-an-object round-trip), enroll it in the !testme bridge (POLL_REPOS) and the weekly /upgrade-all inventory (used-recipes.md weekly row + upstream registry), then VERIFY the whole thing: full harness suite green with the new tests, bridge deployed (test-before-switch) and healthy. Opens the cc-ci PR for visibility and merges it directly once verification is green (the skill invocation is the authorization); the report lists merged PR links + what changed. Invoke as /recipe-enroll <recipe>.
---
# recipe-enroll
Enroll a coopcloud recipe as a **maintained** recipe: mirrored, test-covered, `!testme`-triggerable,
and picked up by the weekly `/upgrade-all`. This is the full end-to-end path — worked example:
the 2026-08-03 wordpress enrollment (cc-ci PR #14).
**"Maintained" = ALL of:**
1. Mirror `recipe-maintainers/<recipe>` exists, `main` = coopcloud upstream main, tags synced.
2. `tests/<recipe>/` enrolled in the cc-ci repo (this is what `/upgrade-all` + `ci-test-review`
enumerate).
3. `POLL_REPOS` in `nix/modules/bridge.nix` includes the mirror (the `!testme` bridge).
4. `cc-ci-plan/used-recipes.md` has a `<recipe> weekly` row (orchestrator repo — `/upgrade-all`
skips recipes without it or tagged `external`).
5. `cc-ci-plan/upstream/<recipe>.md` registry entry (release-notes URLs + standing notes).
## Preconditions
- The recipe exists upstream: `ssh cc-ci 'script -qec "abra recipe fetch <recipe>" /dev/null'`
succeeds (every recipe must have a coop-cloud correspondent).
- `GITEA_*` creds in `/srv/cc-ci/.testenv` (orchestrator side; the cc-ci host does NOT have this
file — inject creds over stdin when running helper scripts there, see step 2).
- The shared Swarm is quiescent for step 6 (verification deploys the recipe) — do NOT verify
concurrent with `/upgrade-all` or other verify runs; author everything first, verify when clear.
## Procedure
### 1. Survey the recipe (read-only)
On cc-ci, after `abra recipe fetch <recipe>`, read `~/.abra/recipes/<recipe>/`:
- `compose.yml` — services + images (for the upstream registry), healthcheck (`start_period`
informs `DEPLOY_TIMEOUT`), traefik labels.
- `.env.sample` — is the app self-initializing, or does a fresh deploy sit in a setup wizard
(e.g. wordpress without `POST_DEPLOY_CMDS core_install`)? The tests must handle the state a
fresh CI deploy actually lands in.
- Auth model — how will a test create an object? (REST + token, session login, XML-RPC, …)
Check overlay configs (htaccess/nginx templates) for blocked endpoints before relying on one.
### 2. Create + sync the mirror
Create `recipe-maintainers/<recipe>` (Gitea API: `POST /orgs/recipe-maintainers/repos`,
`{"name":…,"private":true,"default_branch":"main","auto_init":false}`), then force-sync from
coopcloud with the existing helper **run on cc-ci with creds injected via stdin** (the host has
no `.testenv`):
```
set -a; . /srv/cc-ci/.testenv; set +a
{ printf 'export GITEA_USERNAME=%q GITEA_PASSWORD=%q GITEA_URL=%q\n' "$GITEA_USERNAME" "$GITEA_PASSWORD" "$GITEA_URL";
cat /srv/cc-ci-orch/.claude/skills/recipe-upgrade/open-recipe-pr.sh; } \
| ssh cc-ci 'bash -s -- <recipe> --reconcile-only'
```
Expect: repo created (or exists), `main` force-synced to upstream, published tags pushed.
### 3. Author the test suite (dedicated cc-ci clone + branch)
```
git clone ssh://git@git.autonomic.zone:2222/recipe-maintainers/cc-ci.git /home/loops/work/cc-ci-tests-<recipe>
cd … && git checkout -b test/<recipe>-enroll-$(date -u +%Y%m%d)
```
`tests/<recipe>/` contents (template: `tests/uptime-kuma/`, `tests/wordpress/`):
- **`recipe_meta.py`** — `HEALTH_PATH` / `HEALTH_OK` (accept the fresh-deploy state, e.g. a
302 to a setup wizard), `DEPLOY_TIMEOUT` (recipe healthcheck `start_period` + DB init +
first-boot copy, be generous), `HTTP_TIMEOUT`, `WARM_CANONICAL = True` (canon §2.B — all
recipes enroll as data-warm canonicals, operator 2026-06-17).
- **`custom/`** — the health floor + **≥2 recipe-specific, non-vacuous tests**, one of which is
the §4.3 **create-an-object + read-it-back** (write through the app's real API, read back via
a *different* path where possible — e.g. wordpress: XML-RPC write → REST read → permalink
HTML). If the app needs setup/auth, put it in a recipe-local `_<recipe>.py` helper
(idempotent `ensure_*` so test ordering doesn't matter; run-scoped class-B credentials —
the app is destroyed at teardown). Design assertions to name the broken layer (e.g. assert
both the rewrite-dependent and rewrite-independent API routes separately).
- **`PARITY.md`** — table of tests × what's verified × why non-vacuous; note there's no
recipe-maintainer parity corpus if so.
- Lint with the **repo dev-shell ruff**: `nix develop -c ruff check tests/<recipe>/ && nix
develop -c ruff format tests/<recipe>/` (pre-existing drift in other files is not yours).
### 4. Bridge enrollment (same branch)
`nix/modules/bridge.nix`: append `,recipe-maintainers/<recipe>` to the `POLL_REPOS=` CSV.
### 5. Inventory + registry (orchestrator repo, direct to main)
- `cc-ci-plan/used-recipes.md`: add `<recipe> weekly` row (alphabetical).
- `cc-ci-plan/upstream/<recipe>.md`: images table (source repo + releases/changelog links) +
standing notes (setup-wizard behavior, auth caveats, known upgrade traps).
- Commit + push (doc commits go direct to main in this repo).
### 6. Verify end-to-end — full suite GREEN with the new tests (swarm-serialized)
Open the cc-ci PR first (visibility): `TITLE=… BODY_FILE=… bash
/srv/cc-ci-orch/.claude/skills/ci-test-review/open-cc-ci-pr.sh`. The body: what's enrolled,
the test design rationale, the verify + deploy plan.
Then, when the swarm is clear:
```
ssh cc-ci 'rm -rf /root/cc-ci-test-verify && git clone --branch test/<recipe>-enroll-<date> \
ssh://git@git.autonomic.zone:2222/recipe-maintainers/cc-ci.git /root/cc-ci-test-verify && \
cd /root/cc-ci-test-verify && git submodule update --init secrets 2>/dev/null || true'
RECIPE=<recipe> REMOTE_ROOT=/root/cc-ci-test-verify \
bash /srv/cc-ci-orch/.claude/skills/ci-test-review/verify-pr.sh # no REF — recipe main
```
Required: **cold full-suite green** (install/upgrade/backup/restore/custom). Iterate the tests
(bounded, ≤3 attempts) if red — fix the TESTS to match real app behavior, never weaken. Clean up
`/root/cc-ci-test-verify` after.
### 7. Deploy the bridge change to the cc-ci host (test-before-switch)
The `POLL_REPOS` change only takes effect via a host rebuild. Per `/cc-ci-server-update` steps
5a-e: stage the branch to `/root/cc-ci-deploy` (+ secrets copy), `nixos-rebuild build`, detached
`nixos-rebuild test`, verify reachable + healthy, then `switch`. Confirm the bridge is polling
the new repo: `ssh cc-ci 'docker service inspect ccci-bridge_app --format "{{json
.Spec.TaskTemplate.ContainerSpec.Env}}"' | grep <recipe>` and the bridge task is 1/1 with no
auth errors in its logs (a stale Gitea secret 401s silently — see the 2026-08-03 finding).
### 8. Merge + report
Merge the cc-ci PR (invocation = authorization; PR is the visible record — comment the
verification evidence first). Report to the operator: merged PR link + change summary, the
verify log path, and the note that the **next weekly `/upgrade-all` picks the recipe up
automatically** (it enumerates `tests/<recipe>/` dirs × `weekly` rows).
## Guardrails
- **Full-suite green is the enrollment gate** — an enrolled-but-red recipe poisons every future
sweep. Don't merge on partial green.
- **Never weaken**: the tests assert the app's real current behavior, incl. asserting removed
auth paths are rejected where that's the upstream intent.
- **Single-writer**: dedicated clones/branches; never push `main` of cc-ci; never touch
`/root/builder-clone` or the loops' clones; `/root/cc-ci-test-verify` is yours — remove after.
- **Serialize on the swarm**: authoring is free, verification + bridge deploy wait for
`/upgrade-all`/other runs to finish.
- **abra over ssh needs the pseudo-TTY wrap**: `ssh cc-ci 'script -qec "abra …" /dev/null'`.
- **PRs for visibility, merged directly once verified**; failed enrollment leaves the PR open
with an explanatory comment and the report says exactly what's missing.
+79
View File
@@ -791,3 +791,82 @@ session cc-ci-orchestrator-stale can be killed; recipe-mirrors org still private
deploys to the cc-ci server and the orchestrator host — `test` leaves the bootloader/profile
untouched so a reboot always recovers. Codified in the cc-ci-server-update skill (step 5d) and
AGENTS.md (orchestrator rebuild instructions).
## 2026-08-03 ~21:20 UTC — /cc-ci-tests-update + first /cc-ci-orchestrator-update (backup orchestrator)
- /cc-ci-tests-update: both carry-over stale tests fixed via new recipe-local OIDC session-login
helper, verified GREEN cold full-suite paired with their recipe PRs, MERGED: cc-ci PR #12
(lasuite-docs, impress v5.4.0 Bearer removal) + PR #13 (lasuite-meet, meet v1.22.0 hardening).
Comments left on recipe PRs #7/#8 (operator-owned; merging them ⇒ green — both carry the nginx
1.31.3 CVE batch). Fresh 20-recipe sweep skipped in favor of the immediately-following
/upgrade-all (recorded in .cc-ci-logs/tests-update-2026-08-03.md). discourse #6 remains an
operator migration decision (not stale-test).
- /cc-ci-orchestrator-update (first run of the new skill): host bumped 5b4f72e→531670d
(26.05 tip), gen 34, test-before-switch honored, PR cc-ci-orchestrator#1 merged.
Log: .cc-ci-logs/orchestrator-update-2026-08-03.md
- Next: kicking off the weekly /upgrade-all (opencode ZEN glm-5.2) and monitoring to completion
incl. report generation, per operator instruction.
## 2026-08-04 ~17:15 UTC — weekly run COMPLETE + bridge fixed + wordpress live + session hygiene
- Weekly /upgrade-all 2026-08-03 finished after 3 provider-billing interruptions (Go monthly wall
resets ~Aug 22; ZEN balance twice). Final: 20 considered, 15 PRs opened/extended, 12 GREEN,
3 pending (discourse stale test; keycloak+mailu RED on a HARNESS canonical-baseline-404
regression from the 2026-08-03 nixpkgs bump — open task), 0 failed. Public page
week-2026-08-03.html published + privacy-linted (billing wording neutralized — standing rule).
- Model economics: subagents configured to opencode/deepseek-v4-pro (opencode.jsonc agent.general;
main sessions stay glm-5.2; report glm-5.2). NOTE: not yet confirmed live — the finish-run's
subagents still ran glm; verify on the next run that agent.general.model is honored.
- ccci-bridge FIXED: gitea-token swarm secret _v1 was frozen pre-rotation (ensure_secret is
create-once); nix now pins _v3 minted from current sops at deploy. Zero 401s; !testme restored.
- wordpress ENROLLED end-to-end and MERGED (cc-ci PR #14): suite verified GREEN cold full-suite,
bridge deployed via test-before-switch, POLL_REPOS includes wordpress. Joins next weekly survey.
- launch-upgrader watchdog bug FIXED + CONFIRMED (pinned session id + direct-GET validation;
archive-rename at launch). Root cause: /session API rows carry no time fields — sort degraded
to list order and resumed the wrong (giant) session, killing healthy runs.
- Session naming convention live + durable in all three launchers: exactly one canonical
cc-ci-upgrader / cc-ci-supervisor / cc-ci-report; ALL other top-level sessions renamed
archive-<title> (113 archived in the sweep; 33 restyled earlier).
- OPEN: harness canonical-baseline 404 (keycloak/mailu re-verify after fix); confirm ds4-pro
subagent config on next run; mattermost-lts 11.9.0 is innovation-release (EOL 2026-10-15) —
operator decides ESR vs innovation.
## 2026-08-04 ~18:00 UTC — all three pending weekly-run PRs unblocked (operator ask)
- keycloak #5: harness canonical-baseline 404 NOT reproducible post re-activation; repro build
#1199 full green; cc-ci/testme=success reflected. mailu #6: re-verify #1200 hit a cold-pull
900s deploy timeout, retry #1202 green; reflected. (Missing-runs-dir anomaly = install-stage
failure artifact, not a runner bug.)
- discourse #6: TWO stale-test roots fixed in cc-ci PR #15 (MERGED, verified level 5/5 paired
with the recipe PR head): (1) new UPGRADE_BASE_FLOOR recipe_meta key + resolver support —
excludes structurally-invalid upgrade bases (0.8.x bitnami family) while resolution stays
dynamic; declared skip when nothing ≥ floor; (2) the upgrade faithfulness test's hardcoded
discourse/discourse:3.5.3 pin → version-agnostic official-family assertion. Never-weaken held.
- Debug gitea tokens minted for the drone triggers all deleted (ids 55-57); the two 401s during
verification were transient git.autonomic.zone blips (sops bridge token verified valid, 200).
- Operator review queue: keycloak #5, mailu #6, discourse #6 all green + reflected.
## 2026-08-10 — 2026-08-07 weekly run FINISHED + three real bugs fixed
- The 2026-08-07 run did all 18 per-recipe upgrades then died at the summary step on the provider's
monthly cap (05:26 UTC) — and then sat unfinished and UNREPORTED for 3 days. Operator raised the
cap; a finish-run wrote upgrade-all-2026-08-07.md and published week-2026-08-07.html
(privacy-linted; only false positive is Ghost's own "billing search" changelog text).
Result: 17 surveyed, 14 GREEN, 0 new failures, 2 up-to-date, 1 long-standing cross-major hold.
- BUG 1 (3-day silence) — supervisor progress gate. _run_pids() substring-matched the WHOLE cmdline
for the session name, and an agent's kickoff PROMPT is an argv element: the supervisor's own
billing-hung agent contained "cc-ci-upgrader" and matched as a live upgrader run. It read its own
corpse as health ~60 times. Now matches FLAG VALUES (--title/-s) only, AND progress requires the
SESSION TREE to have advanced — a live-but-idle proc is not progress (that proc spun 3 days
emitting nothing). Billing-walled runs are REPORTED, never killed (operator policy): they may
resume when the wall lifts. Commit d441c6c.
- BUG 2 (my regression from the 2026-08-04 pinning work) — launch-report.py never cleared/re-pinned
its session id, so the shared watchdog resolved a PREVIOUS report session that already carried
RECIPE REPORT COMPLETE, declared "run completed" and exited in 3 min, leaving the live report
unwatched. start() now archives titles, clears the pin, and re-pins after launch (same contract
as the upgrader). Commit 242a6d9.
- BUG 3 — the deepseek subagent config never bound; the entire 2026-08-07 run billed as glm (17/17
subagents). Root cause is PLACEMENT, not the .jsonc extension (that parsed fine): launcher-started
sessions pass no --dir so they inherit the opencode SERVE process's project
(/srv/cc-ci-orch/cc-ci), and task-tool subagents inherit their parent session's directory. The
config now lives in the cc-ci repo at that path. VERIFIED end-to-end with the launcher's exact
invocation: parent=glm-5.2, subagent=deepseek-v4-pro read back from the session DB.
LESSON: `opencode debug config` proves resolution, NOT binding — only a live subagent's recorded
modelID proves binding. First attempt was a false pass because the probe passed --dir (unlike the
real launcher) and landed in a different project.
+313
View File
@@ -0,0 +1,313 @@
# Advisory scan — specification
What `cc-ci-plan/advisory-scan.py` does, step by step, and why each step exists. This documents the
implementation as it stands (2026-08-11); if you change the code, change this file in the same commit.
`cc-ci-plan/test-advisory-scan.py` is the executable half of this spec — every rule below is asserted
there.
**Role.** A per-recipe CVE detector run as a **pre-step of `/recipe-upgrade`** (step 2a). It is
**strictly additive**: it never replaces the release-note reading the upgrade agent already does. The
CVE count reported for a recipe is the **union** of what the agent read and what this scan found; the
scan may never *lower* a count established by reading.
**Why it exists.** gitea 1.27.1 fixed CVE-2026-60004 and CVE-2026-59774 (both CVSS 9.8). The weekly
report printed gitea's CVE count as `1`, then `none`. The upgrade agent had read the GitHub *release
notes*, which name neither — both were announced only in the vendor's blog security section — and the
report then derived security content from those notes plus model knowledge, which predates the CVEs.
Nothing in the pipeline queried an advisory source. This scan closes that hole.
---
## Two passes
| | Pass 1 — measure | Pass 2 — judge (`--adjudicate`) |
|---|---|---|
| Who | Pure Python, no model | The calling agent, a model |
| Does | Collects evidence and decides every case it can by arithmetic | Weighs the collected evidence on cases arithmetic cannot settle |
| Output | A count, or `UNKNOWN` | FIXED / NOT-FIXED / STILL-UNKNOWN per open case |
| Rule | Deterministic and reproducible | May only **raise** the count, never lower it |
**Prefer pass 1.** Every case pass 1 decides is one that reproduces identically next week. Pass 2 exists
for evidence that is prose rather than data — a fallback, not a co-equal stage. When a class of case
keeps landing in pass 2, the fix is a new deterministic method in pass 1. §4c is exactly that: it moved
12 redis advisories out of pass 2 and into arithmetic.
---
## Inputs
```
advisory-scan.py <recipe> [--from <version>] [--to <version>]
[--image <name>=<from>:<to>]... [--adjudicate] [--json] [--registry DIR]
```
| Input | Meaning |
|---|---|
| `<recipe>` | Recipe name; selects `cc-ci-plan/upstream/<recipe>.md` (the per-recipe URL registry) |
| `--from` / `--to` | The **primary app image's** version window being upgraded across |
| `--image NAME=FROM:TO` | A **sidecar image and the versions it moved between** (repeatable, all in ONE call). `NAME` is substring-matched against source repo names. Malformed values warn on stderr and are skipped. Without it that image's advisories stay unclassified. |
| `--adjudicate` | Run pass 2: append the evidence dossier for judgement |
| `--registry` | Registry dir; also `CCCI_UPSTREAM_REGISTRY` |
| `GITHUB_TOKEN` / `GITHUB_TOKEN_FILE` | Read-only token; **rate limit only** (60/hr anonymous → 5000/hr). Default file `/srv/cc-ci/.github-token`, mode 600. Public advisories need **no scopes**. |
Exit code is always 0 — this is informational. Failures are *reported*, never raised.
---
# Pass 1 — deterministic
## Step 1 — Collect source URLs from the registry
Read `cc-ci-plan/upstream/<recipe>.md` and extract every `http(s)://…` URL.
**Trailing markdown punctuation is stripped** (`` ` `` `'` `"` `*` `.` `,` `;` `:` `>` `)`). The registry
is markdown, so URLs appear inside backticks and quotes; capturing the punctuation produced fetches of
`https://docs.n8n.io/release-notes/\`` which 404, and made immich and n8n render `?` for no real reason.
> **Registry hygiene matters.** The scan can only look where the registry points. Two classes of defect
> have been found and fixed by running it: a **wrong URL** (`pgautoupgrade/pgautoupgrade`, which 404s —
> the repo is `pgautoupgrade/docker-pgautoupgrade`) and a **missing** one (gitea's CVEs are announced at
> `blog.gitea.com`, which the registry didn't list). When a vendor publishes security notes somewhere
> the registry lacks, add it.
## Step 2 — Query the sources
Three source classes, each recording **its own status** so *"checked, none found"* is never confused
with *"not checked"*.
### 2a. GitHub Security Advisories — PRIMARY
For every `github.com/<owner>/<repo>` URL in the registry:
`GET /repos/<owner>/<repo>/security-advisories`.
Captured per advisory: `cve_id`, `ghsa_id`, `severity`, `summary`, **`description`**, `published_at`,
and **all** `vulnerabilities[]` entries' `vulnerable_version_range` + `patched_versions` (joined `;`).
- **All entries, not just the first.** An advisory carries one entry **per patched release line** —
n8n patches three (1.123.32, 2.17.4, 2.18.1). Reading only `vulnerabilities[0]` silently dropped the
line a deployment was actually on, and misclassified CVE-2026-42231/42232 as out-of-window.
- **Pagination via the `Link rel="next"` cursor**, to exhaustion (cap 20 hops). This endpoint returns
at most 100 rows **and ignores `?page=`** — it re-returns the same rows, which silently truncates busy
projects. discourse has 286 advisories; a single page cannot even cover one upgrade window.
- **The description is kept from this response.** It is already present here, and pass 2 needs the
prose; re-fetching it per advisory would cost one request each.
- **HTTP 404 ⇒ `no-advisories-published`** — a benign absence (many sidecar images publish none), **not**
a failure. Conflating the two pushed nearly every recipe to `?` and destroyed the signal.
### 2b. Vendor release / security pages
Every other registry URL is fetched, HTML-stripped, and scanned for `CVE-\d{4}-\d{4,7}`, keeping ±160
characters of context per hit.
URLs containing `<`, `>`, `{`, `}`, `VERSION`, or `vX.Y.Z` are **skipped as templates** — they are
human documentation (`…/changelog/v<VERSION>/`), not fetchable, and counting them as failures is wrong.
This is the source that would have caught gitea: the vendor blog names both CVEs, the GitHub release
page names neither. A CVE found **only** here carries no version data, so pass 1 cannot place it — it
goes to pass 2 (§6).
### 2c. OSV.dev — supplementary
Only when the recipe has an entry in `OSV_PACKAGES` (ecosystem + package) and a version is given.
> **Measured, not assumed.** For the two gitea CVEs, OSV **404'd on both** and returned only Go
> *dependency* advisories for the package; NVD's API had them by neither CPE, CVE id, nor keyword.
> **Advisory databases lag the vendor**, which is why 2a and 2b lead and this is supplementary.
## Step 3 — Union
All findings merge into one CVE map: id → `{sources[], severity, ghsa, vulnerable_range, patched,
description, published_at, url, cvss, context}`. A CVE seen by several sources keeps them all.
## Step 4 — Classify against the upgrade window
Two invariants govern this step, both learned from a wrong answer in production.
> **A. Every image is judged by its OWN versions.** The app repo uses `--from/--to`; each sidecar uses
> its own `--image NAME=FROM:TO`. **Pass them all in ONE invocation** — the count is a union across
> images, and the UNKNOWN guarantee in B only holds when a single run sees every one. An image with no
> window is **not** classified; its advisories are listed as unclassified so they stay visible without
> inflating the count. Each window is classified independently, so one may use version ranges while
> another falls back to dates.
> *Why:* discourse once reported **133**, of which **34 were redis CVEs** — including
> `CVE-2021-21309`, patched in redis 6.0.11 in 2021 — counted purely because 6.0.11 sits numerically
> inside discourse's `3.5.3 → 2026.7.1` range. The fix is not to ignore sidecars but to give each one
> the versions it actually moved through: with `--image redis=7.4:8.10`, discourse scores
> **140 = 123 (app, by date) + 17 (redis)**, and the redis seventeen include `CVE-2025-49844`,
> **critical**, invisible while sidecars went uncounted.
>
> **B. Never emit a number you cannot justify.** If no method can order a window, the count is
> `null` / `UNKNOWN`, never `0`. A `0` in a security column asserts safety. Equally, an advisory that
> cannot be judged is **indeterminate** (§4d) — never silently counted as "not fixed".
### 4a. By patched version (preferred — exact)
`patched_versions` is a **range expression** (`">= 2.18.1"`), possibly several joined by `;`. Extract
every version-looking token; the advisory is **fixed-by-this-upgrade** if **any** patched version `p`
satisfies `from < p <= to` — exclusive lower (a fix already in the version you were on is not this
upgrade's doing), inclusive upper.
**Comparison is zero-padded to equal length**, so `18` == `18.0` == `18.0.0` as semver means it.
Without padding, plain tuple order makes `(18,) < (18,0)`, i.e. a fix in 18.0 falls *outside* a window
ending at 18 — and bare major tags are the norm for sidecars (`postgres:18`, `redis:8-alpine`). Padding
is permissive at the lower bound and conservative at the upper: with `to = 18`, a fix in `18.5` is
**not** counted, because nothing proves which 18.x a floating tag resolved to.
### 4b. By advisory publish date (fallback — temporal)
Used **only** when 4a cannot be trusted: a **version-scheme change**, detected as the leading version
component jumping by ≥ `SCHEME_JUMP` (100) — e.g. semver `3.5.3` → calver `2026.7.1`.
Version strings are unorderable across such a jump (`2025.12.2` compares "newer" than `3.5.3` while
shipping earlier), but **release dates always order**. So:
1. Resolve `--from` and `--to` to **git tag dates** on that source (tries `v<version>` then
`<version>`; annotated tag → tagger date, else commit date).
2. An advisory counts as fixed when `date_from < published_at <= date_to` — the same exclusive/inclusive
boundaries as 4a, so the two methods agree at the edges.
This reproduces, automatically, the hand count that established discourse `3.5.3` (2025-12-30) →
`2026.7.1` (2026-07-31) = **123 CVEs**.
*Assumption:* the vendor publishes advisories at fix time (true for discourse). The count includes
**first-party plugin advisories** where the vendor files them on the same repo — which is why a
plugin-rich project scores far higher than a monolith, not a statement about relative security.
### 4c. By release notes naming the CVE (rescue — for advisories with no fix version)
Applied to advisories 4a/4b could not decide, **before** giving up on them. Fetch the source repo's
GitHub **releases** (cached per repo, 4 pages) and find every tag whose notes **name the CVE id**. If
any such tag falls inside the window by 4a's rule, the advisory is fixed by this upgrade, and the
naming tags are recorded in `fix_versions_from_release_notes` as the citation.
> **Why this is not optional.** Vendors routinely publish an advisory with `patched_versions: "TBD"`
> and then name the CVE in the release notes of every branch that got the fix. **All 12** redis
> advisories crossed by discourse's redis bump are exactly this shape — `TBD`, or a placeholder like
> `7.4.X`, with an open-ended `vulnerable_version_range` (`All`, `>= 7.0.0`) — yet each is named in
> concrete releases (`CVE-2025-32023` → 6.2.19, 7.2.10, 7.4.5, 8.0.3, 8.2.0). Without this method
> discourse's redis contribution reads 5; with it, 17. Six of the twelve are high severity.
### 4d. Otherwise — indeterminate, not excluded
An advisory from a **windowed** source that none of 4a4c could decide — no usable `patched_versions`,
an open-ended vulnerable range, and no release note naming it — is recorded as **indeterminate**. It is:
- **not** added to the count (nothing justifies counting it), and
- **not** treated as unaffected (nothing justifies dismissing it either).
It is listed prominently, the headline reads *"(at least — see undetermined below)"*, and it becomes an
input to pass 2. Silently excluding these is the same defect class as printing `0` for an unscanned
recipe, one level down.
### 4e. Unorderable window
If neither 4a nor 4b can order a window at all, `count_known = false`, `cve_count_fixed = null`, and the
headline reads **"CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count"** with an
explicit *"This is NOT zero"*. If **any** requested window is unorderable the whole count is suppressed;
a partial number would understate a security figure.
## Step 5 — Output
Markdown (default) for pasting into the per-recipe upgrade log, or `--json`.
| Field | Meaning |
|---|---|
| `cve_count_fixed` | Union across all windows, or **`null`** if any window was unorderable |
| `count_known` | Distinguishes "counted zero" from "could not count" |
| `cve_count_indeterminate` / `indeterminate[]` | Judged by nothing; a **floor marker** on the count |
| `resolved_by_release_notes` | CVE → the tags that named it (4c citations) |
| `windows` | Every source classified, with its from/to |
| `classified_by` | **Per source**: which method decided it |
| `date_window` | **Per source**, when 4b was used |
| `fixed_by_this_upgrade[]` | CVE ids, with severity / GHSA / fixed-in per id |
| `unclassified[]` | Seen but not attributable (other images, or vendor-page-only) |
| `sources[]` / `sources_failed[]` / `sources_benign[]` | Per-source status; only genuine failures in `failed` |
---
# Pass 2 — adjudication (`--adjudicate`)
## Step 6 — What gets judged
Two kinds of open case, both real gaps rather than noise:
1. **Indeterminate** (§4d) — from an image *with* a window, but no fix version is knowable anywhere.
2. **Vendor-page-only** — a CVE seen only on a vendor security page, with no structured advisory
behind it. **gitea's two CVSS-9.8 RCEs are this shape.** They carry no version data, so no
arithmetic can place them, but the page prose usually states the fixed release.
## Step 7 — The evidence dossier
Pass 1 collects; pass 2 judges. Nothing in the dossier interprets — it assembles what was *measured*,
so the judgement is made against evidence rather than recollection. That distinction is the whole
point: the original failure was a report leaning on model knowledge that predated the CVEs, with no
source queried at all.
Per open case: severity, CVSS, sources, its window, why it is undecided, `patched_versions` and
`vulnerable_version_range` **as published**, summary, full description, references, affected ranges
with `first_patched_version`, and **every release tag naming the CVE** — whether or not in window, since
the model may reason about branch lines the arithmetic deliberately will not.
**Pass 2 also sees every decision pass 1 made** — a compact table of counted and excluded advisories
with the evidence behind each verdict. A deterministic verdict can still be wrong (a mis-parsed range,
a release note that mentions a CVE without fixing it), and only a reader with the evidence in front of
it can catch that. Silence means agreement.
## Step 8 — The verdict contract
For each open case: **FIXED** / **NOT-FIXED** / **STILL-UNKNOWN**, each with a one-line reason
**citing the evidence shown**. Every FIXED is added to the recipe's count — pass 1's number is a floor,
not a total. If the evidence does not settle it, **STILL-UNKNOWN**: do not infer from memory of the
project, and never record an undecided CVE as unaffected.
**No silent caps.** `MAX_ADJUDICATE` (25) and `MAX_REVIEW_ROWS` (400) bound the output; whenever either
truncates, the block says how many were dropped and that the unshown remain undetermined.
---
## How consumers must read it
`/recipe-report` renders the `cve` column from the **union** of this scan and the agent's own reading:
- a clean scan → its number, **including `0`**;
- **failed sources**, or `UNKNOWN` → **`?`**, never `none` — a blank reads as "clean", which is exactly
how two CVSS-9.8 gitea RCEs were published as `none`;
- **no upgrade this run** → `0`, not `?` — nothing an upgrade could have fixed;
- a count with **indeterminate advisories** → the number is a floor; say so in the notes;
- benign notes → never `?`.
`?` must stay **rare**: it means *we tried and could not tell*, not *we did not look*. A rash of `?` is
a bug to raise in the report's Addendum, not a normal outcome — every instance so far traced to a defect
in this tool or stale registry data.
## Testing
`test-advisory-scan.py` — 58 offline tests (fixtures, no network) plus 6 live regressions against the
counts published in week-2026-08-07. **The offline tier covers pass 1 only, by design**: pass 2's
judgement is a model's and cannot be asserted deterministically. What *is* tested about pass 2 is the
part that stays deterministic — which cases it selects, and that truncation is always announced.
```
python3 test-advisory-scan.py # offline
python3 test-advisory-scan.py --live # + historic report numbers
```
`audit-advisory-scan.py` re-derives the counts with a **separate** semver implementation and its own
release fetch, then diffs against the scanner. Run it after changing classification; it is what caught
the 12 undercounted redis CVEs.
## Known limits
1. **Versions must be supplied per image.** An image with no `--image` is not counted — the scan will
not guess a version range it was not given. `/recipe-upgrade` passes one per image it bumped.
2. **Date-based counts are temporal**, not exact — they assume publish-at-fix-time.
3. **Registry-bound.** Unlisted vendor security pages are invisible; the scan cannot know what it was
never pointed at.
4. **`NAME` matching is substring-against-source-name**, so a short or generic name can attach to more
than one repo (`postgres` matches `discourse/discourse-postgres`). The primary source is claimed
first and cannot be stolen. A name matching **nothing** is silently ignored — a typo costs coverage
without warning.
5. **Release-note rescue (4c) trusts that naming implies fixing.** A release note that merely mentions a
CVE would be read as fixing it. Pass 2's review table exists partly to catch this.
6. **Rate limit** without a token is 60/hr — a full weekly sweep will exhaust it and degrade to failed
sources (visibly, but degraded).
+859
View File
@@ -0,0 +1,859 @@
#!/usr/bin/env python3
"""Deterministic per-recipe CVE/advisory scan — an ADDITIVE pre-step for /recipe-upgrade.
WHY THIS EXISTS (2026-08-10): gitea 1.27.1 fixed two CVSS-9.8 RCEs (CVE-2026-60004,
CVE-2026-59774). Our weekly report showed gitea's CVE count as "1", then "none". The upgrade
subagent had scanned the GitHub *release notes*, which mention neither; the two CVEs were announced
only in the vendor's blog security section. The report generator then derived security content from
those notes plus model knowledge — and the model's training predates the CVEs. Nothing in the
pipeline ever queried an advisory source, so a critical CVE that is newer than the model and absent
from the changelog was invisible by construction.
WHAT IT DOES NOT DO: it does not replace or alter any existing security analysis. It is a strictly
ADDITIONAL evidence source whose findings are unioned into the CVE count.
SOURCES (measured against the gitea case before being chosen):
1. GitHub Security Advisories API — repos/<owner>/<repo>/security-advisories. PRIMARY: carries
CVE id, GHSA id, severity AND vulnerable/patched version ranges, so "fixed by THIS upgrade" is
computable rather than guessed. Found both gitea CVEs. Derived from the source-repo URLs the
per-recipe registry already records — no new per-recipe config needed.
2. Vendor release/security pages — every URL in cc-ci-plan/upstream/<recipe>.md, fetched and
regex-scanned for CVE ids. This is what would have caught gitea: the vendor blog names both,
while the GitHub releases page names neither. Add vendor security/announcement URLs to the
registry to widen this.
3. OSV.dev — supplementary, best-effort, only when the recipe declares an ecosystem/package
mapping below. NOTE: for gitea, OSV returned only Go *dependency* advisories and 404'd on both
application CVEs; NVD's API had them neither by CPE, CVE id, nor keyword. Advisory databases
lag the vendor — which is exactly why (1) and (2) lead.
Every source reports its own status, so "checked, none found" is never confused with "not checked".
Usage:
advisory-scan.py <recipe> [--from <version>] [--to <version>] [--json] [--registry DIR]
--from/--to are the app versions being upgraded between (e.g. 1.26.2 -> 1.27.1). When given, each
advisory is classified fixed-by-this-upgrade / still-open / older. Without them everything known
is listed unclassified. Exits 0 even when sources fail (informational; failures are reported).
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import re
import sys
import urllib.error
import urllib.request
REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", "/srv/cc-ci/cc-ci-plan/upstream")
UA = "cc-ci-advisory-scan (+https://git.autonomic.zone/recipe-maintainers/cc-ci)"
TIMEOUT = int(os.environ.get("ADVISORY_SCAN_TIMEOUT", "45"))
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
# Leading-version-component jump that means the scheme changed (semver → calver).
SCHEME_JUMP = 100
# A patched_versions field that names no usable version. GitHub carries these verbatim from the
# vendor: redis publishes "TBD" for 11 advisories and "6.2.X, 7.2.X, 7.4.X" for another, and their
# vulnerable_version_range is open-ended ("All", ">= 7.0.0"), so the fix version is NOT recoverable.
# Such an advisory must be reported as INDETERMINATE, never silently counted as "not fixed" — that
# would assert an upgrade did not fix something we simply cannot judge.
PLACEHOLDER_RE = re.compile(r"\bTBD\b|\bunknown\b|\bnone\b|\d+\.[Xx]\b|\?", re.I)
# Optional OSV mappings: recipe -> (ecosystem, package). Supplementary only (see module docstring).
OSV_PACKAGES: dict[str, tuple[str, str]] = {
"gitea": ("Go", "code.gitea.io/gitea"),
"n8n": ("npm", "n8n"),
}
def _github_token() -> str | None:
"""Read-only GitHub token, for the API rate limit ONLY (60/hr anonymous → 5000/hr with a token).
Env `GITHUB_TOKEN` wins; otherwise the file at `GITHUB_TOKEN_FILE` (default
/srv/cc-ci/.github-token, chmod 600, never in git). Reading PUBLIC security advisories needs NO
scopes at all — create a classic PAT with every box unticked, or a fine-grained token limited to
"Public repositories: read". Do NOT grant repo/write scopes: this tool only ever GETs advisories.
A missing token is not an error — the scan simply runs anonymously and will report sources as
failed once the 60/hr limit bites, which is visible rather than silent.
"""
tok = os.environ.get("GITHUB_TOKEN")
if tok:
return tok.strip()
path = os.environ.get("GITHUB_TOKEN_FILE", "/srv/cc-ci/.github-token")
try:
with open(path) as f:
return f.read().strip() or None
except OSError:
return None
def _gh_paginate(url: str, hdrs: dict, max_pages: int = 20):
"""Yield every row from a GitHub list endpoint, following Link rel=\"next\" cursors."""
seen_keys = set()
for _ in range(max_pages):
req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
rows = json.load(r)
link = r.headers.get("Link", "") or ""
fresh = 0
for a in rows:
k = a.get("ghsa_id") or json.dumps(a, sort_keys=True)[:120]
if k not in seen_keys:
seen_keys.add(k); fresh += 1
yield a
nxt = None
for part in link.split(","):
if 'rel="next"' in part:
nxt = part.split(";")[0].strip().strip("<>")
if not nxt or fresh == 0:
return
url = nxt
def _tag_date(owner: str, repo: str, version: str | None) -> str | None:
"""Publish date of a release tag, for DATE-BASED classification (see classify_by_date).
Version strings cannot be ordered across a scheme change (semver → calver), but tag dates
always can. Tries the common tag spellings; returns an ISO timestamp or None."""
if not version:
return None
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
for tag in (f"v{version}", version):
try:
ref = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}", hdrs))
obj = ref.get("object", {})
sha, typ = obj.get("sha"), obj.get("type")
if typ == "tag":
t = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/tags/{sha}", hdrs))
if t.get("tagger", {}).get("date"):
return t["tagger"]["date"]
sha = t.get("object", {}).get("sha")
c = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}", hdrs))
return c["commit"]["committer"]["date"]
except Exception: # noqa: BLE001 — try the next spelling
continue
return None
def _fetch(url: str, headers: dict | None = None) -> str:
h = {"User-Agent": UA, "Accept-Encoding": "gzip"}
h.update(headers or {})
with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=TIMEOUT) as r:
raw = r.read()
if r.headers.get("Content-Encoding") == "gzip":
raw = gzip.decompress(raw)
return raw.decode(errors="replace")
def _vkey(v: str | None) -> tuple:
"""Loose version ordering key: leading integers of each dot-part ('1.27.1-rootless' -> (1,27,1))."""
if not v:
return ()
v = v.strip().lstrip("vV").split("+")[0]
out = []
for part in re.split(r"[.\-_]", v):
m = re.match(r"^\d+", part)
if m:
out.append(int(m.group()))
elif out:
break
return tuple(out)
def _within(kf: tuple, kt: tuple, c: tuple) -> bool:
"""Is patched-version `c` inside the window (kf, kt] — exclusive lower, inclusive upper?
Compares ZERO-PADDED to equal length, so "18" == "18.0" == "18.0.0" the way semver means it.
Without the padding, plain tuple order says (18,) < (18,0), i.e. a CVE patched in 18.0 falls
OUTSIDE a window ending at 18 — and bare major tags are the norm for sidecars (postgres:18,
redis:8-alpine), so that silently dropped real fixes. Padding also keeps the upper bound
conservative: a fix in 18.5 is still outside a window ending at "18", because nothing proves
which 18.x a floating tag resolved to.
"""
n = max(len(kf), len(kt), len(c))
pad = lambda t: t + (0,) * (n - len(t))
return pad(kf) < pad(c) <= pad(kt)
def registry_urls(recipe: str, registry_dir: str) -> tuple[list[str], str | None]:
path = os.path.join(registry_dir, f"{recipe}.md")
try:
with open(path) as f:
text = f.read()
except OSError:
return [], None
urls = []
for u in re.findall(r"https?://[^\s)|\]]+", text):
# The registry is MARKDOWN: urls appear inside `backticks`, 'quotes', **bold**, and at the
# end of sentences. Trailing punctuation captured into the url makes the fetch 404 and the
# recipe render '?' for no real reason — that is what put immich and n8n in the unknown
# column on 2026-08-07 (https://docs.n8n.io/release-notes/` ← note the backtick).
u = u.rstrip("`'\"*.,;:>)")
if u and u not in urls:
urls.append(u)
return urls, path
def github_advisories(urls: list[str]) -> list[dict]:
"""Query GitHub Security Advisories for every github.com/<owner>/<repo> in the registry."""
seen, results = set(), []
for u in urls:
m = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", u)
if not m:
continue
owner, repo = m.group(1), m.group(2).removesuffix(".git")
if (owner, repo) in seen:
continue
seen.add((owner, repo))
api = f"https://api.github.com/repos/{owner}/{repo}/security-advisories?per_page=100"
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
entry = {"source": f"github-advisories:{owner}/{repo}", "status": "ok", "advisories": []}
try:
# PAGINATE. This endpoint caps at 100 per response and IGNORES ?page= — it returns the
# same rows again, which silently truncates busy projects (discourse has 286; a hand
# count on 2026-08-10 found 123 CVEs in one upgrade window that a single page missed).
# Follow the Link rel="next" cursor to exhaustion instead.
for a in _gh_paginate(api, hdrs):
# An advisory carries ONE ENTRY PER PATCHED RELEASE LINE. n8n patches three
# (1.123.32, 2.17.4, 2.18.1); reading only vulnerabilities[0] silently dropped the
# line our deployment is actually on, so CVE-2026-42231/42232 classified as
# out-of-window. Keep them ALL and let the classifier match any of them.
vulns = a.get("vulnerabilities") or []
entry["advisories"].append(
{
"cve": a.get("cve_id"),
"ghsa": a.get("ghsa_id"),
"severity": a.get("severity"),
"summary": (a.get("summary") or "")[:200],
"vulnerable_range": "; ".join(
filter(None, (v.get("vulnerable_version_range") for v in vulns))
) or None,
"patched": "; ".join(
filter(None, (v.get("patched_versions") for v in vulns))
) or None,
"url": a.get("html_url"),
"published_at": a.get("published_at"),
# The list response ALREADY carries the prose. Keep it: the adjudication
# pass needs it, and re-fetching per advisory costs a request each.
"description": (a.get("description") or "")[:4000],
"cvss": ((a.get("cvss") or {}).get("vector_string")),
}
)
except urllib.error.HTTPError as e:
# 404 = this repo simply publishes no security advisories (e.g. sidecar images like
# pgautoupgrade). That is a BENIGN ABSENCE, not a failed check — conflating the two
# would push nearly every recipe to "unknown" and make the ? signal meaningless again.
entry["status"] = "no-advisories-published" if e.code == 404 else f"error: HTTP {e.code}"
except Exception as e: # noqa: BLE001 — a genuinely dead source must be REPORTED, never silent
entry["status"] = f"error: {type(e).__name__}: {e}"
results.append(entry)
return results
def vendor_pages(urls: list[str]) -> list[dict]:
"""Fetch each registry URL and regex out CVE ids, with a little surrounding context."""
out = []
for u in urls:
if u.startswith("https://api.github.com"):
continue
if re.search(r"[<>{}]|\bVERSION\b|\bvX\.Y\.Z\b", u):
# Registry entries sometimes carry TEMPLATE urls for humans
# (…/changelog/v<VERSION>/). They are documentation, not fetchable — skipping them is
# correct; counting them as failures would wrongly mark the recipe's count unreliable.
out.append({"source": u, "status": "skipped: template URL (not fetchable)", "cves": [], "context": {}})
continue
entry = {"source": u, "status": "ok", "cves": [], "context": {}}
try:
text = _fetch(u)
plain = re.sub(r"<[^>]+>", " ", text)
for cve in sorted(set(CVE_RE.findall(plain))):
entry["cves"].append(cve)
i = plain.find(cve)
entry["context"][cve] = re.sub(r"\s+", " ", plain[max(0, i - 160) : i + 200]).strip()
except Exception as e: # noqa: BLE001
entry["status"] = f"error: {type(e).__name__}: {e}"
out.append(entry)
return out
def osv(recipe: str, version: str | None) -> dict | None:
pkg = OSV_PACKAGES.get(recipe)
if not pkg or not version:
return None
eco, name = pkg
entry = {"source": f"osv:{eco}/{name}@{version}", "status": "ok", "cves": []}
try:
body = json.dumps({"package": {"name": name, "ecosystem": eco}, "version": version}).encode()
req = urllib.request.Request(
"https://api.osv.dev/v1/query", data=body,
headers={"Content-Type": "application/json", "User-Agent": UA}, method="POST",
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
data = json.load(r)
ids = set()
for v in data.get("vulns", []):
for a in [v.get("id")] + (v.get("aliases") or []):
if a and a.startswith("CVE"):
ids.add(a)
entry["cves"] = sorted(ids)
except Exception as e: # noqa: BLE001
entry["status"] = f"error: {type(e).__name__}: {e}"
return entry
_RELEASE_CACHE: dict[str, list[tuple[str, str]]] = {}
def _releases(owner: str, repo: str, max_pages: int = 4) -> list[tuple[str, str]]:
"""[(tag, body)] for a repo's GitHub releases, cached per repo for the process."""
key = f"{owner}/{repo}"
if key in _RELEASE_CACHE:
return _RELEASE_CACHE[key]
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
out: list[tuple[str, str]] = []
try:
for rel in _gh_paginate(
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=100", hdrs, max_pages
):
out.append((rel.get("tag_name") or "",
f"{rel.get('name') or ''}\n{rel.get('body') or ''}"))
except Exception: # noqa: BLE001 — best effort; absence just leaves advisories undetermined
pass
_RELEASE_CACHE[key] = out
return out
def release_fix_versions(source: str, cve: str) -> list[str]:
"""Release tags whose notes NAME this CVE — a deterministic fix version when the advisory has none.
Vendors routinely publish an advisory with `patched_versions: "TBD"` and then name the CVE in the
release notes of every branch that got the fix. redis does exactly this: all 12 of its advisories
that discourse's redis bump crosses carry TBD, yet each is named in concrete releases
(CVE-2025-32023 → 6.2.19, 7.2.10, 7.4.5, 8.0.3, 8.2.0). Ignoring that evidence undercounted
discourse by 12 CVEs, so this is checked BEFORE giving up on an advisory.
"""
if not source.startswith("github-advisories:"):
return []
owner, _, repo = source.split(":", 1)[1].partition("/")
return [tag for tag, body in _releases(owner, repo) if cve in body]
def advisory_text(ghsa: str, source: str | None = None) -> dict:
"""Full text of one advisory, for the ADJUDICATION pass (see adjudication_block).
The structured `patched_versions` field is often "TBD" while the prose description and the
linked references DO state where the fix landed. That prose is not machine-parseable in general
— which is the point: it is collected here for a MODEL to judge, not for a regex."""
hdrs = {"Accept": "application/vnd.github+json"}
tok = _github_token()
if tok:
hdrs["Authorization"] = f"Bearer {tok}"
out = {"ghsa": ghsa, "status": "ok"}
# Repo-scoped FIRST. Many repository advisories are never mirrored into the global GitHub
# Advisory Database, so /advisories/<ghsa> 404s for them (all 12 redis ones, for instance)
# while /repos/<owner>/<repo>/security-advisories/<ghsa> returns the full record.
cands = []
if source and source.startswith("github-advisories:"):
cands.append(f"https://api.github.com/repos/{source.split(':',1)[1]}/security-advisories/{ghsa}")
cands.append(f"https://api.github.com/advisories/{ghsa}")
try:
a, last = None, None
for u in cands:
try:
a = json.loads(_fetch(u, hdrs)); break
except Exception as ex: # noqa: BLE001 — try the next endpoint
last = ex
if a is None:
raise last or RuntimeError("no advisory endpoint responded")
out.update({
"summary": a.get("summary"),
"description": (a.get("description") or "")[:4000],
"severity": a.get("severity"),
"published_at": a.get("published_at"),
"references": [r for r in (a.get("references") or [])][:12] or (
[a.get("html_url")] if a.get("html_url") else []),
"cvss": (a.get("cvss") or {}).get("vector_string"),
"vulnerabilities": [
{"package": (v.get("package") or {}).get("name"),
"vulnerable_version_range": v.get("vulnerable_version_range"),
"first_patched_version": v.get("first_patched_version")}
for v in (a.get("vulnerabilities") or [])
],
})
except Exception as e: # noqa: BLE001
out["status"] = f"error: {type(e).__name__}: {e}"
return out
MAX_ADJUDICATE = int(os.environ.get("ADVISORY_SCAN_MAX_ADJUDICATE", "25"))
# Compact review rows for advisories pass 1 DID decide. Pass 2 sees these too, so a wrong
# deterministic verdict can be caught rather than inherited.
MAX_REVIEW_ROWS = int(os.environ.get("ADVISORY_SCAN_MAX_REVIEW", "400"))
def needs_judgement(rep: dict) -> list[str]:
"""CVEs the deterministic pass could not decide — the input set for the adjudication pass.
Two kinds, both real gaps rather than noise:
1. INDETERMINATE — from an image WITH a window, but no fix version is knowable (advisory says
`TBD`/`7.4.X`, range is open-ended, and no release note names it).
2. VENDOR-PAGE-ONLY — a CVE seen only on a vendor security page, with no structured advisory
behind it at all. gitea's two CVSS-9.8 RCEs are this shape. They carry no version data, so
no arithmetic can place them, but the page's prose usually states the fixed release.
"""
windows = rep.get("windows") or {}
out = list(rep.get("indeterminate") or [])
for cve in rep.get("unclassified") or []:
srcs = rep["cves"][cve]["sources"]
if not any(s.startswith("github-advisories:") for s in srcs) and cve not in out:
out.append(cve)
return sorted(out)
def evidence_bundle(rep: dict, cve: str) -> dict:
"""EVERY deterministic signal held about one CVE, gathered for a model to weigh.
Pass 1 collects; pass 2 judges. Nothing here interprets — it assembles what was measured, so the
judgement is made against evidence rather than recollection (the exact failure that let two
CVSS-9.8 gitea RCEs be published as "none": the report leaned on model knowledge that predated
them, and no source had been queried at all).
"""
e = rep["cves"][cve]
src = e["sources"][0]
windows = rep.get("windows") or {}
win = next((windows[s] for s in e["sources"] if s in windows), None)
ev = {
"cve": cve,
"severity": e.get("severity"),
"cvss": e.get("cvss"),
"sources": e["sources"],
"window": win,
"why_undecided": ("no fix version published and no release note names it"
if cve in (rep.get("indeterminate") or [])
else "seen only on a vendor page — no structured advisory, no version data"),
"patched_as_published": e.get("patched"),
"vulnerable_range_as_published": e.get("vulnerable_range"),
"summary": e.get("context"),
"description": e.get("description"),
"advisory_url": e.get("url"),
# Release tags NAMING this CVE, whether or not they fall in the window — the model may
# reason about branch lines the arithmetic deliberately would not.
"releases_naming_it": release_fix_versions(src, cve) if src.startswith("github-advisories:") else [],
"references": [],
}
if e.get("ghsa"):
t = advisory_text(e["ghsa"], src)
if t.get("status") == "ok":
ev["references"] = t.get("references") or []
ev["description"] = t.get("description") or ev["description"]
ev["summary"] = t.get("summary") or ev["summary"]
ev["affected"] = t.get("vulnerabilities") or []
else:
ev["detail_fetch"] = t.get("status")
return ev
def adjudication_block(rep: dict) -> str:
"""SECOND PASS: present the collected evidence and ask for a judgement on each open case.
This block decides nothing. The deterministic count stands as a FLOOR; a verdict here may only
ADD to it, matching the rule that this scan raises a count on evidence but never lowers one.
"""
todo = needs_judgement(rep)
if not todo:
return ""
shown, dropped = todo[:MAX_ADJUDICATE], max(0, len(todo) - MAX_ADJUDICATE)
L = ["", "---", "",
f"## Adjudication pass — {len(todo)} advisory/advisories need judgement", "",
"Pass 1 collected the evidence below deterministically and could NOT decide these cases. "
"Weigh the evidence and decide each one.", "",
"**For each: did the version move in its window fix it?** Answer **FIXED** / **NOT-FIXED** / "
"**STILL-UNKNOWN**, each with a one-line reason **citing the evidence shown** — a fixed "
"release named in the text, a branch line, an affected range. Add every FIXED to the "
"recipe's CVE count; the deterministic number is a floor, not a total. If the evidence does "
"not settle it, say STILL-UNKNOWN: do NOT infer from memory of the project, and never "
"record an undecided CVE as unaffected.", ""]
for src, win in (rep.get("windows") or {}).items():
L.append(f"- window: `{src.split(':',1)[-1]}` {win['from']}{win['to']}")
if dropped:
L += ["", f"⚠ Showing the first {MAX_ADJUDICATE} of {len(todo)}; **{dropped} not shown** "
f"(raise ADVISORY_SCAN_MAX_ADJUDICATE). The unshown remain undetermined — do not "
f"treat them as absent."]
L.append("")
for cve in shown:
ev = evidence_bundle(rep, cve)
L.append(f"### {cve}{ev['severity'] or '?'}")
L.append(f"- undecided because: {ev['why_undecided']}")
L.append(f"- source: `{ev['sources'][0]}`"
+ (f" · window {ev['window']['from']}{ev['window']['to']}" if ev["window"] else
" · **no version window** for this image"))
L.append(f"- patched_versions as published: `{ev['patched_as_published']}`")
L.append(f"- vulnerable_range as published: `{ev['vulnerable_range_as_published']}`")
if ev["releases_naming_it"]:
L.append(f"- **releases naming this CVE**: {', '.join(ev['releases_naming_it'][:14])}")
for v in ev.get("affected") or []:
L.append(f"- affects `{v.get('package')}` {v.get('vulnerable_version_range')}"
f"first_patched_version: {v.get('first_patched_version')}")
if ev["references"]:
L.append(f"- references: {', '.join(r.strip() for r in ev['references'][:6])}")
if ev.get("detail_fetch"):
L.append(f"- ⚠ detail fetch failed: {ev['detail_fetch']} (evidence below is from pass 1)")
if ev["summary"]:
L += ["", f"> {ev['summary']}"]
if ev["description"]:
L += ["", "```", (ev["description"] or "").strip()[:2000], "```"]
L.append("")
# ── everything pass 1 DID decide, with the evidence behind each verdict ──────────────────────
# Pass 2 must see the whole picture, not only the leftovers: a deterministic verdict can still
# be wrong (a mis-parsed range, a release note that names a CVE without fixing it), and only a
# reader with the evidence in front of it can catch that.
decided = []
for cve in rep.get("fixed_by_this_upgrade") or []:
e = rep["cves"][cve]
decided.append((cve, "COUNTED", e))
for cve, e in sorted(rep.get("cves", {}).items()):
if e.get("classification") == "outside-window":
decided.append((cve, "excluded (outside window)", e))
if decided:
shown_rows, dropped_rows = decided[:MAX_REVIEW_ROWS], max(0, len(decided) - MAX_REVIEW_ROWS)
L += ["---", "",
f"## Pass 1 decisions — {len(decided)} already judged deterministically", "",
"Review these too. If any verdict looks wrong given its evidence, say so and explain; "
"a correction here changes the count. Silence means you agree.", ""]
if dropped_rows:
L.append(f"⚠ Showing {MAX_REVIEW_ROWS} of {len(decided)}; **{dropped_rows} not shown** "
f"(raise ADVISORY_SCAN_MAX_REVIEW).")
L.append("")
L += ["| CVE | verdict | severity | patched as published | releases naming it | source |",
"|---|---|---|---|---|---|"]
for cve, verdict, e in shown_rows:
rel = e.get("fix_versions_from_release_notes") or []
L.append(f"| {cve} | {verdict} | {e.get('severity') or '?'} | "
f"{(e.get('patched') or '')[:60]} | {', '.join(rel[:5]) or ''} | "
f"{e['sources'][0].split(':',1)[-1]} |")
L.append("")
return "\n".join(L)
def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
images: list[tuple[str, str, str]] | None = None) -> dict:
urls, reg_path = registry_urls(recipe, registry_dir)
report: dict = {
"recipe": recipe,
"from": v_from,
"to": v_to,
"registry": reg_path,
"registry_urls": len(urls),
"sources": [],
"cves": {},
}
if reg_path is None:
report["sources"].append(
{"source": f"registry:{recipe}.md", "status": "error: registry file not found"}
)
def record(cve: str, src: str, **extra):
e = report["cves"].setdefault(cve, {"sources": [], "severity": None, "ghsa": None,
"vulnerable_range": None, "patched": None,
"context": None, "published_at": None,
"description": None, "url": None, "cvss": None})
if src not in e["sources"]:
e["sources"].append(src)
for k, v in extra.items():
if v and not e.get(k):
e[k] = v
for entry in github_advisories(urls):
report["sources"].append({"source": entry["source"], "status": entry["status"],
"found": len(entry.get("advisories", []))})
for a in entry.get("advisories", []):
if a.get("cve"):
record(a["cve"], entry["source"], severity=a.get("severity"), ghsa=a.get("ghsa"),
vulnerable_range=a.get("vulnerable_range"), patched=a.get("patched"),
context=a.get("summary"), published_at=a.get("published_at"),
description=a.get("description"), url=a.get("url"), cvss=a.get("cvss"))
for entry in vendor_pages(urls):
report["sources"].append({"source": entry["source"], "status": entry["status"],
"found": len(entry.get("cves", []))})
for cve in entry.get("cves", []):
record(cve, entry["source"], context=entry["context"].get(cve))
for version in filter(None, (v_from, v_to)):
o = osv(recipe, version)
if o:
report["sources"].append({"source": o["source"], "status": o["status"],
"found": len(o.get("cves", []))})
for cve in o.get("cves", []):
record(cve, o["source"])
# Classify against the upgrade window when we know it: an advisory is "fixed by this upgrade"
# when its patched version is newer than `from` and no newer than `to`.
#
# TWO HARD-WON CONSTRAINTS (2026-08-10, discourse reported a false 133):
# a) The window belongs to ONE image. Advisories from OTHER repos in the registry (redis,
# postgres, nginx sidecars) must NOT be judged by it — redis CVE-2021-21309, patched in
# redis 6.0.11, scored as "fixed" because 6.0.11 sits numerically inside discourse's
# 3.5.3 → 2026.7.1 window. Only the PRIMARY app repo is classified; every other source is
# reported as unclassified so a human/agent still sees it but it never inflates the count.
# b) A version-SCHEME change (semver → calver, 3.5.3 → 2026.7.1) makes numeric ordering
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier. When the
# leading component jumps by more than SCHEME_JUMP we refuse to classify and say so,
# rather than emitting a confident wrong number.
# ── Classification ────────────────────────────────────────────────────────────────────────
# A recipe upgrades SEVERAL images (app + redis/postgres/nginx sidecars), each with its OWN
# version window. Judging every advisory by the app's window is how discourse once reported a
# false 133 (34 of them redis CVEs, incl. one patched in redis 6.0.11 in 2021). So each source
# is classified against ITS OWN window, and the count is the union across windows.
#
# --from/--to → the PRIMARY app repo (first github source in the registry)
# --image NAME=FROM:TO → any other source whose name contains NAME (repeatable),
# e.g. --image redis=7.4:8.10
#
# A source with no window is not classified: its advisories are listed as unclassified so they
# stay visible without inflating the count.
gh_sources = [x["source"] for x in report["sources"] if x["source"].startswith("github-advisories:")]
primary = gh_sources[0] if (gh_sources and (v_from or v_to)) else None
report["primary_source"] = primary
windows = {} # source name -> (from, to)
if primary:
windows[primary] = (v_from, v_to)
for key, wf, wt in (images or []):
for src in gh_sources:
if key.lower() in src.lower() and src not in windows:
windows[src] = (wf, wt)
report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()}
def _classify_window(src, wf, wt):
"""Return (fixed, method, date_window|None, unresolved, indeterminate) for one source.
`indeterminate` = advisories from this source that the method COULD NOT JUDGE (no usable
patched version, or no publish date). They are neither counted nor dismissed."""
kf, kt = _vkey(wf), _vkey(wt)
# A version-SCHEME change (semver 3.5.3 → calver 2026.7.1) makes numeric ordering
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier.
scheme = bool(kf and kt and abs(kt[0] - kf[0]) >= SCHEME_JUMP)
if not scheme:
got, undecidable = set(), set()
for cve, e in report["cves"].items():
if src not in e["sources"]:
continue
patched = e.get("patched") or ""
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", patched)]
if kf and kt and any(_within(kf, kt, c) for c in cands):
got.add(cve)
elif not patched or PLACEHOLDER_RE.search(patched):
# No fix version published ("TBD") or only a placeholder ("7.4.X" — which could
# be 7.4.1, inside the window). We cannot say either way, so say so.
undecidable.add(cve)
return got, "patched version ranges", None, False, undecidable
# DATE FALLBACK: release DATES always order, even across a scheme change. Resolve both
# versions to git tag dates and count advisories PUBLISHED in that window — the method a
# hand count used to establish discourse 3.5.3 (2025-12-30) → 2026.7.1 (2026-07-31) = 123.
owner, _, repo = src.split(":", 1)[1].partition("/")
d_from, d_to = _tag_date(owner, repo, wf), _tag_date(owner, repo, wt)
if d_from and d_to and d_from < d_to:
got = {cve for cve, e in report["cves"].items()
if src in e["sources"] and e.get("published_at")
and d_from < e["published_at"] <= d_to}
undecidable = {cve for cve, e in report["cves"].items()
if src in e["sources"] and not e.get("published_at")}
return got, "advisory publish date (version scheme changed)", (d_from, d_to), False, undecidable
return set(), "unresolved", None, True, set()
fixed_set, methods, date_windows, unresolved_any = set(), {}, {}, False
indeterminate: set = set()
for src, (wf, wt) in windows.items():
got, method, dw, unresolved, undecidable = _classify_window(src, wf, wt)
indeterminate |= undecidable
methods[src] = method
if dw:
date_windows[src] = {"from": dw[0], "to": dw[1]}
if unresolved:
unresolved_any = True
for cve in got:
report["cves"][cve]["classification"] = f"fixed-by-this-upgrade ({method}) via {src}"
fixed_set.add(cve)
unknown = []
for cve, e in report["cves"].items():
if cve in fixed_set:
continue
if not any(src in e["sources"] for src in windows):
e["classification"] = "unclassified: no versions given for this image"
unknown.append(cve)
else:
e.setdefault("classification", "outside-window")
if e["classification"] == "outside-window":
pass
else:
unknown.append(cve)
report["classified_by"] = methods
if date_windows:
report["date_window"] = date_windows
# THIRD METHOD: before declaring an advisory undecidable, look for the CVE id in the project's
# own release notes. A tag that names it, inside the window, IS the fix version the advisory
# failed to publish. Deterministic and citable — not a judgement call.
resolved_by_release = {}
for cve in sorted(indeterminate - fixed_set):
e = report["cves"][cve]
for src in e["sources"]:
if src not in windows:
continue
wf, wt = windows[src]
kf, kt = _vkey(wf), _vkey(wt)
if not (kf and kt):
continue
hits = [t for t in release_fix_versions(src, cve) if _within(kf, kt, _vkey(t))]
if hits:
fixed_set.add(cve)
resolved_by_release[cve] = sorted(hits)
e["classification"] = (f"fixed-by-this-upgrade (named in release notes "
f"{', '.join(sorted(hits))}) via {src}")
e["fix_versions_from_release_notes"] = sorted(hits)
break
if resolved_by_release:
report["resolved_by_release_notes"] = resolved_by_release
indeterminate -= fixed_set
for cve in indeterminate:
report["cves"][cve]["classification"] = "indeterminate: no fix version published"
unknown = [c for c in unknown if c not in fixed_set]
report["fixed_by_this_upgrade"] = sorted(fixed_set)
report["indeterminate"] = sorted(indeterminate)
report["cve_count_indeterminate"] = len(indeterminate)
report["unclassified"] = sorted(unknown)
# NEVER report 0 for something we could not determine — a 0 asserts safety. If ANY requested
# window could not be ordered at all, the total is UNKNOWN rather than a partial number.
report["count_known"] = not unresolved_any
report["cve_count_fixed"] = len(fixed_set) if not unresolved_any else None
report["cve_count_total_seen"] = len(report["cves"])
# Only GENUINE failures make a count unreliable. "no-advisories-published" (404: the repo has
# no advisory feed) and "skipped: template URL" are benign and must not degrade the verdict.
report["sources_failed"] = [
s["source"]
for s in report["sources"]
if not (s["status"] == "ok" or s["status"].startswith(("no-advisories-published", "skipped:")))
]
report["sources_benign"] = [
s["source"]
for s in report["sources"]
if s["status"].startswith(("no-advisories-published", "skipped:"))
]
return report
def markdown(rep: dict) -> str:
"""Human/agent-readable block for pasting into the per-recipe upgrade log."""
L = [f"### Advisory scan (deterministic pre-step) — {rep['recipe']} "
f"{rep.get('from') or '?'}{rep.get('to') or '?'}"]
if not rep.get("count_known", True):
L.append("\n**CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count.**")
L.append("\n⚠ This is NOT zero. A version-scheme change (e.g. semver → calver) makes numeric "
"ordering meaningless across this jump, so no advisory could be classified. Render "
"this recipe's cve cell as `?`, never `0`. Read the vendor's release notes for the "
"jump and count by hand.")
if rep["unclassified"]:
L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes "
f"other images in this recipe: " + ", ".join(rep["unclassified"][:12]))
if rep["sources_failed"]:
L.append(f"\n⚠ sources that FAILED: {', '.join(rep['sources_failed'])}")
L.append(f"\n_Sources checked: {len(rep['sources'])} ({rep['registry_urls']} registry URLs + "
f"advisory APIs). This scan is ADDITIVE — it does not replace the release-note "
f"reading in the upgrade step._")
return "\n".join(L)
ind = rep.get("cve_count_indeterminate") or 0
if rep["fixed_by_this_upgrade"]:
floor = " (at least — see undetermined below)" if ind else ""
L.append(f"\n**CVEs fixed by this upgrade: {rep['cve_count_fixed']}**{floor}\n")
cb = rep.get("classified_by") or {}
if isinstance(cb, dict) and cb:
for src, method in cb.items():
dw = (rep.get("date_window") or {}).get(src)
win = (rep.get("windows") or {}).get(src, {})
span = f"{win.get('from')}{win.get('to')}"
extra = (f" (dates {dw['from'][:10]}{dw['to'][:10]})" if dw else "")
L.append(f"_{src.split(':',1)[-1]}: {span} — counted by {method}{extra}._")
L.append("")
L.append("| CVE | severity | fixed in | advisory | source |")
L.append("|---|---|---|---|---|")
for cve in rep["fixed_by_this_upgrade"]:
e = rep["cves"][cve]
L.append(f"| {cve} | {e.get('severity') or '?'} | {e.get('patched') or '?'} | "
f"{e.get('ghsa') or '-'} | {e['sources'][0]} |")
else:
L.append("\n**CVEs fixed by this upgrade: 0 identified by the deterministic scan.**")
if ind:
L.append(f"\n⚠ **{ind} advisory/advisories could NOT be judged** — the vendor published no fix "
f"version (GitHub carries `TBD` or a placeholder like `7.4.X`) and the vulnerable "
f"range is open-ended, so neither method can tell whether this upgrade fixed them. "
f"They are NOT included in the count above and must NOT be read as unaffected: "
+ ", ".join(f"{c} ({rep['cves'][c].get('severity') or '?'})"
for c in rep["indeterminate"][:15])
+ ("" if len(rep["indeterminate"]) > 15 else "")
+ "\n\nRe-run with `--adjudicate` for the collected evidence on each, to judge.")
if rep["unclassified"]:
L.append(f"\nSeen but not version-classified ({len(rep['unclassified'])}) — includes advisories "
f"from OTHER images in this recipe (sidecars), which this window cannot judge: "
+ ", ".join(rep["unclassified"][:12]))
if rep["sources_failed"]:
L.append(f"\n⚠ sources that FAILED (treat counts as incomplete): {', '.join(rep['sources_failed'])}")
L.append(f"\n_Sources checked: {len(rep['sources'])} "
f"({rep['registry_urls']} registry URLs + advisory APIs). This scan is ADDITIVE — it does "
f"not replace the release-note reading in the upgrade step._")
return "\n".join(L)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("recipe")
ap.add_argument("--from", dest="v_from", default=None)
ap.add_argument("--to", dest="v_to", default=None)
ap.add_argument("--json", action="store_true", help="emit raw JSON instead of markdown")
ap.add_argument("--adjudicate", action="store_true",
help="SECOND PASS: for advisories the deterministic pass could not judge (no "
"fix version published), fetch their full text + references and append a "
"block for the agent to judge. Additive: it never changes the count above.")
ap.add_argument("--registry", default=REGISTRY_DIR)
ap.add_argument("--image", action="append", default=[], metavar="NAME=FROM:TO",
help="a sidecar image and the versions it moved between, e.g. "
"--image redis=7.4:8.10 (repeatable). NAME matches a source repo name; "
"its advisories are then counted against ITS OWN versions instead of "
"being left unclassified.")
a = ap.parse_args()
images = []
for spec in a.image:
name, _, rng = spec.partition('=')
vf, _, vt = rng.partition(':')
if name and vf and vt:
images.append((name, vf, vt))
else:
print(f'ignoring malformed --image {spec!r} (expected NAME=FROM:TO)', file=sys.stderr)
rep = scan(a.recipe, a.v_from, a.v_to, a.registry, images)
if a.adjudicate:
rep["adjudication"] = [evidence_bundle(rep, c)
for c in needs_judgement(rep)[:MAX_ADJUDICATE]]
if a.json:
print(json.dumps(rep, indent=2))
else:
print(markdown(rep))
if a.adjudicate:
print(adjudication_block(rep))
return 0
if __name__ == "__main__":
sys.exit(main())
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Independent audit of advisory-scan's counts.
Deliberately does NOT reuse the scanner's classifier. Re-parses patched versions with a separate
semver implementation and re-derives membership, then diffs against what the scanner concluded.
Anything the two disagree on is a miscategorization in one of them.
"""
import importlib.util, json, re, sys
spec = importlib.util.spec_from_file_location("A", "/srv/cc-ci-orch/cc-ci-plan/advisory-scan.py")
A = importlib.util.module_from_spec(spec); spec.loader.exec_module(A)
REG = "/srv/cc-ci-orch/cc-ci-plan/upstream"
def sv(s):
"""Independent semver parse: strict 3-tuple, missing parts are 0."""
m = re.match(r"^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", s or "")
if not m:
return None
return tuple(int(x) if x else 0 for x in m.groups())
def in_window(f, t, patched_expr):
"""Independent membership: any patched token strictly above f and at most t."""
kf, kt = sv(f), sv(t)
for tok in re.findall(r"\d+(?:\.\d+)*", patched_expr or ""):
c = sv(tok)
if c and kf and kt and kf < c <= kt:
return True
return False
_RELS = {}
def fetch_releases(owner_repo):
"""Independent releases fetch — deliberately NOT the scanner's cache or pagination helper."""
if owner_repo in _RELS:
return _RELS[owner_repo]
import urllib.request
tok = None
try:
tok = open("/srv/cc-ci/.github-token").read().strip()
except OSError:
pass
h = {"User-Agent": "audit", "Accept": "application/vnd.github+json"}
if tok:
h["Authorization"] = f"Bearer {tok}"
out, url, pages = [], f"https://api.github.com/repos/{owner_repo}/releases?per_page=100", 0
while url and pages < 4:
req = urllib.request.Request(url, headers=h)
with urllib.request.urlopen(req, timeout=45) as r:
rows = json.load(r)
link = r.headers.get("Link", "") or ""
out += [(x.get("tag_name") or "", (x.get("body") or "") + " " + (x.get("name") or ""))
for x in rows]
url = None
for part in link.split(","):
if 'rel="next"' in part:
url = part.split(";")[0].strip().strip("<>")
pages += 1
_RELS[owner_repo] = out
return out
def audit(recipe, vf, vt, images=None, label=""):
rep = A.scan(recipe, vf, vt, REG, images)
print(f"\n{'='*78}\n{recipe} {vf}{vt} {label}\n{'='*78}")
print(f"scanner count = {rep['cve_count_fixed']} known={rep['count_known']} "
f"failed_sources={rep['sources_failed']}")
counted = set(rep["fixed_by_this_upgrade"])
by_src = {}
for cve, e in rep["cves"].items():
by_src.setdefault(e["sources"][0], []).append((cve, e))
windows = rep["windows"]
total_mismatch = 0
for src, win in windows.items():
f, t = win["from"], win["to"]
method = rep["classified_by"][src]
rows = by_src.get(src, [])
scanner_here = {c for c, e in rows if c in counted}
print(f"\n ── {src} ({f}{t}) method={method} advisories={len(rows)}")
if "publish date" in method:
dw = rep["date_window"][src]
indep = {c for c, e in rows
if e.get("published_at") and dw["from"] < e["published_at"] <= dw["to"]}
print(f" date window {dw['from'][:10]}{dw['to'][:10]}")
else:
indep = {c for c, e in rows if in_window(f, t, e.get("patched"))}
# Independently redo the release-note method: fetch the repo's releases ourselves and
# confirm a tag NAMING the CVE really does fall inside (f, t].
kf, kt = sv(f), sv(t)
owner_repo = src.split(":", 1)[1]
rels = fetch_releases(owner_repo)
for c, e in rows:
if c in indep:
continue
naming = [tag for tag, body in rels if c in body]
if any(kf < sv(tag) <= kt for tag in naming if sv(tag)):
indep.add(c)
missed = indep - scanner_here
extra = scanner_here - indep
print(f" scanner counted {len(scanner_here)} | independent {len(indep)}"
f" | missed_by_scanner {len(missed)} | over_counted {len(extra)}")
if missed:
print(f" !! MISSED: {sorted(missed)}")
for c in sorted(missed):
print(f" {c} patched={dict(rows)[c].get('patched')!r}")
if extra:
print(f" !! OVER-COUNTED: {sorted(extra)}")
for c in sorted(extra):
print(f" {c} patched={dict(rows)[c].get('patched')!r}")
total_mismatch += len(missed) + len(extra)
# Anything counted that belongs to NO window would be a leak.
leaked = {c for c in counted if not any(s in rep["cves"][c]["sources"] for s in windows)}
if leaked:
print(f"\n !! COUNTED BUT OUTSIDE EVERY WINDOW: {sorted(leaked)}")
total_mismatch += len(leaked)
# Unclassified entries that belong to a WINDOWED source would mean a judged CVE was dropped.
dropped = [c for c in rep["unclassified"]
if any(s in rep["cves"][c]["sources"] for s in windows)]
if dropped:
print(f"\n !! UNCLASSIFIED DESPITE HAVING A WINDOW: {sorted(dropped)[:10]}")
total_mismatch += len(dropped)
unwindowed = {}
for cve in rep["unclassified"]:
unwindowed.setdefault(rep["cves"][cve]["sources"][0], []).append(cve)
if unwindowed:
print("\n unclassified by source (expected: images with no --image given):")
for s, cs in sorted(unwindowed.items()):
print(f" {len(cs):4d} {s}")
print(f"\n VERDICT: {'CLEAN' if total_mismatch == 0 else f'{total_mismatch} DISAGREEMENTS'}")
return total_mismatch, rep
if __name__ == "__main__":
bad = 0
bad += audit("gitea", "1.27.0", "1.27.1")[0]
bad += audit("discourse", "3.5.3", "2026.7.1")[0]
bad += audit("discourse", "3.5.3", "2026.7.1", [("redis", "7.4", "8.10")], "+redis sidecar")[0]
bad += audit("keycloak", "26.7.0", "26.7.1")[0]
bad += audit("mailu", "2024.06.55", "2024.06.57", [("redis", "8.8.0", "8.10.0")], "+redis")[0]
bad += audit("n8n", "1.123.0", "2.18.1")[0]
print(f"\n\n{'#'*78}\nOVERALL: {'CLEAN — no disagreements' if bad == 0 else f'{bad} DISAGREEMENTS'}")
sys.exit(1 if bad else 0)
+32
View File
@@ -70,6 +70,34 @@ def start(mode, date):
log(f"{SESSION} busy with a report — leaving it"); return
log(f"{SESSION} exists (idle/leftover) — killing first"); kill_session(); time.sleep(1)
# Unique-name invariant (same as upgrader/supervisor): archive-rename every older session
# titled SESSION so the one this launch creates is the ONLY 'cc-ci-report' in the web UI.
# Archived names start with 'archive-' (operator convention 2026-08-04).
#
# AND clear the stale session PIN. The pin file is what the shared watchdog resolves via
# lu._session_id(); if a PREVIOUS run's pin survives, the watchdog inspects that old (already
# DONE_MARKER-bearing) session, declares "run completed" and exits within one poll — leaving
# the new run unwatched. That regression silently un-watchdogged the 2026-08-07 finish-run's
# report step (pin dated 2026-08-04). The pin is re-established after launch, below.
_lu = None
_prev_ids = set()
try:
os.environ["UPGRADER_SESSION"] = SESSION # scope the shared helpers to THIS session name
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location(
"launch_upgrader", os.path.join(os.path.dirname(os.path.realpath(__file__)), "launch-upgrader.py"))
_lu = _ilu.module_from_spec(_spec); _spec.loader.exec_module(_lu)
_lu._archive_stale_titles(SESSION)
_rows = _lu._server_get("/session") or []
_rows = _rows if isinstance(_rows, list) else _rows.get("data", [])
_prev_ids = {s.get("id") for s in _rows}
try:
_lu.STATE_SID_FILE.unlink()
except OSError:
pass
except Exception as e:
log(f" (archive-rename/pin-clear skipped: {e})")
kf = Path(LOG_DIR) / f".kickoff-{SESSION}.txt"
kf.write_text(build_kickoff(date))
model_flag = f"--model '{MODEL}'" if MODEL else ""
@@ -90,6 +118,10 @@ def start(mode, date):
log(f"starting {SESSION} (backend={BACKEND}, tier={TIER}, model={MODEL}, date={date or 'today'})")
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", cwd, cmd])
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_DIR}/{SESSION}.log'"])
if BACKEND == "opencode" and _lu is not None:
# Re-establish the pin on THIS run's session, so the watchdog (spawned below) resolves the
# new session rather than falling back to a title lookup or a stale pin.
_lu._pin_new_session(_prev_ids)
if BACKEND == "opencode":
if OPENCODE_SHARE:
log(f" attached to {OPENCODE_SERVER} → http://oc.commoninternet.net +public --share link")
+24 -3
View File
@@ -98,6 +98,12 @@ def spawn_supervisor(sid, reason):
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
if _sup_alive():
_sup_kill(); time.sleep(1)
# Unique-name invariant (same as the upgrader): archive-rename every older session titled
# SUP_SESSION so the one this launch creates is the ONLY 'cc-ci-supervisor' in the web UI.
try:
lu._archive_stale_titles(SUP_SESSION)
except Exception:
pass
kf = Path(LOG_DIR) / f".kickoff-{SUP_SESSION}.txt"
kf.write_text(build_kickoff(sid, reason))
share = "--share" if OPENCODE_SHARE else ""
@@ -137,9 +143,24 @@ def _gate():
# a live `opencode run … -s <sid> --attach` proc, or a log touched within the stall window.
pids = lu._run_pids(sid)
idle = lu._session_idle_min()
if pids or (idle is not None and idle < lu.STALL_MIN):
via = f"{len(pids)} live run proc(s)" if pids else f"log idle {idle:.0f}m < {lu.STALL_MIN:.0f}m"
return False, sid, f"upgrader run progressing ({via}) — leaving it"
advancing = idle is not None and idle < lu.STALL_MIN
# PROGRESS REQUIRES THE SESSION TO BE ADVANCING — a live proc alone is not enough. A run walled
# by the provider keeps its process alive and spinning while emitting nothing (2026-08-07: 3
# days, zero output). Treating "proc exists" as progress is what deadlocked the gate.
if advancing:
return False, sid, f"upgrader run progressing (session advanced {idle:.0f}m ago) — leaving it"
if pids and lu._billing_blocked():
# NEVER kill it: it may resume when the wall lifts, and its context is the run's state.
# Surface it loudly instead — this is an operator-actionable condition, not self-healing.
return False, sid, (
f"run BLOCKED on a provider billing/usage wall ({len(pids)} proc(s) alive, session idle "
f"{idle:.0f}m) — NOT killing (resumable once the wall lifts); operator action required"
)
if pids:
log(
f"note: {len(pids)} live run proc(s) but session idle {idle:.0f}m ≥ "
f"{lu.STALL_MIN:.0f}m — treating as stalled, not progressing"
)
# The per-run watchdog owns PROMPT recovery (resume on proc-death/stall) and is the single writer
# while it lives. Defer to it — it gives up (exits its tmux) only after MAX_RESUMES fail, i.e. the
# run is stuck in a way a bare resume can't fix (e.g. disk-full). THEN the supervisor takes over.
+141 -11
View File
@@ -184,6 +184,18 @@ def start(mode="use-or-create"):
if SESSION == "cc-ci-upgrader":
prereclaim_cc_ci()
# Unique-name invariant: the run we are about to launch becomes THE 'cc-ci-upgrader' in the
# web UI; every older run gets an archive title. Also snapshot existing ids so the new
# session can be pinned unambiguously after launch.
_archive_stale_titles()
_prev_rows = _server_get("/session") or []
_prev_rows = _prev_rows if isinstance(_prev_rows, list) else _prev_rows.get("data", [])
_prev_ids = {s.get("id") for s in _prev_rows}
try:
STATE_SID_FILE.unlink()
except OSError:
pass
kf = Path(LOG_DIR) / f".kickoff-{SESSION}.txt"
kf.write_text(build_kickoff())
@@ -217,6 +229,8 @@ def start(mode="use-or-create"):
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION,
f"cat >> '{LOG_DIR}/{SESSION}.log'"])
log(f"started. attach: tmux attach -t {SESSION} log: {LOG_DIR}/{SESSION}.log")
if BACKEND == "opencode":
_pin_new_session(_prev_ids)
# For the opencode backend, spawn a watchdog that auto-resumes the run if the opencode
# usage-limit (429) stalls it mid-run (it does NOT self-resume). See watchdog().
if BACKEND == "opencode" and os.environ.get("UPGRADER_WATCHDOG", "1") == "1":
@@ -246,13 +260,95 @@ def _server_get(path):
except Exception:
return None
STATE_SID_FILE = Path(LOG_DIR) / f".{SESSION}-session-id"
def _server_patch(path, body):
req = _ureq.Request(OPENCODE_SERVER + path, method="PATCH",
headers={"Content-Type": "application/json"},
data=_json.dumps(body).encode())
try:
with _ureq.urlopen(req, timeout=15) as r:
return r.status
except Exception:
return None
def _db_created_ms(sid):
"""Session creation time from the opencode sqlite DB — the /session API rows carry NO
time fields, which is exactly how the 2026-08-04 watchdog resumed the WRONG (old, giant)
session: sorting on a missing key degraded to server list order."""
try:
import sqlite3
db = sqlite3.connect("file:" + os.path.expanduser(
"~/.local/share/opencode/opencode.db") + "?mode=ro", uri=True)
row = db.execute("SELECT time_created FROM session WHERE id=?", (sid,)).fetchone()
db.close()
return row[0] if row else 0
except Exception:
return 0
def _archive_stale_titles(title=None, label=None):
"""Rename every existing top-level session with the given canonical title to a dated
archive title, so EXACTLY ONE session ever carries the canonical name (the one the next
launch creates). Keeps the run trivially findable in the opencode web UI and makes the
title lookup in _session_id() unambiguous. Old runs stay browsable under
'<label> — <date>'. Also used by launch-supervisor.py for its own session name."""
title = title or SESSION
# Archive names always start with 'archive-' (operator convention 2026-08-04) so they
# sort/filter together in the web UI: 'archive-<original-title> — <date>'.
label = label or f"archive-{title}"
rows = _server_get("/session") or []
rows = rows if isinstance(rows, list) else rows.get("data", [])
for s in rows:
if s.get("title") == title and not (s.get("parentID") or s.get("parentId")):
created = _db_created_ms(s["id"])
d = datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created else "unknown-date"
_server_patch(f"/session/{s['id']}", {"title": f"{label} {d}"})
log(f" archived old session {s['id'][:20]}'{label} {d}'")
def _session_id():
"""Newest top-level opencode session titled like SESSION (the run we manage)."""
"""The opencode session this launcher manages. Prefers the pinned id recorded at launch
(LOG_DIR/.{SESSION}-session-id) — title lookup is only the fallback, and thanks to
_archive_stale_titles() at most one top-level session carries the title. Never trust
server list order (see _db_created_ms)."""
try:
pinned = STATE_SID_FILE.read_text().strip()
# Validate directly — the /session LIST is paginated (~100 rows), so membership
# scans miss older/newer sessions; a direct GET is authoritative.
if pinned and (_server_get(f"/session/{pinned}") or {}).get("id") == pinned:
return pinned
except OSError:
pass
rows = _server_get("/session") or []
rows = rows if isinstance(rows, list) else rows.get("data", [])
cands = [s for s in rows if s.get("title") == SESSION and not (s.get("parentID") or s.get("parentId"))]
cands.sort(key=lambda s: (s.get("time") or {}).get("created") or 0, reverse=True)
return cands[0]["id"] if cands else None
cands.sort(key=lambda s: _db_created_ms(s.get("id")), reverse=True)
if cands:
try:
STATE_SID_FILE.write_text(cands[0]["id"])
except OSError:
pass
return cands[0]["id"]
return None
def _pin_new_session(prev_ids, wait_sec=45):
"""After launching a fresh run, discover the NEW top-level SESSION-titled session (one not
in prev_ids) and pin its id to the state file, so the watchdog can never grab an old one."""
deadline = _time.time() + wait_sec
while _time.time() < deadline:
rows = _server_get("/session") or []
rows = rows if isinstance(rows, list) else rows.get("data", [])
for s in rows:
if (s.get("title") == SESSION and not (s.get("parentID") or s.get("parentId"))
and s.get("id") not in prev_ids):
try:
STATE_SID_FILE.write_text(s["id"])
except OSError:
pass
log(f" pinned managed session id {s['id'][:20]}{STATE_SID_FILE.name}")
return s["id"]
_time.sleep(3)
log(" WARNING: could not discover the new session id to pin (title lookup remains the fallback)")
return None
def _session_idle_min():
"""Minutes since the managed run last ADVANCED — measured across the whole session TREE (the
@@ -321,25 +417,55 @@ def _limit_retry_after():
return 0
def _run_pids(sid=None):
"""PIDs of live `opencode run` procs for THIS session (via /proc scan — never matches self)."""
"""PIDs of live `opencode run` procs for THIS session (via /proc scan — never matches self).
Matches on FLAG VALUES (`--title <SESSION>` / `-s|--session <sid>`), never a substring of the
whole cmdline. The substring form caused the 2026-08-07 three-day deadlock: an agent's kickoff
PROMPT is passed as an argv element, and the supervisor's prompt text contains the literal
"cc-ci-upgrader", so the supervisor's own (billing-hung) agent matched as a live upgrader run —
the hourly gate then read its own corpse as "run progressing" and no-opped ~60 times."""
me, out = os.getpid(), []
for p in os.listdir("/proc"):
if not p.isdigit() or int(p) == me:
continue
try:
cl = open(f"/proc/{p}/cmdline", "rb").read().split(b"\0")
cl = [c for c in open(f"/proc/{p}/cmdline", "rb").read().split(b"\0") if c]
except Exception:
continue
joined = b" ".join(cl)
if not (b"opencode" in joined and b"run" in cl and b"--attach" in cl):
if not cl or b"opencode" not in cl[0] or b"run" not in cl or b"--attach" not in cl:
continue
# Scope to THIS managed session only: a fresh run carries `--title <SESSION>`, a resumed
# run carries `-s <sid>`. Without this, the report watchdog would kill the idle upgrader
# run (and vice-versa) since both are `opencode run … --attach`.
if SESSION.encode() in joined or (sid and sid.encode() in joined):
def _flag(names):
for i, a in enumerate(cl[:-1]):
if a in names:
return cl[i + 1]
return None
title = _flag((b"--title",))
s_val = _flag((b"-s", b"--session"))
if title == SESSION.encode() or (sid and s_val == sid.encode()):
out.append(int(p))
return out
def _billing_blocked(window_bytes=4000):
"""True when the tail of this run's log shows a provider billing/usage wall.
Such a process must NEVER be killed: it may resume when the wall lifts, and killing it can
lose in-flight work. It is also NOT progress — it can spin for days emitting nothing (observed
2026-08-07..10), so the gate reports it as BLOCKED and leaves recovery to the operator."""
try:
with open(LOG_FILE, "rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - window_bytes))
tail = f.read().decode(errors="replace").lower()
except OSError:
return False
return any(
s in tail
for s in ("spending limit", "insufficient balance", "usage limit", "usagelimiterror")
)
def _completed():
# Done only when the MODEL signs off with DONE_MARKER as its FINAL word: the marker in the LAST
# assistant TEXT (prose) message. This guards THREE false-positives that each abandoned a run:
@@ -399,6 +525,10 @@ def resume(reason="manual"):
f"--model '{MODEL}' {share} --attach '{OPENCODE_SERVER}' --dir '{WORKDIR}' \"$(cat '{kf}')\"")
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", WORKDIR, cmd])
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_FILE}'"])
try:
STATE_SID_FILE.write_text(sid) # a resume continues the SAME session — keep it pinned
except OSError:
pass
log(f"resume: relaunched {SESSION} (session {sid})")
# Every resume must be self-healing: ensure a watchdog is watching this run. Skip if one is
# already alive — notably when the watchdog ITSELF called resume (it lives in {SESSION}-watchdog),
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env python3
"""Tests for advisory-scan.py.
Two tiers:
OFFLINE (default) — pure logic, fixtures injected in place of the network. Fast, deterministic,
no token, no rate limit. These encode every classification rule and every guarantee the CVE count
makes, including the specific production defects that motivated them.
LIVE (--live) — re-derives the CVE counts published in the week-2026-08-07 report against the real
advisory APIs. Slow, needs network + ideally a GitHub token. Run before changing classification.
Usage:
python3 test-advisory-scan.py # offline only
python3 test-advisory-scan.py --live # offline + historic report regressions
"""
from __future__ import annotations
import importlib.util
import io
import json
import os
import pathlib
import sys
import unittest
import unittest.mock
HERE = pathlib.Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("advisory_scan", HERE / "advisory-scan.py")
A = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(A)
# ── fixture helpers ───────────────────────────────────────────────────────────────────────────────
def adv(cve, patched=None, published=None, severity="high", ghsa=None):
"""One GitHub advisory row as github_advisories() would emit it."""
return {"cve": cve, "ghsa": ghsa or f"GHSA-fake-{cve[-4:]}", "severity": severity,
"summary": f"summary for {cve}", "vulnerable_range": None, "patched": patched,
"url": None, "published_at": published}
def gh(owner_repo, advisories, status="ok"):
return {"source": f"github-advisories:{owner_repo}", "status": status, "advisories": advisories}
def vendor(url, cves=(), status="ok"):
return {"source": url, "status": status, "cves": list(cves),
"context": {c: f"...{c}..." for c in cves}}
def run_scan(gh_entries=(), vendor_entries=(), tag_dates=None, *, v_from=None, v_to=None,
images=None, recipe="fixture", urls=None, releases=None):
"""scan() with every network call replaced by fixtures.
`releases` maps CVE id -> tags whose release notes name it (the third, release-note method)."""
tag_dates = tag_dates or {}
releases = releases or {}
urls = urls if urls is not None else ["https://github.com/app/app"]
with unittest.mock.patch.object(A, "registry_urls", lambda r, d: (list(urls), "/fake/reg.md")), \
unittest.mock.patch.object(A, "github_advisories", lambda u: list(gh_entries)), \
unittest.mock.patch.object(A, "vendor_pages", lambda u: list(vendor_entries)), \
unittest.mock.patch.object(A, "osv", lambda r, v: None), \
unittest.mock.patch.object(A, "_tag_date", lambda o, r, v: tag_dates.get(v)), \
unittest.mock.patch.object(A, "release_fix_versions", lambda src, cve: list(releases.get(cve, []))):
return A.scan(recipe, v_from, v_to, "/fake", images)
def parse_image_args(argv):
"""Drive main()'s --image parsing exactly as the CLI does, returning the tuples scan() receives."""
captured = {}
def fake_scan(recipe, vf, vt, reg, images):
captured["images"] = images
return {"recipe": recipe, "from": vf, "to": vt, "registry": reg, "registry_urls": 0,
"sources": [], "cves": {}, "fixed_by_this_upgrade": [], "unclassified": [],
"sources_failed": [], "sources_benign": [], "count_known": True,
"cve_count_fixed": 0, "windows": {}, "classified_by": {}}
err = io.StringIO()
with unittest.mock.patch.object(A, "scan", fake_scan), \
unittest.mock.patch.object(sys, "argv", ["advisory-scan.py", *argv]), \
unittest.mock.patch.object(sys, "stdout", io.StringIO()), \
unittest.mock.patch.object(sys, "stderr", err):
A.main()
return captured["images"], err.getvalue()
# ── A. version ordering ───────────────────────────────────────────────────────────────────────────
class TestVersionKey(unittest.TestCase):
def test_strips_prefix_and_suffix(self):
self.assertEqual(A._vkey("v1.27.1"), (1, 27, 1))
self.assertEqual(A._vkey("1.27.1-rootless"), (1, 27, 1))
self.assertEqual(A._vkey("2024.06.55"), (2024, 6, 55))
def test_empty_and_none(self):
self.assertEqual(A._vkey(None), ())
self.assertEqual(A._vkey(""), ())
def test_dotted_minor_is_numeric_not_lexical(self):
# The bug this guards: "8.10" must be NEWER than "8.2.3". String compare says otherwise.
self.assertGreater(A._vkey("8.10"), A._vkey("8.2.3"))
self.assertGreater(A._vkey("1.27.10"), A._vkey("1.27.9"))
def test_shorter_prefix_orders_below_its_own_patch(self):
# 7.4 < 7.4.1, so a CVE patched in 7.4.1 IS fixed by moving off a bare 7.4 pin.
self.assertLess(A._vkey("7.4"), A._vkey("7.4.1"))
class TestWindowMembership(unittest.TestCase):
"""(from, to] membership — exclusive lower, inclusive upper, compared zero-padded."""
def _in(self, f, t, c):
return A._within(A._vkey(f), A._vkey(t), A._vkey(c))
def test_bounds(self):
self.assertTrue(self._in("1.27.0", "1.27.1", "1.27.1")) # upper inclusive
self.assertFalse(self._in("1.27.0", "1.27.1", "1.27.0")) # lower exclusive
self.assertFalse(self._in("1.27.0", "1.27.1", "1.26.9"))
self.assertFalse(self._in("1.27.0", "1.27.1", "1.28.0"))
def test_bare_major_upper_bound_includes_its_dot_zero(self):
# Regression: plain tuple order makes (18,) < (18,0), so a fix in 18.0 fell OUTSIDE a
# window ending at 18. Bare major tags are the norm for sidecars (postgres:18, redis:8).
self.assertTrue(self._in("17", "18", "18.0"))
self.assertTrue(self._in("7", "8", "8.0"))
self.assertTrue(self._in("7.4", "8.10", "8.0.4"))
def test_bare_major_upper_bound_excludes_later_patches(self):
# Conservative on the other side: nothing proves which 18.x a floating tag resolved to.
self.assertFalse(self._in("17", "18", "18.5"))
def test_bare_version_is_read_literally_as_dot_zero(self):
# from="8" means 8.0, so a fix in 8.0.4 is inside a window that ends at 9.
self.assertTrue(self._in("8", "9", "8.0.4"))
self.assertFalse(self._in("8", "9", "8.0")) # == the stated lower bound
def test_prefix_lower_bound_still_counts_its_patches(self):
self.assertTrue(self._in("7.4", "8.10", "7.4.1"))
self.assertTrue(self._in("7.4", "8.10", "7.4.6"))
def test_the_false_133_cve_stays_out(self):
self.assertFalse(self._in("7.4", "8.10", "6.0.11"))
# ── B. registry URL extraction ────────────────────────────────────────────────────────────────────
class TestRegistryUrls(unittest.TestCase):
def _write(self, tmp, text):
p = pathlib.Path(tmp) / "r.md"
p.write_text(text)
return A.registry_urls("r", tmp)
def test_strips_trailing_markdown_punctuation(self):
# Production defect: a captured backtick 404'd the fetch and rendered n8n/immich as '?'.
import tempfile
with tempfile.TemporaryDirectory() as tmp:
urls, _ = self._write(tmp, "see `https://docs.n8n.io/release-notes/` and "
"**https://example.com/sec.html**, plus https://a.test/x.")
self.assertIn("https://docs.n8n.io/release-notes/", urls)
self.assertIn("https://example.com/sec.html", urls)
self.assertIn("https://a.test/x", urls)
self.assertFalse([u for u in urls if u.endswith(("`", "*", ".", ","))])
def test_dedupes_and_reports_missing_registry(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
urls, path = self._write(tmp, "https://a.test/x https://a.test/x")
self.assertEqual(urls.count("https://a.test/x"), 1)
self.assertTrue(path.endswith("r.md"))
urls, path = A.registry_urls("does-not-exist", "/nonexistent-dir")
self.assertEqual((urls, path), ([], None))
# ── C. --image argument parsing ───────────────────────────────────────────────────────────────────
class TestImageArgParsing(unittest.TestCase):
def test_single_and_repeated(self):
imgs, _ = parse_image_args(["r", "--image", "redis=7.4:8.10"])
self.assertEqual(imgs, [("redis", "7.4", "8.10")])
imgs, _ = parse_image_args(["r", "--image", "redis=7.4:8.10", "--image", "postgres=17:18"])
self.assertEqual(imgs, [("redis", "7.4", "8.10"), ("postgres", "17", "18")])
def test_malformed_is_skipped_with_a_warning_not_a_crash(self):
# It is an ADDITIVE pre-step: one typo must not abort the upgrade's scan step.
for bad in ("redis=7.4", "redis", "=7.4:8.10", "redis=:8.10", "redis=7.4:"):
imgs, err = parse_image_args(["r", "--image", bad])
self.assertEqual(imgs, [], f"{bad!r} should be rejected")
self.assertIn("malformed", err)
def test_good_and_bad_mixed_keeps_the_good(self):
imgs, err = parse_image_args(["r", "--image", "redis=7.4:8.10", "--image", "nope"])
self.assertEqual(imgs, [("redis", "7.4", "8.10")])
self.assertIn("malformed", err)
# ── D. classification ─────────────────────────────────────────────────────────────────────────────
class TestClassificationBoundaries(unittest.TestCase):
def _one(self, patched, v_from="1.27.0", v_to="1.27.1"):
rep = run_scan([gh("app/app", [adv("CVE-2026-0001", patched=patched)])],
v_from=v_from, v_to=v_to)
return rep
def test_patched_at_upper_bound_counts(self):
self.assertEqual(self._one("1.27.1")["fixed_by_this_upgrade"], ["CVE-2026-0001"])
def test_patched_at_lower_bound_does_not_count(self):
# Already fixed in the version we were ON — this upgrade did not fix it.
rep = self._one("1.27.0")
self.assertEqual(rep["fixed_by_this_upgrade"], [])
self.assertEqual(rep["cve_count_fixed"], 0)
def test_patched_below_and_above_window_do_not_count(self):
self.assertEqual(self._one("1.26.0")["fixed_by_this_upgrade"], [])
self.assertEqual(self._one("1.28.0")["fixed_by_this_upgrade"], [])
def test_any_of_several_patched_lines_counts(self):
# n8n regression: one advisory patches several release lines; reading only the first
# dropped the line the deployment was on (CVE-2026-42231/42232 misclassified).
rep = self._one("1.123.32; 2.17.4; 2.18.1", v_from="2.17.0", v_to="2.17.4")
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2026-0001"])
def test_no_patched_data_is_not_counted(self):
self.assertEqual(self._one(None)["fixed_by_this_upgrade"], [])
class TestPerImageWindows(unittest.TestCase):
"""The false-133 family of defects: an image must only ever be judged by its OWN versions."""
APP = gh("discourse/discourse", [adv("CVE-APP-0001", patched="3.5.4", published="2026-03-01T00:00:00Z")])
REDIS = gh("redis/redis", [
adv("CVE-2021-21309", patched="6.0.11", published="2021-02-01T00:00:00Z"),
adv("CVE-2025-49844", patched="7.4.6; 8.0.4; 8.2.2", published="2025-10-01T00:00:00Z",
severity="critical"),
])
URLS = ["https://github.com/discourse/discourse", "https://github.com/redis/redis"]
def test_sidecar_cve_is_not_judged_by_the_app_window(self):
# redis 6.0.11 sits numerically inside discourse 3.5.3 -> 2026.7.1. It must NOT count.
rep = run_scan([self.APP, self.REDIS], v_from="3.5.3", v_to="2026.7.1",
tag_dates={"3.5.3": "2025-12-30T00:00:00Z", "2026.7.1": "2026-07-31T00:00:00Z"},
urls=self.URLS)
self.assertNotIn("CVE-2021-21309", rep["fixed_by_this_upgrade"])
self.assertIn("CVE-2021-21309", rep["unclassified"])
def test_unwindowed_image_is_unclassified_never_counted(self):
rep = run_scan([self.APP, self.REDIS], v_from="3.5.3", v_to="3.5.4", urls=self.URLS)
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-APP-0001"])
for cve in ("CVE-2021-21309", "CVE-2025-49844"):
self.assertIn(cve, rep["unclassified"])
def test_sidecar_window_counts_only_what_that_bump_fixed(self):
rep = run_scan([self.APP, self.REDIS], v_from="3.5.3", v_to="3.5.4",
images=[("redis", "7.4", "8.10")], urls=self.URLS)
self.assertIn("CVE-2025-49844", rep["fixed_by_this_upgrade"]) # patched 7.4.6, in window
self.assertNotIn("CVE-2021-21309", rep["fixed_by_this_upgrade"]) # patched 6.0.11, below it
self.assertEqual(rep["cve_count_fixed"], 2) # app 1 + redis 1
def test_count_is_the_union_across_images(self):
rep = run_scan([self.APP, self.REDIS], v_from="3.5.3", v_to="3.5.4",
images=[("redis", "7.4", "8.10")], urls=self.URLS)
self.assertEqual(sorted(rep["fixed_by_this_upgrade"]), ["CVE-2025-49844", "CVE-APP-0001"])
def test_each_image_classified_independently(self):
# App crosses a scheme change (date method); redis does not (version method). Both resolve.
rep = run_scan([self.APP, self.REDIS], v_from="3.5.3", v_to="2026.7.1",
images=[("redis", "7.4", "8.10")], urls=self.URLS,
tag_dates={"3.5.3": "2025-12-30T00:00:00Z", "2026.7.1": "2026-07-31T00:00:00Z"})
methods = rep["classified_by"]
self.assertIn("publish date", methods["github-advisories:discourse/discourse"])
self.assertEqual(methods["github-advisories:redis/redis"], "patched version ranges")
self.assertTrue(rep["count_known"])
def test_image_name_matches_as_substring(self):
rep = run_scan([self.APP, gh("discourse/discourse-postgres", [adv("CVE-PG-1", patched="18.0")])],
v_from="3.5.3", v_to="3.5.4", images=[("postgres", "17", "18")],
urls=["https://github.com/discourse/discourse",
"https://github.com/discourse/discourse-postgres"])
self.assertIn("github-advisories:discourse/discourse-postgres", rep["windows"])
self.assertIn("CVE-PG-1", rep["fixed_by_this_upgrade"])
def test_primary_cannot_be_stolen_by_a_loose_image_name(self):
rep = run_scan([self.APP, gh("discourse/discourse-postgres", [adv("CVE-PG-1", patched="18.0")])],
v_from="3.5.3", v_to="3.5.4", images=[("discourse", "1", "2")],
urls=["https://github.com/discourse/discourse",
"https://github.com/discourse/discourse-postgres"])
self.assertEqual(rep["windows"]["github-advisories:discourse/discourse"],
{"from": "3.5.3", "to": "3.5.4"})
def test_unmatched_image_name_is_silently_ignored(self):
# Documents CURRENT behaviour: a typo'd name costs coverage without warning.
rep = run_scan([self.APP], v_from="3.5.3", v_to="3.5.4",
images=[("nosuchimage", "1", "2")], urls=self.URLS[:1])
self.assertEqual(list(rep["windows"]), ["github-advisories:discourse/discourse"])
self.assertTrue(rep["count_known"])
class TestSchemeChangeDateFallback(unittest.TestCase):
DATES = {"3.5.3": "2025-12-30T00:00:00Z", "2026.7.1": "2026-07-31T00:00:00Z"}
def _rep(self, advisories, dates=None):
return run_scan([gh("discourse/discourse", advisories)], v_from="3.5.3", v_to="2026.7.1",
tag_dates=self.DATES if dates is None else dates)
def test_counts_advisories_published_inside_the_date_window(self):
rep = self._rep([adv("CVE-IN-1", published="2026-03-01T00:00:00Z"),
adv("CVE-OUT-1", published="2025-06-01T00:00:00Z"),
adv("CVE-OUT-2", published="2026-09-01T00:00:00Z")])
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-IN-1"])
def test_date_boundaries_match_the_version_rule(self):
# Exclusive lower, inclusive upper — same as 4a, so the two methods agree at the edges.
rep = self._rep([adv("CVE-LOWER", published=self.DATES["3.5.3"]),
adv("CVE-UPPER", published=self.DATES["2026.7.1"])])
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-UPPER"])
def test_advisory_without_a_publish_date_is_not_counted(self):
self.assertEqual(self._rep([adv("CVE-NODATE", published=None)])["fixed_by_this_upgrade"], [])
def test_scheme_change_is_detected_not_version_compared(self):
rep = self._rep([adv("CVE-IN-1", patched="2026.1.0", published="2026-03-01T00:00:00Z")])
self.assertIn("publish date", rep["classified_by"]["github-advisories:discourse/discourse"])
self.assertIn("github-advisories:discourse/discourse", rep["date_window"])
def test_small_major_bump_still_uses_version_ranges(self):
rep = run_scan([gh("app/app", [adv("CVE-X", patched="3.0.0")])], v_from="2.9.0", v_to="3.0.0")
self.assertEqual(rep["classified_by"]["github-advisories:app/app"], "patched version ranges")
# ── E. count guarantees ───────────────────────────────────────────────────────────────────────────
class TestCountGuarantees(unittest.TestCase):
def test_unresolvable_window_yields_unknown_never_zero(self):
# Scheme change AND tag dates unresolvable -> must refuse to emit a number.
rep = run_scan([gh("app/app", [adv("CVE-1", published="2026-01-01T00:00:00Z")])],
v_from="3.5.3", v_to="2026.7.1", tag_dates={})
self.assertIs(rep["cve_count_fixed"], None)
self.assertFalse(rep["count_known"])
md = A.markdown(rep)
self.assertIn("UNKNOWN", md)
self.assertIn("NOT zero", md)
def test_one_unresolvable_image_makes_the_whole_count_unknown(self):
# A partial number would understate a security figure, so it is suppressed entirely.
rep = run_scan([gh("app/app", [adv("CVE-APP", patched="1.1")]),
gh("redis/redis", [adv("CVE-REDIS", patched="8.0")])],
v_from="1.0", v_to="1.1", images=[("redis", "7.4", "9999.1")],
tag_dates={}, urls=["https://github.com/app/app", "https://github.com/redis/redis"])
self.assertIs(rep["cve_count_fixed"], None)
self.assertFalse(rep["count_known"])
def test_genuine_zero_is_reported_as_zero(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="9.9.9")])], v_from="1.0", v_to="1.1")
self.assertEqual(rep["cve_count_fixed"], 0)
self.assertTrue(rep["count_known"])
self.assertIn("0 identified", A.markdown(rep))
def test_404_advisory_feed_is_benign_not_a_failure(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")]),
gh("side/car", [], status="no-advisories-published")],
v_from="1.0", v_to="1.1")
self.assertEqual(rep["sources_failed"], [])
self.assertIn("github-advisories:side/car", rep["sources_benign"])
self.assertEqual(rep["cve_count_fixed"], 1)
def test_template_url_is_benign_not_a_failure(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")])],
[vendor("https://x.test/changelog/v<VERSION>/", status="skipped: template URL")],
v_from="1.0", v_to="1.1")
self.assertEqual(rep["sources_failed"], [])
def test_real_source_failure_is_surfaced(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")], status="error: HTTP 500")],
v_from="1.0", v_to="1.1")
self.assertIn("github-advisories:app/app", rep["sources_failed"])
self.assertIn("FAILED", A.markdown(rep))
def test_no_window_given_classifies_nothing(self):
rep = run_scan([gh("app/app", [adv("CVE-1", patched="1.1")])])
self.assertEqual(rep["fixed_by_this_upgrade"], [])
self.assertIn("CVE-1", rep["unclassified"])
class TestVendorOnlyCves(unittest.TestCase):
"""The gitea case: CVEs named ONLY on a vendor page, absent from the GitHub advisory feed."""
def test_vendor_only_cve_is_recorded_and_surfaced(self):
rep = run_scan([gh("go-gitea/gitea", [])],
[vendor("https://blog.gitea.com/release-1.27.1/", ["CVE-2026-60004"])],
v_from="1.27.0", v_to="1.27.1")
self.assertIn("CVE-2026-60004", rep["cves"])
self.assertIn("CVE-2026-60004", rep["unclassified"])
def test_vendor_only_cve_is_NOT_counted_but_IS_sent_for_judgement(self):
# It carries no version data, so no arithmetic can place it — the deterministic count must
# not include it. It must not be silently dropped either: pass 2 gets it with its evidence.
rep = run_scan([gh("go-gitea/gitea", [])],
[vendor("https://blog.gitea.com/release-1.27.1/", ["CVE-2026-60004"])],
v_from="1.27.0", v_to="1.27.1")
self.assertEqual(rep["fixed_by_this_upgrade"], [])
self.assertEqual(rep["cve_count_fixed"], 0)
self.assertIn("CVE-2026-60004", A.needs_judgement(rep))
def test_cve_in_both_vendor_and_advisory_feed_is_counted_once(self):
rep = run_scan([gh("go-gitea/gitea", [adv("CVE-2026-60004", patched="1.27.1")])],
[vendor("https://blog.gitea.com/x/", ["CVE-2026-60004"])],
v_from="1.27.0", v_to="1.27.1")
self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2026-60004"])
self.assertEqual(rep["cve_count_fixed"], 1)
self.assertEqual(len(rep["cves"]["CVE-2026-60004"]["sources"]), 2)
class TestIndeterminateBucket(unittest.TestCase):
"""An advisory with no knowable fix version is neither counted nor dismissed."""
def test_tbd_patched_is_indeterminate_not_excluded(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
self.assertEqual(rep["fixed_by_this_upgrade"], [])
self.assertIn("CVE-TBD", rep["indeterminate"])
self.assertEqual(rep["cve_count_indeterminate"], 1)
def test_placeholder_patched_is_indeterminate(self):
# "7.4.X" could be 7.4.1 — inside the window. Extracting a bare 7.4 and excluding it was
# how CVE-2024-46981 (high) went missing.
rep = run_scan([gh("redis/redis", [adv("CVE-X", patched="6.2.X, 7.2.X, 7.4.X")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
self.assertIn("CVE-X", rep["indeterminate"])
def test_real_versions_outside_the_window_are_decided_not_indeterminate(self):
rep = run_scan([gh("redis/redis", [adv("CVE-OLD", patched="6.0.11")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
self.assertEqual(rep["indeterminate"], [])
self.assertEqual(rep["cve_count_fixed"], 0)
def test_indeterminate_is_surfaced_in_the_markdown_and_not_read_as_zero(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD", severity="critical")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
md = A.markdown(rep)
self.assertIn("could NOT be judged", md)
self.assertIn("must NOT be read as unaffected", md)
class TestReleaseNoteResolution(unittest.TestCase):
"""Third method: a release whose notes NAME the CVE supplies the fix version the advisory lacks."""
def test_release_naming_the_cve_inside_the_window_counts_it(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],
releases={"CVE-TBD": ["6.2.19", "7.2.10", "7.4.5", "8.0.3"]})
self.assertIn("CVE-TBD", rep["fixed_by_this_upgrade"])
self.assertEqual(rep["indeterminate"], [])
self.assertEqual(rep["resolved_by_release_notes"]["CVE-TBD"], ["7.4.5", "8.0.3"])
def test_release_naming_it_only_outside_the_window_stays_indeterminate(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],
releases={"CVE-TBD": ["6.2.19"]})
self.assertEqual(rep["fixed_by_this_upgrade"], [])
self.assertIn("CVE-TBD", rep["indeterminate"])
def test_release_evidence_is_recorded_for_audit(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],
releases={"CVE-TBD": ["7.4.5"]})
e = rep["cves"]["CVE-TBD"]
self.assertEqual(e["fix_versions_from_release_notes"], ["7.4.5"])
self.assertIn("named in release notes", e["classification"])
def test_it_does_not_override_a_version_range_decision(self):
# A CVE already counted by patched ranges is untouched; the method only rescues undecided.
rep = run_scan([gh("redis/redis", [adv("CVE-OK", patched="7.4.1")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],
releases={"CVE-OK": ["7.4.1"]})
self.assertNotIn("CVE-OK", rep.get("resolved_by_release_notes") or {})
class TestAdjudicationEvidenceAssembly(unittest.TestCase):
"""Pass 2's JUDGEMENT is a model's and not testable; what IS testable is what it gets shown."""
def test_selects_indeterminate_and_vendor_only_cases(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
[vendor("https://blog.test/sec", ["CVE-VENDOR"])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
todo = A.needs_judgement(rep)
self.assertIn("CVE-TBD", todo)
self.assertIn("CVE-VENDOR", todo)
def test_does_not_re_ask_about_cases_pass_1_settled(self):
rep = run_scan([gh("redis/redis", [adv("CVE-OK", patched="7.4.1")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
self.assertNotIn("CVE-OK", A.needs_judgement(rep))
def test_evidence_bundle_carries_the_window_and_published_fields(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
with unittest.mock.patch.object(A, "advisory_text", lambda g, s=None: {"status": "skipped"}), \
unittest.mock.patch.object(A, "release_fix_versions", lambda s, c: []):
ev = A.evidence_bundle(rep, "CVE-TBD")
self.assertEqual(ev["window"], {"from": "7.4", "to": "8.10"})
self.assertEqual(ev["patched_as_published"], "TBD")
self.assertIn("no fix version", ev["why_undecided"])
def test_pass_1_decisions_are_included_for_review(self):
rep = run_scan([gh("redis/redis", [adv("CVE-OK", patched="7.4.1"),
adv("CVE-OLD", patched="6.0.11"),
adv("CVE-TBD", patched="TBD")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
with unittest.mock.patch.object(A, "advisory_text", lambda g, s=None: {"status": "skipped"}), \
unittest.mock.patch.object(A, "release_fix_versions", lambda s, c: []):
block = A.adjudication_block(rep)
self.assertIn("Pass 1 decisions", block)
self.assertIn("CVE-OK", block) # counted
self.assertIn("CVE-OLD", block) # excluded as outside-window
self.assertIn("CVE-TBD", block) # needs judgement
def test_truncation_is_announced_never_silent(self):
advs = [adv(f"CVE-2026-{1000+i}", patched="TBD") for i in range(30)]
rep = run_scan([gh("redis/redis", advs)], v_from="7.4", v_to="8.10",
urls=["https://github.com/redis/redis"])
with unittest.mock.patch.object(A, "advisory_text", lambda g, s=None: {"status": "skipped"}), \
unittest.mock.patch.object(A, "release_fix_versions", lambda s, c: []), \
unittest.mock.patch.object(A, "MAX_ADJUDICATE", 5):
block = A.adjudication_block(rep)
self.assertIn("not shown", block)
self.assertIn("do not", block.lower())
class TestMarkdownOutput(unittest.TestCase):
def test_lists_every_window_with_its_method(self):
rep = run_scan([gh("discourse/discourse", [adv("CVE-A", patched="3.5.4")]),
gh("redis/redis", [adv("CVE-B", patched="8.0")])],
v_from="3.5.3", v_to="3.5.4", images=[("redis", "7.4", "8.10")],
urls=["https://github.com/discourse/discourse", "https://github.com/redis/redis"])
md = A.markdown(rep)
self.assertIn("discourse/discourse: 3.5.3 → 3.5.4", md)
self.assertIn("redis/redis: 7.4 → 8.10", md)
self.assertIn("**CVEs fixed by this upgrade: 2**", md)
def test_severity_and_fixed_in_are_rendered(self):
rep = run_scan([gh("redis/redis", [adv("CVE-2025-49844", patched="7.4.6; 8.2.2",
severity="critical")])],
v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"])
md = A.markdown(rep)
self.assertIn("critical", md)
self.assertIn("7.4.6", md)
# ── F. live regressions against published historic reports ────────────────────────────────────────
class TestHistoricReportNumbers(unittest.TestCase):
"""Re-derive counts published in week-2026-08-07. Network + GitHub token; opt in with --live."""
REGISTRY = str(HERE / "upstream")
@classmethod
def setUpClass(cls):
if not os.environ.get("ADVISORY_SCAN_LIVE"):
raise unittest.SkipTest("live tests: re-run with --live")
def _count(self, recipe, v_from, v_to, images=None):
rep = A.scan(recipe, v_from, v_to, self.REGISTRY, images)
self.assertEqual(rep["sources_failed"], [], f"{recipe}: source failures make the count unsafe")
self.assertTrue(rep["count_known"], f"{recipe}: count came back UNKNOWN")
return rep
def test_gitea_1_27_0_to_1_27_1_is_2(self):
rep = self._count("gitea", "1.27.0", "1.27.1")
self.assertEqual(rep["cve_count_fixed"], 2)
# Both CVSS-9.8 RCEs — the pair whose omission is why this tool exists.
self.assertEqual(set(rep["fixed_by_this_upgrade"]), {"CVE-2026-59774", "CVE-2026-60004"})
def test_discourse_app_only_is_123(self):
rep = self._count("discourse", "3.5.3", "2026.7.1")
self.assertEqual(rep["cve_count_fixed"], 123)
self.assertIn("publish date", rep["classified_by"]["github-advisories:discourse/discourse"])
def test_discourse_with_redis_sidecar_is_140(self):
# 123 app + 17 redis. Five redis advisories carry a usable patched_versions; the other
# twelve say "TBD" and are resolved from the release notes that name them.
rep = self._count("discourse", "3.5.3", "2026.7.1", [("redis", "7.4", "8.10")])
self.assertEqual(rep["cve_count_fixed"], 140)
self.assertEqual(len(rep.get("resolved_by_release_notes") or {}), 12)
self.assertEqual(rep["cve_count_indeterminate"], 0)
# The five redis advisories that a sidecar-blind scan missed, incl. one critical.
for cve in ("CVE-2024-31227", "CVE-2024-31228", "CVE-2024-31449",
"CVE-2025-49844", "CVE-2025-62507"):
self.assertIn(cve, rep["fixed_by_this_upgrade"], f"{cve} missing from discourse+redis")
self.assertEqual(rep["cves"]["CVE-2025-49844"]["severity"], "critical")
def test_discourse_redis_delta_is_exactly_seventeen(self):
app = self._count("discourse", "3.5.3", "2026.7.1")
both = self._count("discourse", "3.5.3", "2026.7.1", [("redis", "7.4", "8.10")])
delta = set(both["fixed_by_this_upgrade"]) - set(app["fixed_by_this_upgrade"])
self.assertEqual(len(delta), 17)
for cve in delta:
self.assertIn("redis", both["cves"][cve]["sources"][0])
def test_mailu_scan_finds_zero_and_says_so_knowingly(self):
# Published as 2 via the UNION with release-note reading; the scan's own contribution is 0,
# and 0 here must mean "checked, none", not "could not check".
rep = self._count("mailu", "2024.06.55", "2024.06.57", [("redis", "8.8.0", "8.10.0")])
self.assertEqual(rep["cve_count_fixed"], 0)
self.assertTrue(rep["count_known"])
def test_keycloak_26_7_0_to_26_7_1_is_7(self):
rep = self._count("keycloak", "26.7.0", "26.7.1")
self.assertEqual(rep["cve_count_fixed"], 7)
def _main():
live = "--live" in sys.argv
if live:
sys.argv.remove("--live")
os.environ["ADVISORY_SCAN_LIVE"] = "1"
unittest.main(verbosity=2)
if __name__ == "__main__":
_main()
+27 -3
View File
@@ -18,9 +18,33 @@
support; monthly `release` channel gets ~2 months; `latest` is continuous. The `esr` Docker tag
tracks the current ESR. Target the ESR line for stable deployments.
- discourse/postgres tags are `pg<MAJOR>` (pg13..pg18), non-semver — abra can't parse them. Check Docker
Hub directly: https://hub.docker.com/r/discourse/postgres/tags . pg18 is the newest (2026-07-01); no
pg19 yet. The image auto-upgrades an older cluster in place on boot (pg_upgrade into versioned PGDATA);
Hub directly: https://hub.docker.com/r/discourse/postgres/tags . pg18 is the newest (no pg19 yet;
re-confirmed 2026-08-07: all of pg13..pg18 + latest re-pushed 2026-08-04, still no pg19). The image
auto-upgrades an older cluster in place on boot (pg_upgrade into versioned PGDATA);
no manual dump/restore needed (the old "Welcome to hell" procedure is superseded for this image).
- Redis 8.0 GA supports upgrade from 7.x data files. Discourse uses Redis only as a cache/queue (Sidekiq)
with no persistence modules. Redis 8.0 integrates RediSearch/RedisJSON/etc. as built-ins; discourse
does not use those modules so this is transparent.
does not use those modules so this is transparent. Redis 8.10-alpine is a safe sidecar bump from 8.8.
- **2026.1 ESR → 2026.7 ESR jump (2026-07-28 release; 2026.7.1 = July 31 security intermediate):**
the big ~6-month ESR jump. Behavior changes that arrive on their own (all surfaced via the new
Upcoming Changes opt-out system, so NOT hard breaks for the recipe boot): Discourse Reactions
enabled by default (opt out via `discourse_reactions_enabled`); Uncategorized being removed (opt-out
for now); `rich_editor` site setting gone (rich editor unconditional, per-user `composition_mode`
remains); simpler email subject lines (beta). Web server: **Discourse replaced Unicorn with Pitchfork
(default in 2026.2, Unicorn removed entirely in 2026.4)** — internal, healthcheck `curl /srv/status`
still works. Theme/plugin deprecations: `.hbs` deprecated (2026.7 is last ESR with support, drop in
2026.8.0-latest → 2027.1 first ESR without it; codemod available); `.js.es6` deprecated (rename to
`.js`); legacy widget shims removed (`discourse.breadcrumbs.*`, `add-flag-property`,
`add-header-panel`, `bootbox`). New features: Upcoming Changes config page, Nested replies
(experimental), one-time email code login (alpha), new admin category management, bulk actions
(tag/pin/suspend), livestreams in Discourse Events. Postgres: discourse-health-check flags anything
below pg15; recipe is on pg18 so fine — do NOT bump the pg major in the weekly cron (operator
decision). For a fresh CI deploy (no custom themes/plugins/data) the jump converges cleanly; the
breaking-change surface is operator-facing for existing sites with custom themes. No Ruby version
concern (Ruby ships in the image). Release notes: https://releases.discourse.org/changelog/v2026.7.1/
and https://releases.discourse.org/changelog/v2026.7.0/ ; community jump guide:
https://meta.discourse.org/t/jumping-from-2026-1-esr-to-2026-7-what-i-found/408779 .
- **Recommended release bump for the ESR jump:** `-x` major (it's a 6-month ESR jump with web-server
swap, default-on behavior changes, and theme deprecations — signal operators to review). The recipe
version label is NOT bumped in the upgrade PR; the operator runs `abra recipe release discourse -x`
after the upstream PR merges.
+7
View File
@@ -3,6 +3,12 @@
| service | image | source repo | releases / changelog |
|---------|-------|-------------|----------------------|
| app | gitea/gitea | https://github.com/go-gitea/gitea | https://github.com/go-gitea/gitea/releases |
**Security announcements: https://blog.gitea.com/ — per-release posts (e.g.
https://blog.gitea.com/release-of-1.27.1/) carry the CVE list; the GitHub release notes do NOT.**
This is where CVE-2026-60004 + CVE-2026-59774 (both CVSS 9.8, fixed in 1.27.1) were announced,
and why the 2026-08-03/07 reports under-counted gitea's CVEs. advisory-scan.py fetches every URL
in this file, so keep vendor security pages listed here.
| db | postgres | https://github.com/postgres/postgres | https://www.postgresql.org/docs/release/ |
## Standing notes
@@ -15,3 +21,4 @@
- **1.26.2**: Multiple CVE security fixes — strongly recommended upgrade.
- **1.26.3**: carries a regression (#38177 "context deadline exceeded" opening repo code pages) — upstream says upgrade straight to 1.26.4, skip 1.26.3.
- **1.27.0** (released 2026-07-13, MINOR with BREAKING changes): `Feat(actions)!: improve support for reusable workflows` (#37478) and `Use Content-Security-Policy: script nonce` (#37232, may break custom inline JS / reverse-proxy CSP). Many security + feature additions. A dedicated minor-bump run should evaluate CSP / reusable-workflow impact before adopting.
- **1.27.1** (released 2026-07-27, PATCH): security fix (oauth2 mandatory 2FA enforcement on authorize/grant endpoints, #38606), API swagger alignment, diff-contrast enhancement, and many bugfixes (actions reusable-workflow/job-stranding, OIDC end-session, repo-deletion cleanup, webhook/mail). No breaking changes; safe patch bump from 1.27.0. Release notes: https://github.com/go-gitea/gitea/releases/tag/v1.27.1
+18 -1
View File
@@ -34,12 +34,29 @@
`valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411`
(a NEWER valkey build than v3.0.3's `4963247afc4cd…` — this `8e8d64b4…` is the same digest the live `9`
tag had already moved to at the 2026-07-17 run; immich v3.1.0 now officially ships it, so it IS the
immich-tested combo → re-pin the recipe's `redis` service to `8e8d64b4…` for the v3.1.0 upgrade) and
immich-tested combo → re-pin the recipe's `redis` service to `8e8d64b4…` for the v3.1.0 upgrade) and
`postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191…` (SAME combo as v3.0.3 — immich did NOT
bump vectorchord/pgvectors, so the recipe STAYS AHEAD on `pgvectors0.3.0@sha256:87c050465…`; do NOT
downgrade). Only **breaking change in v3.1.0** is `chore(mobile): drop support for iOS 14` (mobile
client, NOT server-side — no server migration, no DB migration, no operator action for the recipe).
Done in the 2026-07-31 upgrade (v3.0.1→v3.1.0, extending PR #4 again).
- **2026-08-07: upstream main MOVED to v3.1.0 (published `1.10.0+v3.1.0`, commit `794560f`).** The
coopcloud maintainer published v3.1.0 independently of PR #4 (PR #4 tip `77d7937` is NOT in upstream
main, so the reconcile leaves it open — functionally superseded on the version bump). Upstream main
pins: `immich-server/ML v3.1.0`, `postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf633…` (immich's
OFFICIAL v3.1.0 combo — a DOWNGRADE from the recipe's prior `pgvectors0.3.0@87c0…`), and
`valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd` (DIVERGES from
immich v3.1.0's official valkey pin `8e8d64b4…` — upstream pin hygiene discrepancy, not an upgrade).
Immich is now **up-to-date** (v3.1.0 is latest stable per GitHub releases; no v3.1.x patch / v3.2.x).
PR #4 now diverges from upstream main only on `database` (pgvectors0.3.0 vs 0.2.0) + `redis`
(valkey:9@8e8d64b4 vs @3acc0687) pins — operator decides merge-vs-close.
- **2026-08-07 INFRA note: cc-ci runner's gitea clone-token is STALE (HTTP 401).** `!testme` build
#1210 died at the recipe `git clone` step (~5s, before any deploy) with `could not read Username for
'https://git.autonomic.zone'` — the runner's mounted gitea token `13e299f2…` is rejected (verified
HTTP 401 on the gitea API + git endpoint; `.testenv` basic creds work). PR #4's tree `77d79379` was
GREEN on #1145 (2026-07-31) & #1180 (2026-08-04) — the RED is infra, not the recipe. Fix: rotate the
runner's gitea clone-token (swarm secret), then re-`!testme`. (The 2026-08-04 Drone-direct
`GITEA_TOKEN`-as-build-param workaround is disallowed for normal runs.)
- **Historical (v2.7.5, 2026-04-13):** immich-server v2.7.5 pinned
`postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191…`. PR #2 bumped the recipe to
`pgvectors0.3.0@sha256:87c050465…` (same PG14 + VectorChord 0.4.3, newer pgvectors 0.2.0→0.3.0).
+11 -1
View File
@@ -7,7 +7,7 @@
| celery | lasuite/impress-backend | https://github.com/suitenumerique/docs | https://github.com/suitenumerique/docs/releases |
| y-provider | lasuite/impress-y-provider | https://github.com/suitenumerique/docs | https://github.com/suitenumerique/docs/releases |
| docspec | ghcr.io/docspecio/api | https://github.com/docspecIO/api | https://github.com/docspecIO/api/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | redis | https://github.com/redis/redis | https://hub.docker.com/_/redis/tags |
| minio | minio/minio | https://github.com/minio/minio | https://github.com/minio/minio/releases |
| web | nginx | https://github.com/nginx/nginx | https://nginx.org/en/CHANGES |
@@ -21,3 +21,13 @@
- v5.2.0 adds two optional new env vars: DOCUMENT_ALL_ENDPOINT_ENABLED and OIDC_OP_USER_ENDPOINT_FORMAT.
Both are backward-compatible (no action required for existing deployments).
- Recipe version label convention: 0.X.Y+vA.B.C where A.B.C is the impress version.
- **v5.4.0 removed Bearer/JWT auth on the API** (upstream PR suitenumerique/docs#2480 dropped
`mozilla_django_oidc.contrib.drf.OIDCAuthentication` from DRF DEFAULT_AUTHENTICATION_CLASSES).
The API now accepts only the app's session cookie from the real OIDC authorization-code flow.
Any test/assertion that sends `Authorization: Bearer <jwt>` to `/api/v1.0/*` will get 401 — this is
the NEW CORRECT behavior, not a regression. The cc-ci lasuite-docs tests were updated for this in
cc-ci PR #12 (merged 2026-08-03): test_oidc_login.py + test_create_doc.py now use session cookies
and assert Bearer is rejected. Don't revert to Bearer assertions.
- redis sidecar (cache/broker for celery+backend): prefer the patch line (8.8.1 over 8.10.0) unless a
feature is needed — 8.8.1 is a security patch (RedisBloom/TDigest RESTORE RCE); 8.10.0 is a minor
with many new features.
+6 -2
View File
@@ -6,12 +6,12 @@
| backend | lasuite/drive-backend | https://github.com/suitenumerique/drive | https://github.com/suitenumerique/drive/releases |
| celery | lasuite/drive-backend | https://github.com/suitenumerique/drive | https://github.com/suitenumerique/drive/releases |
| celery-beat | lasuite/drive-backend | https://github.com/suitenumerique/drive | https://github.com/suitenumerique/drive/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | redis | https://github.com/redis/redis | https://github.com/redis/redis/releases |
| mailcatcher | sj26/mailcatcher | https://github.com/sj26/mailcatcher | https://github.com/sj26/mailcatcher/releases |
| minio | minio/minio | https://github.com/minio/minio | https://github.com/minio/minio/releases |
| minio-createbuckets | minio/minio | https://github.com/minio/minio | https://github.com/minio/minio/releases |
| collabora | collabora/code | https://github.com/CollaboraOnline/online | https://www.collaboraoffice.com/category/release-notes/ |
| collabora | collabora/code | https://github.com/CollaboraOnline/online | https://www.collaboraonline.com/release-notes/ (per-version: .../collabora-online-25-04-release-notes/) |
| onlyoffice | onlyoffice/documentserver-de | https://github.com/ONLYOFFICE/DocumentServer | https://github.com/ONLYOFFICE/DocumentServer/blob/master/CHANGELOG.md |
| web | nginx | https://github.com/nginx/nginx | https://nginx.org/en/CHANGES |
@@ -19,5 +19,9 @@
- lasuite/drive-frontend and lasuite/drive-backend share the same version tag (drive monorepo).
- minio and onlyoffice tags use non-semver formats; abra cannot auto-detect upgrades for them.
- collabora/code uses a 5-part version scheme; abra cannot auto-detect upgrades for it.
- minio: Docker Hub `minio/minio` `latest` is frozen at `RELEASE.2025-09-07T16-13-09Z` (the
`RELEASE.2025-10-15T17-29-55Z` GitHub security release is NOT on Docker Hub `minio/minio`).
Newer minio releases moved to the **AIStor** product line (`quay.io/minio/aistor/minio`).
A future minio bump requires a registry+product switch — operator decision, not a routine tag bump.
- nginx 1.31.x (1.31.0, 1.31.1) contains multiple security CVE fixes; upgrade is recommended.
- nginx 1.31.0 breaking change: HTTP/2 and HTTP/3 requests with Connection/Proxy-Connection/Keep-Alive/Transfer-Encoding/Upgrade headers are now rejected (affects proxied HTTP/2 backends).
+5 -17
View File
@@ -5,24 +5,12 @@
| app | lasuite/meet-frontend | https://github.com/suitenumerique/meet | https://github.com/suitenumerique/meet/releases |
| backend | lasuite/meet-backend | https://github.com/suitenumerique/meet | https://github.com/suitenumerique/meet/releases |
| celery | lasuite/meet-backend | https://github.com/suitenumerique/meet | https://github.com/suitenumerique/meet/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade/releases |
| redis | redis | https://github.com/redis/redis | https://hub.docker.com/_/redis/tags |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade/releases |
| redis | redis | https://github.com/redis/redis | https://github.com/redis/redis/releases |
| livekit | livekit/livekit-server | https://github.com/livekit/livekit | https://github.com/livekit/livekit/releases |
| web | nginx | https://github.com/nginx/nginx | https://nginx.org/en/CHANGES |
## Standing notes
- meet-frontend, meet-backend (used for both backend + celery services) share the same version tag from the suitenumerique/meet monorepo. Upgrade app, backend, and celery in lockstep.
- AUTO_MIGRATIONS=true means DB migrations run automatically on backend startup. No manual step needed.
- v1.17.0v1.19.0: no breaking changes documented; feature additions only (participant muting, PiP, S3 recording, API exposure). Standard rolling upgrade applies.
- v1.19.0: security fix for CVE-2026-45409 (idna ≥3.15) — no operator action needed (baked into image).
- Recipe version label convention: 0.X.Y+vA.B.C where A.B.C is the meet version.
- LiveKit version is decoupled from meet version; only bump if explicitly required.
- v1.13.1: removes backwards compatibility for TURN auth without TTL (deprecated in v1.12.0). Operators who never set TURN TTL must add it before upgrading LiveKit to v1.13.x. No new required env vars for standard deployments.
- v1.13.2: patch (Added/Changed/Fixed — Prometheus metrics for join latency, 512 KiB metadata cap, egress v2 api, etc.); no breaking changes within v1.13.x.
- v1.13.3: patch (mock API server for SDK testing, data track schema metadata, whip ingress bitrates, webrtc interop fix for bundled datachannel, WHIP notifier fix); no breaking changes.
- v1.22.0: feature release (purge deleted/pending files, generalized STT API, LiveKit egress_ended fallback for recordings, PiP tile cap/pagination, reject user access tokens on API, dedicated PostHog feature-flag domain, backend analytics). No breaking changes; no new required env vars for the standalone meet recipe.
- v1.23.0: feature release (migrate visio integration to summary API v2, summary feature flag, Sentry monitoring for agents, MuteEveryoneButton admin/owner gate, dep upgrades). ⚠️ Special mention: removed `api/v1` code from the `summary` sub-project BUT kept meet→summary v1-API compatibility as the DEFAULT — `SUMMARY_SERVICE_VERSION: 2` is only needed if deploying the latest meet AND summary from the monorepo together. The lasuite-meet recipe deploys meet standalone (no summary service, no `SUMMARY_SERVICE_VERSION` env), so NO operator action is required for this recipe.
- v1.24.0: feature release (deprecate `SUMMARY_SERVICE_VERSION=1` — only matters when deploying meet+summary together, NOT the standalone recipe; prioritize screen share in PiP; recording admin search by owner email; participant color gradient when camera off; PostHog external-user identification; new OPTIONAL env `AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME` defaulting `true` — set `false` to force SSO display name; mjml v5; info-panel crash fix for unregistered rooms; Outsource Outlook add-on calendar fix; whisper call error handling). No breaking changes / no required migrations / no new required env for the standalone meet recipe. AUTO_MIGRATIONS=true applies any Django migrations on backend startup. Standard rolling upgrade.
- livekit v1.13.2/v1.13.3/v1.13.4: patch series within v1.13.x (Prometheus metrics for join latency, mock SDK test API, WHIP ingress bitrates, WEBRTC interop fix for bundled datachannel; v1.13.4 adds SIP mocking, IPv6-exclusion option, forward-stats API method, data-track buffering-under-congestion fix, goroutine-leak fix, pion/ice hang-on-close fix). No breaking changes within v1.13.x; the v1.13.1 TURN-TTL note (only relevant if TURN was ever configured) is the only standing operator action in this line.
- nginx 1.31.3: SECURITY patch release (CVE-2026-42533 heap buffer overflow in `map` with regex; CVE-2026-60005 uninitialized memory with `slice`/background cache update; CVE-2026-56434 use-after-free in ssi filter on proxied backend responses). Change: HTTP/2 response header/trailer sizes now bounded by `proxy_buffer_size`/`grpc_buffer_size`; external entities disabled in xslt module by default. Bugfixes in HTTP/2 flow control, proxy_v2/ tunnel modules. No required config change for the recipe (nginx serves static frontend + proxies to backend); standard rolling upgrade.
- Redis 8.8.0: compatible drop-in upgrade; includes ReJSON module with V5V7 API exported. No config or persistence-format changes.
- lasuite/meet-frontend and lasuite/meet-backend share the same version tag (meet monorepo, versioned together); celery uses the SAME image as backend.
- livekit-server must stay compatible with the meet version's expected livekit API — take newest SAME-minor livekit tag, do NOT jump to a newer major/minor line unless meet release notes require it.
- meet-backend runs Django DB migrations on boot (manage.py migrate) — watch backend logs on upgrade.
+28 -1
View File
@@ -2,7 +2,7 @@
| service | image | source repo | releases / changelog |
|----------|-------------------------------------------|---------------------------------------------------|-------------------------------------------------------------------|
| app | mattermost/mattermost-team-edition | https://github.com/mattermost/mattermost | https://docs.mattermost.com/about/mattermost-changelog.html |
| app | mattermost/mattermost-team-edition | https://github.com/mattermost/mattermost | https://docs.mattermost.com/deploy/mattermost-changelog.html |
| postgres | postgres | https://github.com/postgres/postgres | https://www.postgresql.org/docs/release/ |
## Standing notes
@@ -17,6 +17,33 @@
The `release-11.7` Docker Hub floating tag always points to the latest 11.7.x patch.
IMPORTANT: Do NOT use 11.7.011.7.2 — they have a schemeid migration bug in the `roles` table
when upgrading from 10.11.17+; use 11.7.3 or later (current: 11.7.7).
- **2026-08-07 re-check** (endoflife.date/api/mattermost.json; GitHub releases API; Docker Hub):
Operator (weekly /upgrade-all) directed extending PR #2 to **11.10.0** (newest on the 11.x
innovation line, consistent with PR #2's existing line). **11.10.0 is a PRE-RELEASE** — GitHub
`prerelease=True`, published 2026-08-04 (rc1 2026-07-17, rc2 2026-07-30, rc3+v11.10.0 2026-08-04);
Docker Hub `mattermost/mattermost-team-edition:11.10.0` exists (462 MB, pushed 2026-08-04,
actively pulled). No detailed release notes published yet (body = "Mattermost Platform Release
11.10.0"). 11.10 cycle not yet on endoflife.date; monthly cadence → EOL ~2026-11-15. Innovation,
NOT ESR. This run bumps 11.9.0 → 11.10.0 per operator instruction and flags the pre-release +
ESR-vs-innovation choice prominently in the PR body + report. **ESR remains 11.7.8** (stable,
EOL 2027-05-15) — the real LTS/ESR alternative if the operator wants LTS. **Survey-hint
correction (again):** the 2026-08-07 survey claimed "alternative ESR = 10.12.4"; that is STILL
wrong — 10.12 is an EXPIRED innovation release (EOL 2025-12-15, lts:false on endoflife.date);
do NOT switch to 10.12.4. 10.11 ESR EOL 2026-08-15 (8 days); upstream main still pins 10.11.22.
postgres 15-alpine HELD (major DB bump out of scope for weekly cron).
- **2026-08-04 re-check** (endoflife.date/api/mattermost.json; GitHub releases; Docker Hub):
**11.7.8** released 2026-07-31 is the newest 11.7.x ESR patch (ESR supported through
2027-05-15). **11.9.0** (innovation, released 2026-07-08, cycle EOL **2026-10-15** ~10 weeks,
NOT ESR) and **11.8.4** (innovation, EOL 2026-09-15) remain innovation releases — the standing
guidance for AUTONOMOUS runs is still "do NOT target innovation; track 11.7 ESR." HOWEVER, the
2026-08-03 weekly /upgrade-all operator task **explicitly directed a bump to 11.9.0** (the survey
target), so this run targets 11.9.0 per operator instruction with the EOL/innovation status
flagged in the plan + PR body + report for operator reconsideration. 11.9.0 confirmed real:
GitHub tag `v11.9.0` (published 2026-07-08, not prerelease), Docker Hub
`mattermost/mattermost-team-edition:11.9.0` (458 MB, 2026-07-08). Features: Ranked Attributes,
ABAC per-action Permission Rules, Program Masking, Azure Blob Storage. `abra recipe upgrade`
lists 11.10.0 highest and 11.9.0 next. PR #2 extended 11.7.8 → 11.9.0 (fast-forward). 10.11 ESR
security support ENDS 2026-08-15 (~11 days).
- **2026-07-24 re-check** (endoflife.date/api/mattermost.json; GitHub releases): **11.7.7**
released 2026-07-17 is the newest 11.7.x ESR patch — bumped PR #2 `11.7.6 → 11.7.7` (ESR security
patch, "Low to High severity security fixes", no migrations/breaking changes), `!testme` GREEN
+32 -1
View File
@@ -3,7 +3,7 @@
| service | image | source repo | releases / changelog |
|---------|-------|-------------|----------------------|
| app | n8nio/n8n | https://github.com/n8n-io/n8n | https://github.com/n8n-io/n8n/releases |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/pgautoupgrade | https://hub.docker.com/r/pgautoupgrade/pgautoupgrade/tags |
| db | pgautoupgrade/pgautoupgrade | https://github.com/pgautoupgrade/docker-pgautoupgrade | https://hub.docker.com/r/pgautoupgrade/pgautoupgrade/tags |
## Standing notes
- pgautoupgrade uses a non-standard tag scheme (e.g. `17-alpine`, `18-alpine`) mapping to the TARGET
@@ -19,3 +19,34 @@
- 2.31.0 shipped "private credentials enabled by default" (UI renamed to "end-user credentials") and a
Notion node API migration -- functional/UX changes worth a heads-up to the operator, not deployment
blockers (no compose/env/migration impact).
- 2.32.0 (minor, 2026-06-xx): Instance AI / AI Agent builder + core bug fixes. No breaking changes, no
`N8N_*` env renames, no required manual migration. 2.32.1-2.32.7 = patch bugfixes (AI Agent preview
rename to "V1", agent channel credential setup, Instance AI follow-up-loop guard, AI Assistant
verification LLM fallback). Confirmed live on cc-ci 2026-07-24 (2.31.0 -> 2.32.4): TypeORM migrations
clean, editor served HTTPS 200.
- 2.33.0 (minor, 2026-07-28; GitHub release marked "Pre-release" but the 2.33.x line stabilized --
2.33.3 carries the "Latest" badge): features = admin-managed instance credentials, workflow review
requests + publish/unpublish public API endpoints, **API deprecation of workflow activate/deactivate
public API endpoints** (replaced by publish/unpublish; old endpoints still work but deprecated -- flag
for operators who automate via the public API), new OPTIONAL env `N8N_SCHEDULER_MAX_ATTEMPTS`
(scheduler dead-letter threshold), OpenTelemetry config API, Microsoft Excel (SharePoint) node, custom
OAuth scopes for Google/Microsoft creds. `core: Prevent concurrent instance startups from racing
database migrations` is a relevant migration-safety fix. No breaking compose/config changes; no
required operator action for the recipe. 2.33.1-2.33.3 = patch bugfixes (2.33.3: security-audit risk
reporter import + MCP server trigger execution-data save).
- 2.34.0 (2026-08-04, marked **Pre-release** on GitHub) is a larger minor (Agent management in
instance MCP, LDAP/OIDC SSO config API, editor OIDC logout, durable-scheduler misfire policy, etc.).
The GitHub release body lists mostly "Bug Fixes" (core task-broker/runner resilience, SSE/OTel fixes,
editor OAuth/credential/agent-credential fixes); no breaking compose/env/migration changes, no
`N8N_*` env renames. No required operator action for the recipe.
- 2.33.4 (2026-08-05, patch): core task-broker resilience when a runner dies + recover unresponsive
task runners; template "see all" tracking; skip redundant workflow-edit approval in AI Assistant.
- 2.33.5 (2026-08-06, carries the **Latest** badge as of this run): single editor bugfix (focus
Markdown editor input before toolbar).
- 2.34.1 (2026-08-05, Pre-release): core recover unresponsive task runners + editor agent-capability
chip spacing.
- 2.34.2 (2026-08-06, Pre-release): editor bugfixes (focus Markdown editor input before toolbar; show
agent tool credentials above configuration).
- 2026-08-07 run: operator directed 2.33.3 -> 2.34.2 (the newest). The whole 2.34.x line is still
marked Pre-release on GitHub (2.33.5 holds the Latest badge); flagged in the PR body. No breaking
changes across 2.33.3 -> 2.34.2; rolling upgrade safe (TypeORM migrations auto-run on boot).
+5 -1
View File
@@ -14,7 +14,11 @@
`ghcr.io/plausible/community-edition` starting with v2.1.x. The v2.0.0 image is still on Docker Hub
as `plausible/analytics:v2.0.0`.
- ClickHouse version must be compatible with the plausible app version. Check release notes when
upgrading either. The `23.4.2.11-alpine` image works with plausible v2.0.0.
upgrading either. The `23.4.2.11-alpine` image works with plausible v2.0.0. Plausible's official
community-edition v3.2.1 `compose.yml` ships `clickhouse/clickhouse-server:24.12-alpine` — that is
the supported pairing for CE v3.x (do NOT go to 25.x/26.x unless Plausible explicitly ships it).
The 23.4.x tags have aged off Docker Hub's paginated listing (the specific `23.4.2.11-alpine` tag
is still active but was last pushed 2023-05-04).
- Recipe version label convention: `<recipe-semver>+<app-version>` (e.g. `3.0.1+v2.0.0`).
- For postgres major upgrades: bump `DB_ENTRYPOINT_VERSION` in abra.sh to force config re-deploy
if entrypoint script changes. Current is v1 (handles 13→14 and beyond via pg_upgrade).
+24
View File
@@ -0,0 +1,24 @@
# Upstream sources — wordpress
| service | image | source repo | releases / changelog |
|---------|-------|-------------|----------------------|
| app | wordpress | https://github.com/WordPress/WordPress | https://wordpress.org/news/category/releases/ (image: https://hub.docker.com/_/wordpress) |
| db | mariadb | https://github.com/MariaDB/server | https://mariadb.com/kb/en/release-notes/ (image: https://hub.docker.com/_/mariadb) |
## Standing notes
- Enrolled 2026-08-03 (operator request). Recipe = coopcloud `wordpress`; mirror
`recipe-maintainers/wordpress` synced from upstream `adcd0e9f` (recipe 3.0.3+7.0.2:
wordpress 7.0.2, mariadb 12.3).
- The CI env runs NO `POST_DEPLOY_CMDS core_install` — a fresh deploy serves the install
wizard, and `tests/wordpress/custom/_wp.py` completes it with run-scoped credentials.
- The recipe templates `.htaccess` via `htaccess.tmpl` (config `htaccess_conf`) — the pretty
REST route `/wp-json/` depends on it; `tests/wordpress` asserts both it and the
rewrite-independent `?rest_route=` fallback so a broken overlay names the right layer.
- XML-RPC (`xmlrpc.php`) ships enabled and is used by the §4.3 post round-trip test with the
admin user/password. If a future recipe version disables XML-RPC (a common hardening), the
test must move to REST + application-passwords — that would be a legitimate stale-test fix,
not a regression.
- WORDPRESS image tags: the recipe pins `wordpress:<major.minor.patch>` (Docker official
image). mariadb major bumps (e.g. 12.x→13.x) need the usual dump/restore caution if the
recipe ever pins a non-`mariadb`-auto-upgrading setup; the official image handles minor
bumps in place.
+1
View File
@@ -33,4 +33,5 @@ mumble weekly
n8n weekly
plausible weekly
uptime-kuma external # maintained elsewhere — used/tested by cc-ci but NOT weekly-upgraded
wordpress weekly
```
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate /cctest-* wrapper skills from the autonomic-recipe-maintainer submodule.
The ARM toolkit (references/recipe-maintainer) carries ~30 recipe-maintenance skills
that operate against the recipe-maintainer TEST server ("cctest") + local abra sandbox — a
different substrate than the cc-ci skills in this repo. To give the operator ONE interface,
every ARM skill is exposed here as `cctest-<name>`: a thin wrapper whose frontmatter carries
ARM's own description (so discovery works) and whose body points at the canonical SKILL.md in
the submodule, sets the execution context, and states the policy overrides.
Run after every submodule bump:
python3 scripts/gen-cctest-skills.py
It removes cctest-* skills whose ARM source disappeared and (re)writes the rest, in BOTH
.opencode/skills/ (canonical) and .claude/skills/ (thin pointer), then prints a summary.
Commit the result.
"""
from __future__ import annotations
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "references/recipe-maintainer"
ARM_SKILLS = VENDOR / ".opencode/skills"
PREFIX = "cctest-"
BODY_TEMPLATE = """# {wrapped} (cctest wrapper)
**Canonical procedure:** `references/recipe-maintainer/.opencode/skills/{name}/SKILL.md`
— read it and follow it. This wrapper only sets context + policy.
**Context:** this is an **autonomic-recipe-maintainer (ARM)** skill. It operates on the
recipe-maintainer **cctest** test server / local abra sandbox — NOT on the cc-ci CI server or
its shared swarm. Execute with the submodule as your working directory:
`cd /srv/cc-ci-orch/references/recipe-maintainer`. If the ARM environment is not yet
configured on this host (`settings.toml` from `settings.toml.example`, sandbox/test instances),
run `/cctest-intro` / `/cctest-setup-sandbox` first.
**Unified policy (same as cc-ci — no differences):**
- **Recipe PRs are NEVER merged by an agent.** Every flow ends at an open PR; the operator
reviews and merges. This is ARM's own rule too ("PRs are reviewed and merged manually by a
human afterwards — never pushes to upstream or merges anything"); ARM's "no human review in
the middle" wording refers only to skipping the mid-run plan confirmation, not to merging.
- Never touch cc-ci infrastructure (the CI server, its swarm, `/root/*` clones, the weekly
timers) from an ARM skill — cc-ci work goes through the cc-ci skills.
- The submodule is **pinned**: don't commit into it from here; upstream ARM changes arrive via
a deliberate submodule bump + `scripts/gen-cctest-skills.py` regeneration.
"""
WRAPPER_TEMPLATE = """# {wrapped} (thin wrapper)
The canonical definition of this skill lives in the **opencode** position:
**`.opencode/skills/{wrapped}/SKILL.md`**
Read that file. It in turn wraps the ARM submodule skill
`references/recipe-maintainer/.opencode/skills/{name}/SKILL.md`.
"""
def parse_frontmatter(text: str) -> dict[str, str]:
m = re.match(r"\A---\n(.*?)\n---\n", text, re.S)
fields: dict[str, str] = {}
if m:
for line in m.group(1).splitlines():
if ":" in line:
k, v = line.split(":", 1)
fields[k.strip()] = v.strip()
return fields
def main() -> int:
if not ARM_SKILLS.is_dir():
print(f"ERROR: {ARM_SKILLS} missing — init the submodule first", file=sys.stderr)
return 1
arm_names = sorted(p.parent.name for p in ARM_SKILLS.glob("*/SKILL.md"))
written, removed = [], []
for pos in (ROOT / ".opencode/skills", ROOT / ".claude/skills"):
for stale in pos.glob(f"{PREFIX}*"):
if stale.name[len(PREFIX) :] not in arm_names:
shutil.rmtree(stale)
removed.append(str(stale.relative_to(ROOT)))
for name in arm_names:
src = ARM_SKILLS / name / "SKILL.md"
fm = parse_frontmatter(src.read_text())
desc = fm.get("description", f"ARM skill {name} (no description)")
wrapped = PREFIX + name
frontmatter = (
f"---\nname: {wrapped}\n"
f"description: \"[recipe-maintainer/cctest] {desc} (Wraps the autonomic-recipe-"
f"maintainer skill /{name}; runs against the cctest test server + ARM sandbox, "
f"not cc-ci. Invoke as /{wrapped}.)\"\n---\n\n"
)
canon = ROOT / ".opencode/skills" / wrapped / "SKILL.md"
canon.parent.mkdir(parents=True, exist_ok=True)
canon.write_text(frontmatter + BODY_TEMPLATE.format(name=name, wrapped=wrapped))
thin = ROOT / ".claude/skills" / wrapped / "SKILL.md"
thin.parent.mkdir(parents=True, exist_ok=True)
thin.write_text(frontmatter + WRAPPER_TEMPLATE.format(name=name, wrapped=wrapped))
written.append(wrapped)
print(f"generated {len(written)} cctest skills: {', '.join(written)}")
if removed:
print(f"removed stale: {', '.join(removed)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+37
View File
@@ -0,0 +1,37 @@
# Recovery tooling — permanent home
Promoted from `/tmp` ad-hocery after the 2026-08-03 cc-ci 26.05 outage (see
`.cc-ci-logs/server-update-2026-08-03.md` and the `hetzner-server-recovery` skill, which is
the *procedure*; these are the *tools* it uses).
| Tool | What it does |
|---|---|
| `hetzner.py <server> <cmd>` | Hetzner Cloud API: status, actions, reboot/reset/poweroff/poweron, rescue-on/off, console credentials. Knows `cc-ci` (134485294) and `orchestrator` (134487234) by name. |
| `hetzner-console.sh <server> screenshot\|key\|type` | Shell-only access to the VGA console: fresh console session → websocat bridge → vncdotool (venv auto-bootstrapped at `~/.cache/hetzner-console-venv`). |
**Token:** `HCLOUD_TOKEN` env, or `/srv/cc-ci/.hcloud-token` (chmod 600). Not in git, not in
`.testenv`. Prefer per-incident tokens and revoke after — and never paste tokens into chat
transcripts (the 2026-08-03 incident token was pasted mid-incident and had to be flagged for
rotation).
## The 10-minute unreachable-server drill (condensed from 2026-08-03)
1. `hetzner.py cc-ci status` — "running" + no SSH/ping means booted-but-broken, not crashed.
2. `hetzner-console.sh cc-ci screenshot /tmp/console.png` — look at the actual screen: which
generation booted? login prompt or emergency shell?
3. Plain `reboot` first. If the default boot is the broken generation, DON'T fight GRUB
timing over VNC — go to rescue:
`rescue-on``poweroff` → wait `status=off``poweron``ssh root@<public-ip>` (key
113082420 = `~/.ssh/cc-ci-root-ed25519`; fresh `UserKnownHostsFile`).
4. In rescue: `mount /dev/sda1 /mnt` and fix the boot default:
- Generations live in a GRUB **submenu**: entry ids are `1>N` (top level: 0 = default
entry, 1 = the submenu). A bare index like `2` silently falls back to the broken default.
- Persistent: `grub-editenv /mnt/boot/grub/grubenv set 'default=1>N'` (survives reboots).
- **Clear it after the next successful `nixos-rebuild switch`** — the regenerated grub.cfg
shifts indices and a stale override points at the wrong generation.
- Journal of the failed boot: `journalctl -D /mnt/var/log/journal --list-boots` / `-b <id>`.
5. `rescue-off``poweroff``poweron` → verify → write the incident up in
`cc-ci-plan/JOURNAL.md` and (if server) `.cc-ci-logs/`.
**Prevention:** `nixos-rebuild test` before `switch`, always (see AGENTS.md / the update
skills) — `test` leaves the bootloader alone, so a power-cycle recovers by itself.
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# hetzner-console.sh — drive a Hetzner web-console (raw VNC over websocket) from a shell.
# The screenshot/keyboard half of hetzner-server-recovery; proven in the 2026-08-03
# cc-ci 26.05 outage (console screenshots identified the wrong-generation boot).
#
# Usage:
# hetzner-console.sh <server> screenshot <out.png>
# hetzner-console.sh <server> key <key> [key ...] # e.g. key Down Down Return
# hetzner-console.sh <server> type "<text>"
#
# <server> = name/id understood by hetzner.py. Each invocation requests a FRESH console
# session (they are cheap, and hard resets invalidate old ones), bridges it to a local
# TCP port with websocat, and runs vncdo against it. The bridge is single-connection —
# that is why every command re-requests + re-bridges.
#
# Gotchas encoded here so nobody rediscovers them at 2am:
# - The wss_url from the API contains literal '&' — when it arrives via JSON it may be
# &-escaped; parse the JSON properly (as below), never paste from raw output.
# - A hard `reset` drops the console websocket mid-session: re-request and reconnect.
# - GRUB menus: generations live in a SUBMENU — one-shot boot ids are "1>N", and any
# persistent grubenv `default` must be cleared after the next switch regenerates
# grub.cfg (indices shift). See the hetzner-server-recovery skill.
set -o errexit -o nounset -o pipefail
HERE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
SERVER="${1:?usage: hetzner-console.sh <server> screenshot|key|type ...}"
CMD="${2:?need a command: screenshot|key|type}"
shift 2
PORT="${CONSOLE_PORT:-5905}"
VENV="${HOME}/.cache/hetzner-console-venv"
# 1. vncdotool venv (bootstrap once; durable across incidents, unlike /tmp)
if [ ! -x "${VENV}/bin/vncdo" ]; then
echo "bootstrapping vncdotool venv at ${VENV}..." >&2
python3 -m venv "${VENV}"
"${VENV}/bin/pip" -q install vncdotool
fi
# 2. fresh console session
CREDS="$(python3 "${HERE}/hetzner.py" "${SERVER}" console)"
WSS="$(printf '%s' "${CREDS}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["wss_url"])')"
PW="$(printf '%s' "${CREDS}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["password"])')"
# 3. bridge (single-connection; killed on exit)
pkill -f "websocat.*${PORT}" 2>/dev/null || true
sleep 0.5
nix shell nixpkgs#websocat -c websocat --binary "tcp-listen:127.0.0.1:${PORT}" "${WSS}" \
> /tmp/hetzner-console-websocat.log 2>&1 &
BRIDGE=$!
trap 'kill ${BRIDGE} 2>/dev/null || true; pkill -f "websocat.*${PORT}" 2>/dev/null || true' EXIT
sleep 2
# 4. run the vncdo command
case "${CMD}" in
screenshot)
OUT="${1:?screenshot needs an output path}"
timeout 40 "${VENV}/bin/vncdo" -s "127.0.0.1::${PORT}" -p "${PW}" capture "${OUT}"
echo "captured ${OUT}"
;;
key)
[ $# -ge 1 ] || { echo "key needs at least one key name" >&2; exit 1; }
ARGS=()
for k in "$@"; do ARGS+=(key "$k" pause 0.3); done
timeout 60 "${VENV}/bin/vncdo" -s "127.0.0.1::${PORT}" -p "${PW}" "${ARGS[@]}"
echo "sent: $*"
;;
type)
TEXT="${1:?type needs text}"
timeout 60 "${VENV}/bin/vncdo" -s "127.0.0.1::${PORT}" -p "${PW}" type "${TEXT}"
echo "typed"
;;
*)
echo "unknown command ${CMD} (screenshot|key|type)" >&2; exit 1
;;
esac
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Hetzner Cloud recovery helper — the API half of hetzner-server-recovery.
Promoted to the repo 2026-08-04 after the cc-ci 26.05 outage recovery was done with
ad-hoc tooling living in /tmp (which had evaporated from the previous incident).
Token: $HCLOUD_TOKEN, else the file $HCLOUD_TOKEN_FILE, else /srv/cc-ci/.hcloud-token
(chmod 600; ask the operator for a token if absent — and prefer a per-incident token
that gets revoked afterwards).
Usage:
hetzner.py <server> status
hetzner.py <server> actions [n] # recent actions, newest first
hetzner.py <server> reboot|reset|poweroff|poweron
hetzner.py <server> rescue-on [ssh_key_id ...] # then poweroff+poweron to enter it
hetzner.py <server> rescue-off
hetzner.py <server> console # prints wss_url + password (JSON)
<server> is a name from SERVERS below or a numeric Hetzner server id.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
SERVERS = {
"cc-ci": 134485294, # the CI server (nixos, `ssh cc-ci`, tailnet 100.95.31.88)
"orchestrator": 134487234, # this host (cc-ci-orchestrator-1, tailnet 100.84.190.30)
}
# SSH keys registered in the Hetzner project (for rescue-mode injection):
# 113082219 cc-ci-deploy · 113082420 cc-ci-orchestrator-deploy (= ~/.ssh/cc-ci-root-ed25519)
DEFAULT_RESCUE_KEYS = [113082219, 113082420]
def token() -> str:
tok = os.environ.get("HCLOUD_TOKEN")
if not tok:
path = os.environ.get("HCLOUD_TOKEN_FILE", "/srv/cc-ci/.hcloud-token")
try:
tok = open(path).read().strip()
except OSError:
sys.exit(
"ERROR: no Hetzner token. Set HCLOUD_TOKEN, or put one in "
f"{path} (chmod 600). Ask the operator; prefer a revocable per-incident token."
)
return tok
def api(path: str, method: str = "GET", body: dict | None = None) -> dict:
req = urllib.request.Request(
"https://api.hetzner.cloud/v1" + path,
method=method,
headers={"Authorization": "Bearer " + token(), "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
sys.exit(f"ERROR: HTTP {e.code} on {method} {path}: {e.read()[:300]!r}")
def main() -> None:
if len(sys.argv) < 3:
sys.exit(__doc__)
server, cmd, args = sys.argv[1], sys.argv[2], sys.argv[3:]
sid = SERVERS.get(server) or (int(server) if server.isdigit() else None)
if sid is None:
sys.exit(f"ERROR: unknown server {server!r} (known: {', '.join(SERVERS)} or numeric id)")
if cmd == "status":
d = api(f"/servers/{sid}")["server"]
print(
f"status={d['status']} rescue_enabled={d.get('rescue_enabled')} "
f"locked={d['locked']} public_ip={d['public_net']['ipv4']['ip']}"
)
elif cmd == "actions":
n = int(args[0]) if args else 10
for a in api(f"/servers/{sid}/actions?sort=started:desc&per_page={n}")["actions"]:
print(a["started"], a["command"], a["status"], a["progress"])
elif cmd in ("reboot", "reset", "poweroff", "poweron"):
r = api(f"/servers/{sid}/actions/{cmd}", "POST")
print(cmd, r["action"]["status"], r["action"]["started"])
elif cmd == "rescue-on":
keys = [int(k) for k in args] or DEFAULT_RESCUE_KEYS
r = api(f"/servers/{sid}/actions/enable_rescue", "POST", {"type": "linux64", "ssh_keys": keys})
print("enable_rescue", r["action"]["status"], "| root password:", r.get("root_password"))
print("NOTE: rescue boots on the next power cycle — run poweroff, wait for status=off, poweron.")
elif cmd == "rescue-off":
r = api(f"/servers/{sid}/actions/disable_rescue", "POST")
print("disable_rescue", r["action"]["status"])
elif cmd == "console":
r = api(f"/servers/{sid}/actions/request_console", "POST")
print(json.dumps({"wss_url": r["wss_url"], "password": r["password"]}))
else:
sys.exit(f"ERROR: unknown command {cmd!r}\n{__doc__}")
if __name__ == "__main__":
main()