From b0bdce2c1579e3a7185bd086379362abf1622fa0 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 04:04:59 +0000 Subject: [PATCH 1/3] add /cve-check and /cve-check-and-upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /cve-check answers 'what are we exposed to that an upgrade would fix?' without running an upgrade: per-recipe, resolve the available window for EVERY image (sidecars included), run the advisory scan over it, adjudicate whatever pass 1 could not decide, publish a report. Read-only — no PRs, no CI, no merges. /cve-check-and-upgrade does that sweep, then runs /recipe-upgrade only on the recipes whose upgrade actually closes a CVE, worst severity first, and reports on both. --min-severity high for just the urgent ones; --dry-run prints the queue and stops. Never merges. Deliberate choices, each written into the skills: - externals are SWEPT but never upgraded here — a security sweep that skipped deployed software would misreport exposure, but we don't maintain them. - an unknown count never justifies an upgrade AND is never treated as clean; it goes to the Addendum. - no upgrade available means 0 CVEs, not '?'. - subagents are told which CVEs justify their upgrade, so the PR says why it exists — a PR naming the RCE it closes gets reviewed sooner. recipe-report.py grows a page kind: 'cve' files as cve-DATE.html so a sweep can't overwrite a weekly edition, while BOTH appear in the same archive index, suffixed 'full report' / 'CVE check'. /help and /cc-ci-status updated to route to them. --- .claude/skills/cve-check-and-upgrade/SKILL.md | 91 +++++++++++ .claude/skills/cve-check/SKILL.md | 151 ++++++++++++++++++ .opencode/skills/cc-ci-status/SKILL.md | 6 + .opencode/skills/help/SKILL.md | 11 ++ cc-ci-plan/recipe-report.py | 59 +++++-- 5 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/cve-check-and-upgrade/SKILL.md create mode 100644 .claude/skills/cve-check/SKILL.md diff --git a/.claude/skills/cve-check-and-upgrade/SKILL.md b/.claude/skills/cve-check-and-upgrade/SKILL.md new file mode 100644 index 0000000..6f8f8b3 --- /dev/null +++ b/.claude/skills/cve-check-and-upgrade/SKILL.md @@ -0,0 +1,91 @@ +--- +name: cve-check-and-upgrade +description: Security-driven upgrade run. Does a full /cve-check sweep first (per-image advisory scan of every recipe's available upgrade, with adjudication), then runs /recipe-upgrade ONLY on the recipes whose upgrade fixes at least one CVE — worst severity first — opening a verified recipe PR for each, and finally publishes one report covering both the sweep and the PRs. Recipes with no CVEs are left alone; that is the point. NEVER merges. Invoke as /cve-check-and-upgrade [recipe ...] [--min-severity high] [--capacity N] [--dry-run]. +--- + +# cve-check-and-upgrade + +`/upgrade-all` upgrades everything that *has* an upgrade. **This upgrades what has a reason.** It runs +the `/cve-check` sweep, then spends CI time only on the recipes where an upgrade actually closes a +vulnerability, handling the worst first. + +Use it when you want to act on security rather than churn the whole fleet: after a vendor announcement, +when CI capacity is short, or between weekly runs. When you only want to *know*, use `/cve-check`. When +you want everything current regardless of CVEs, use `/upgrade-all`. + +**Creates PRs. Never merges.** Every PR is verified green on cc-ci and left for a human. + +## Arguments +- ` …` — restrict the whole run to these recipes. +- `--min-severity critical|high|medium|low` — only upgrade recipes whose fixed CVEs reach this + severity. Default **`low`** (any CVE at all justifies the upgrade). `--min-severity high` is the + useful "just the urgent ones" setting. +- `--capacity N` — subagent pool size; defaults to the live `DRONE_RUNNER_CAPACITY` (the drone + runner's slots), matching `/upgrade-all`'s rolling-pool behaviour. +- `--dry-run` — do the whole sweep and print exactly which recipes *would* be upgraded and why, then + stop without spawning a single upgrade. **Publishes no report and opens no PR.** + +## Procedure + +### 1. Sweep — run `/cve-check` in full +Follow `.claude/skills/cve-check/SKILL.md` steps 1–5 exactly: candidate list, per-image upgrade windows, +the advisory scan per recipe, pass-2 adjudication of anything undecided, and the severity classification. +**Do not publish its report** — this run produces one combined report at the end instead. + +Keep, per recipe: the windows scanned, the CVE count, the CVE ids with severities, and whether the count +is a floor (undetermined advisories remain) or unknown. + +### 2. Decide what to upgrade +`RECIPES_TO_UPGRADE` = recipes where **the scan found ≥1 CVE** at or above `--min-severity`. + +Deliberate exclusions, each recorded in the report with its reason: +- **0 CVEs** — an upgrade may exist, but nothing security-relevant. Left alone; that is the point of + this skill. `/upgrade-all` is what sweeps those up. +- **`external` tier** — swept for visibility, **never upgraded here**; someone else maintains it. Flag + it loudly in the report if it has a critical, since the action is to tell them, not to open a PR. +- **`UPTODATE`** — nothing available. +- **count `?` / UNKNOWN** — do **not** upgrade blind, and do **not** treat it as clean. Put it in the + Addendum as needing a look. An unknown is a gap in our knowledge, not evidence of safety. + +Order the queue by **worst severity first** (critical → high → …), count breaking ties. If `--dry-run`, +print this queue with each recipe's CVE ids and severities, and STOP here. + +### 3. Upgrade each one — via `/recipe-upgrade` subagents +Run `/recipe-upgrade ` per queued recipe as a **subagent**, in the queue order above, as a +**rolling pool** keeping `--capacity` (default `DRONE_RUNNER_CAPACITY`) running at once and starting the +next as each finishes — the same concurrency discipline as `/upgrade-all` §3, and safe for the same +reason (per-run recipe trees + app-domain locks). + +Each subagent does the full job: plan, implement the bump, verify green on cc-ci with `!testme`, and +open a recipe PR. **Default mode — no `--with-tests`**: a genuinely stale test gets an explanatory PR +comment, not a test edit. + +**Tell each subagent which CVEs justify its upgrade**, with ids and severities, so the PR description +says why it exists. That is most of this skill's value to a reviewer: a PR that names the CVSS-9.8 RCE +it closes gets merged today, an unexplained version bump waits a fortnight. + +Collect per recipe: PR url + number, the `!testme` verdict and build number, and any failure. + +### 4. Report — one page covering sweep AND PRs +Write `/tmp/cve-spec-.json` per `/cve-check` step 6, with these differences: +- Rows for upgraded recipes carry the real `ci` (`build N ✓` / `RED N · `) + `ci_url`, and + `pr`/`pr_url`. `status` is the CI verdict (`GREEN`/`FAILED`/`STALE`); the live PR-status column + derives itself from `recipe` + `pr`. +- Rows for swept-but-not-upgraded recipes keep `PENDING`/`UPTODATE` with empty `ci`/`pr`, and a + `notes` reason (`0 CVEs — not upgraded`, `external — maintained elsewhere`, `below --min-severity`). +- Include `changes[]` — one entry per recipe that got a PR, describing what the upgrade changes **and + the CVEs it closes**. +- Keep `"kind": "cve"`: it titles the page "The Recipe Report — CVE check" and files it as + `cve-.html`, alongside the weekly editions in the same archive index. + +Then render + publish exactly as `/cve-check` step 7, and verify as its step 8. Print the report URL, +`N swept · M upgraded · K PRs green · J failed`, and `CVE CHECK AND UPGRADE COMPLETE`. + +## Guardrails +- **NEVER merge.** Create and verify; a human merges. Never push to true upstream. +- **Never weaken a test** to make a PR green, and never edit a test without `--with-tests`. +- **Never upgrade a recipe whose CVE count is unknown** on the assumption it is fine — surface it. +- **Never upgrade an `external` recipe** here, even with a critical; report it instead. +- **Public-safe report only** — no secrets, tokens, internal hostnames, raw logs, or spend figures. +- If the sweep finds **nothing** at or above `--min-severity`, that is a good outcome: publish the + report saying so and open no PRs. Do not manufacture work. diff --git a/.claude/skills/cve-check/SKILL.md b/.claude/skills/cve-check/SKILL.md new file mode 100644 index 0000000..47a803f --- /dev/null +++ b/.claude/skills/cve-check/SKILL.md @@ -0,0 +1,151 @@ +--- +name: cve-check +description: Fleet-wide CVE sweep WITHOUT upgrading anything. For every recipe cc-ci deploys, works out what upgrade is available (current pinned tag → newest supported tag, per image including sidecars), runs the deterministic advisory scan over that window, adjudicates whatever the scan could not decide, and publishes a CVE report to report.ci.commoninternet.net as cve-.html. READ-ONLY — opens no PRs, edits no recipes, runs no CI, merges nothing. Answers "what are we exposed to that an upgrade would fix?" in minutes rather than the hours a full upgrade run takes. Invoke as /cve-check [recipe ...] [--weekly-only]. +--- + +# cve-check + +A **security sweep, not an upgrade run.** It answers one question for every recipe cc-ci deploys: + +> If we upgraded this recipe today, how many CVEs would that fix, and how bad are they? + +It is the cheap, safe half of `/upgrade-all`: the same version research and the same advisory scan, +with **no implementation, no CI, and no PRs**. Use it when you want the security picture now — after a +vendor announcement, before deciding what to prioritise, or between weekly runs. When you want the PRs +too, use **`/cve-check-and-upgrade`**. + +**Read-only, absolutely.** Never edit a recipe, never open or comment on a PR, never merge, never +deploy. The only thing it writes is its own log and the published report page. + +## Arguments +- ` …` — sweep only these recipes (else every recipe in `cc-ci-plan/used-recipes.md`). +- `--weekly-only` — skip rows tagged `external`. **Off by default on purpose**: an `external` recipe is + still deployed and still exposes us, so a security sweep that silently skipped it would misreport the + fleet's exposure. Externals are swept and clearly marked "maintained elsewhere" in the report. + +## Procedure + +> ### ⚠️ Run abra over a pseudo-TTY (or it FATAs `inappropriate ioctl for device`) +> `abra` needs a TTY. Wrap every abra call: `ssh cc-ci 'script -qec "abra -n" /dev/null'`. +> (`git` and other commands do NOT need the wrapper.) + +### 1. Build the candidate list +Read `cc-ci-plan/used-recipes.md` — the canonical inventory. Take every row (both tiers), recording the +tier per recipe; with `--weekly-only`, drop the `external` rows. An explicit recipe argument overrides +any skip. + +### 2. Per recipe — establish the upgrade window WITHOUT upgrading + +This is `/recipe-upgrade` step 1's research, stopping before it implements anything. + +> ⚠️ **The same four things that silently skip recipes apply here — handle ALL FOUR:** +> 1. **pseudo-TTY** — per the box above. +> 2. **go-git auth to git.autonomic.zone** — recipes on the private mirror FATA +> `authentication required: Unauthorized`. Bake creds into origin first (idempotent, only when +> origin is on git.autonomic.zone): +> `git -C ~/.abra/recipes/ remote set-url origin "https://$GITEA_USERNAME:$GITEA_PASSWORD@git.autonomic.zone/recipe-maintainers/.git"` +> 3. **dirty worktree** — usually just the untracked cc-ci overlay; `git stash -u` before, `stash pop` +> after. Only a genuinely dirty TRACKED tree is a skip. +> 4. **tag+digest pins abra cannot parse** — abra FATAs and aborts the WHOLE recipe (immich). This is +> **not** "not fetchable": enumerate the compose's `image:` refs yourself and check the upstream +> registry directly for the ones abra could not read, picking the newest tag the app version +> supports rather than the numerically highest. + +Reconcile the mirror from true upstream first (so you research the real current recipe, not a stale +mirror), then read versions — **note this is the same reconcile `/upgrade-all` does, and it is +read-only with respect to the recipe's content**: +``` +set -a; . /srv/cc-ci/.testenv; set +a +ssh cc-ci "GITEA_USERNAME='$GITEA_USERNAME' GITEA_PASSWORD='$GITEA_PASSWORD' GITEA_URL='$GITEA_URL' bash -s --reconcile-only" \ + < /srv/cc-ci/.claude/skills/recipe-upgrade/open-recipe-pr.sh +ssh cc-ci 'export PATH=/run/current-system/sw/bin:$PATH; R=; \ + git -C ~/.abra/recipes/$R stash -u >/dev/null 2>&1 || true; \ + script -qec "abra recipe fetch $R --force -n" /dev/null; \ + script -qec "abra recipe upgrade $R -m -n" /dev/null; \ + git -C ~/.abra/recipes/$R stash pop >/dev/null 2>&1 || true' +``` + +For each recipe produce **one window per image**: `current pinned tag → newest supported tag`. You need +the sidecars (redis, postgres, nginx …), not just the app — a sidecar bump is where discourse's only +CRITICAL came from, and an image with no window is not counted at all. + +- **No upgrade available** → the recipe is `UPTODATE`; its CVE count is **`0`**, not `?`. There is + nothing an upgrade could fix. Record it and move on. + +### 3. Run the advisory scan over that window +``` +python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py --from --to \ + [--image =:]... +``` +**One call per recipe with every image in it** — the count is a union across images, and the +UNKNOWN guarantee only holds when a single run sees them all. Paste the markdown block verbatim into +the per-recipe log at `/srv/cc-ci/.cc-ci-logs/cve-check//.md`. + +### 4. Adjudicate what the scan could not decide (pass 2) +If the block reports advisories it **could NOT judge**, or the count is **UNKNOWN**, re-run with +`--adjudicate` and decide each open case yourself: + +``` +python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py … --adjudicate +``` + +Answer **FIXED / NOT-FIXED / STILL-UNKNOWN** per case, each with a one-line reason **citing the +evidence shown** — never from memory of the project, which is the exact failure that let two CVSS-9.8 +gitea RCEs be published as "none". Every FIXED is added to the count; pass 1's number is a floor. The +block also lists what pass 1 already decided — if a verdict looks wrong given its evidence, say so. +Record your verdicts in the per-recipe log so the number is auditable. + +### 5. Classify severity and priority +For each recipe collect the CVE ids with **severities** (the scan gives them, with GHSA ids). Sort the +report rows by what an operator should deal with first: +1. recipes with a **critical**, then **high**, then anything else with CVEs (more CVEs higher within a band); +2. then `?` (a count that could not be established — investigate, do not ignore); +3. then recipes with an upgrade available but **0** CVEs; +4. then `UPTODATE`. + +Severity outranks raw count: 2 CVSS-9.8 RCEs matter more than 120 medium plugin advisories. + +### 6. Write the report spec +`/tmp/cve-spec-.json`, same shape as `/recipe-report` (see `recipe-report.py`'s header), with: +- **`"kind": "cve"`** — titles the page "The Recipe Report — CVE check" and files it as + `cve-.html`. It appears in the SAME archive index as the weekly editions, suffixed + "— CVE check" so the two are told apart at a glance. Without this field you would overwrite that + date's weekly edition. +- `date`, `subtitle` "CVE check ", +- `lead` — **one short paragraph**: fleet exposure in a sentence and what to do first. +- `table[]` — every recipe swept. `recipe`; `change` = the window you scanned, e.g. + `1.27.0 → 1.27.1 · redis 7.4 → 8.10`; `status` = `UPTODATE` when nothing is available, else + `PENDING` (an upgrade exists and is not yet taken); **`cve`** = the count (integer, `?` only per the + rules below); `notes` = severity mix, whether the number is a floor, and `maintained elsewhere` for + `external` rows. **Leave `ci`/`pr` empty — nothing was built and no PR exists.** +- `addendum[]` — real anomalies only: registry URLs that failed, recipes whose window could not be + established, a scan whose count is a floor with many undetermined advisories. +- `security[]` — one entry per **critical/high** finding: recipe · CVE id(s) + severity · what it fixes + · **which image** it is in. Name the image: `CVE-2025-49844` is a redis flaw, and an operator reading + "discourse" needs to know that. +- `changes[]` — **omit** (nothing changed; there are no PRs). + +**`?` must stay RARE.** Use it only when a scan ran and reported genuinely failed sources, or the count +came back UNKNOWN and adjudication could not settle it. Never `none` for an unknown — a blank reads as +clean. A recipe with no upgrade available is `0`, not `?`. Many `?` is a bug for the Addendum. + +### 7. Render and publish — via the script only +``` +python3 /srv/cc-ci/cc-ci-plan/recipe-report.py render /tmp/cve-spec-.json /tmp/cve-.html +python3 /srv/cc-ci/cc-ci-plan/recipe-report.py publish /tmp/cve-.html cve +``` +All layout is owned by `recipe-report.py`. Never hand-write or post-process HTML; if `render` errors, +fix the spec JSON and re-render. **Public page — no secrets, tokens, internal hostnames, raw logs, or +any billing/spend figures.** + +### 8. Verify and stop +`curl -fsS https://report.ci.commoninternet.net/cve-.html` renders and the index lists it. Print +the URL, a one-line summary (`N recipes swept · M with CVEs · K critical`), and `CVE CHECK COMPLETE`, +then go idle. One-shot — do not loop, and do not start upgrading anything. + +## Guardrails +- **Read-only.** No PRs, no edits, no merges, no deploys, no CI runs. If a recipe looks urgent, say so + in the report — do not act on it. `/cve-check-and-upgrade` is the skill that acts. +- **Never report `0` for something you could not scan.** `0` means checked-and-clean; unknown is `?`. +- A count with undetermined advisories is a **floor** — say so in the notes rather than rounding away. +- **Public-safe output only.** diff --git a/.opencode/skills/cc-ci-status/SKILL.md b/.opencode/skills/cc-ci-status/SKILL.md index 21649d5..0366ef7 100644 --- a/.opencode/skills/cc-ci-status/SKILL.md +++ b/.opencode/skills/cc-ci-status/SKILL.md @@ -106,6 +106,12 @@ systemctl --failed --no-legend; df -h / | tail -1; tmux ls 1. → / (or operator action) … ``` +When a finding is that the fleet's **security exposure is unknown** — the last weekly run failed or +is stale, so nobody has scanned for CVEs recently — the recommended step is **`/cve-check`** (read-only, +minutes, no PRs). If it is instead that a known CVE is sitting unpatched, recommend +**`/cve-check-and-upgrade`** (add `--min-severity high` when only the urgent ones matter). Prefer +`/cve-check` over waiting for the next weekly run whenever the question is "are we exposed?". + `ALL HEALTHY` requires: recent successful weekly run + published report, no stale tests, no CVE PR open >14 days, both hosts <30 days behind their channel, zero failed units, disk under thresholds, bridge clean, maintained-set consistent. Anything else is a finding — even minor diff --git a/.opencode/skills/help/SKILL.md b/.opencode/skills/help/SKILL.md index 519ddfb..aef266b 100644 --- a/.opencode/skills/help/SKILL.md +++ b/.opencode/skills/help/SKILL.md @@ -31,6 +31,14 @@ Then present the roster grouped as follows, and close with the situation guide. PR). `--with-tests` also fixes that recipe's stale test. - **/recipe-report** — (re)generate the weekly report page for report.ci.commoninternet.net. +**Security (CVEs)** +- **/cve-check** — fleet-wide CVE sweep with **no upgrading**: for every recipe, work out what + upgrade is available (per image, sidecars included), scan it for CVEs, and publish a CVE report. + Read-only and quick — the "what are we exposed to?" answer without an upgrade run. +- **/cve-check-and-upgrade** — the same sweep, then open verified PRs **only** for the recipes whose + upgrade actually fixes a CVE, worst severity first. `--min-severity high` for just the urgent ones. + Never merges. + **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. @@ -73,6 +81,9 @@ ARM skills never touch cc-ci infra. After a submodule bump run `scripts/gen-ccte | "Run the weekly upgrades now" | `/upgrade-all` (or `systemctl start cc-ci-upgrade-all.service`) | | "Upgrade just " | `/recipe-upgrade ` | | "The report site is stale/missing a week" | `/recipe-report` | +| "What CVEs are we exposed to right now?" | `/cve-check` (read-only, no PRs) | +| "A CVE just dropped — check and patch it" | `/cve-check-and-upgrade` (add `--min-severity high` to skip the noise) | +| "Is vulnerable?" | `/cve-check ` | | "Tests are red because upstream changed" | `/cc-ci-tests-update` (fleet) or `/recipe-upgrade --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` | diff --git a/cc-ci-plan/recipe-report.py b/cc-ci-plan/recipe-report.py index 6c42772..b124996 100755 --- a/cc-ci-plan/recipe-report.py +++ b/cc-ci-plan/recipe-report.py @@ -9,7 +9,11 @@ Subcommands (the /recipe-report agent runs them around its own review/classifica survey [DATE] JSON of the run + every recipe's open PRs + CI verdict + per-recipe upgrade notes (breaking-change/CVE analysis), and the /upgrade-all summary. render SPEC.json OUT.html render the agent's report spec -> a self-contained newspaper HTML page - publish OUT.html DATE copy to cc-ci:/var/lib/cc-ci-reports/week-DATE.html and regen the archive index + publish OUT.html DATE [KIND] copy to cc-ci:/var/lib/cc-ci-reports/-DATE.html and regen the + archive index. KIND is `week` (default, the weekly /recipe-report) or `cve` + (a /cve-check advisory sweep). BOTH kinds appear in the SAME archive index, + newest first, each row suffixed "full report" or "CVE check"; the distinct + filename prefix just stops a sweep overwriting a weekly edition. Page order: short lead → the full wire table (priority-sorted, CVEs column) → Addendum → Security Bulletin → per-recipe "What changed". @@ -44,6 +48,10 @@ LOGDIR = "/srv/cc-ci/.cc-ci-logs" TESTENV = "/srv/cc-ci/.testenv" INFRA = {"cc-ci", "cc-ci-orchestrator", "cc-ci-secrets"} HOST_REPORTS = "/var/lib/cc-ci-reports" +# Both kinds live in ONE archive, distinguished by a suffix on a common title. +# prefix -> (page title, index label) +KINDS = {"week": ("The Recipe Report", "Week of {d} — full report"), + "cve": ("The Recipe Report — CVE check", "{d} — CVE check")} def _env(): @@ -215,8 +223,16 @@ def _table(rows, repo_url=None): if repo_url and r.get("recipe") in repo_url: name = f'{name}' cve = r.get("cve") - cve_cell = (f'{int(cve)}' if isinstance(cve, (int, float)) and cve - else 'none') + # "?" = advisory scan absent or had failed sources → count NOT authoritative. Per the + # /recipe-report guardrail this must NEVER render as "none" (a blank-that-reads-clean is + # exactly how two CVSS-9.8 gitea RCEs were misreported as "none" on 2026-08-07). A positive + # int is the confirmed CVE count; 0/omit is a confirmed-clean scan. + if isinstance(cve, str) and cve.strip() == "?": + cve_cell = '?' + elif isinstance(cve, (int, float)) and cve: + cve_cell = f'{int(cve)}' + else: + cve_cell = 'none' ci = _esc(r.get("ci")) if r.get("ci_url"): ci = f'{ci}' @@ -270,8 +286,10 @@ def _mast(): def render(spec_path, out_path): s = json.load(open(spec_path)) + kind = s.get("kind", "week") + title = KINDS.get(kind, KINDS["week"])[0] gen = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - sub = s.get("subtitle", "Week of " + s["date"]) + sub = s.get("subtitle", ("Week of " if kind == "week" else "CVE check ") + s["date"]) lead = s.get("lead", "") or "" # Auto-link recipe-name mentions in the lead to their mirror repos. gitea = _env().get("GITEA_URL", "git.autonomic.zone") @@ -285,7 +303,9 @@ def render(spec_path, out_path): f'report.ci.commoninternet.net{gen}' f'
{lead}
') # 1) the full wire — every recipe, in the agent's recommended priority order (CVEs first); CVEs column. - body += f'

The full wire — every recipe, in priority order

{_table(s.get("table"), repo_url)}' + wire = ("The full wire — every recipe, in priority order" if kind == "week" + else "Advisory sweep — every recipe, worst first") + body += f'

{wire}

{_table(s.get("table"), repo_url)}' # 2) addendum — special issues to look into (normal-size header); omitted entirely if there are none. add = [a for a in (s.get("addendum") or []) if str(a).strip()] if add: @@ -298,19 +318,30 @@ def render(spec_path, out_path): # 4) what changed — a short section per recipe that has a PR if s.get("changes"): body += f'

What changed

{_changes(s.get("changes"), repo_url)}' - body += (f'