Merge pull request '[ka:cli-build-out] ka-promote: implement resolver + cumulative + manifest.lock (closes #22)' (#23) from claude-noether/kernel-agent:noether/ka-promote into main

Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
2026-05-18 08:57:15 +00:00
9 changed files with 527 additions and 21 deletions
+7
View File
@@ -0,0 +1,7 @@
# ka-promote / ka-build output
/build/
# transient
*.pyc
__pycache__/
.DS_Store
+27 -15
View File
@@ -106,10 +106,12 @@ persistent, audit trail per item).
## Verbs (explicit, parameterized, audit-issue auto-filed)
```
ka-promote <campaign> <patch-or-glob> --to <scope>
ka-promote <host> # resolve fleet/<host>.yaml → cumulative.patch + manifest.lock [bin/ka-promote — implemented Phase 6, issue #22]
ka-import <campaign> <patch-or-glob> --to <scope> # patches from campaign → scope-tagged tree (today: manual git workflow)
ka-close <campaign> --status success
ka-abandon <campaign> --keep-as-archive | --purge-from-fleet
ka-install <host>
ka-build <host> # render PKGBUILD template with cumulative b2sum, run makepkg [next verb, issue TBD]
ka-install <host> # scp + pacman -U + extlinux/mkinitcpio + heartbeat [last verb, issue TBD]
ka-keep <job-id> [--for <duration>]
ka-pause-prune / ka-resume-prune
ka-restore-archive <job-id>
@@ -120,6 +122,13 @@ ka-migrate-tree --from <p> --to <p>
ka-wake-data # wraps wake-host data through His
```
Note: the original spec had `ka-promote <campaign> <patch-or-glob> --to <scope>`
("promote patches from a campaign into the canonical tree"). That semantic
moved to `ka-import` to free `ka-promote` for the manifest-resolution role
its issue (#22) and the implemented `bin/ka-promote` actually fulfil. `ka-import`
remains unimplemented — patches still land in `patches/` via the regular git
+ PR workflow.
Conversational invocation triggers a y/n confirmation enumerating what will
happen. Direct CLI invocation executes immediately.
@@ -226,15 +235,17 @@ via `ka-snooze <issue-id> [--for <duration>]`.
## Bootstrap reference build (2026-05-09 — fresnel)
First end-to-end run, before any `ka-*` CLI exists. Documented here as the
canonical worked example so future ka-* implementations have a concrete
substrate to replay. Issue #3 (fresnel DTS persistence) closed by this
build.
First end-to-end run, before `ka-promote` / `ka-build` / `ka-install` existed.
Documented here as the canonical worked example; the substrate that the ka-*
verbs are/will-be implemented against. Issue #3 (fresnel DTS persistence) closed by this
build. `ka-promote` (issue #22) replaced the manual step #1 below as of 2026-05-18.
### Inputs
- **Baseline:** mmind/linux-rockchip @ `v7.0` (Heiko Stübner / Collabora,
via kernel.org).
- **Baseline:** torvalds/linux @ `v7.0` (verified during ka-promote Phase 3,
issue #22 — mmind/linux-rockchip does not ship a plain `v7.0` tag despite
earlier docs; mmind kept in fresnel.yaml as informational
`patch_authoring_context`).
- **Patches** (scope `board/pinebook-pro`):
- `0001-arm64-dts-rk3399-pinebook-pro-add-OC-OPP-tables-1704-2184.patch`
- `0002-arm64-dts-rk3399-pinebook-pro-enable-hdmi-sound.patch`
@@ -249,13 +260,14 @@ build.
### Manual substitute for each ka-* verb
| Designed verb | What we did manually |
|---|---|
| `ka-promote fresnel-fourier <patches> --to board/pinebook-pro` | Authored 3 patches with proper headers/scope tags, pushed to `marfrit/kernel-agent/patches/board/pinebook-pro/` via Gitea contents API as `claude-noether`. |
| `ka-build fresnel` | On boltzmann: cloned linux v7.0 from kernel.org, ran `makepkg -s --skipchecksums --skippgpcheck` against `marfrit-packages/arch/linux-fresnel-fourier/PKGBUILD`. Native aarch64 (boltzmann is RK3588). One headers-pkg bug discovered (`ln -sr` on missing parent dir) and fixed mid-flight. Repackaged. |
| `ka-sign + push` | scp pkgs hertz → `sudo /opt/herding/bin/marfrit-publish-arch aarch64 <pkg>` per pkg. Script signs with key `92D5E96D8F63C75E4116AA1FF5C8C4603D0D250C`, runs repo-add, rsyncs to nc. |
| `ka-install fresnel` (consent-via-action) | `sudo pacman -U /tmp/<pkg>` over LAN scp (HTTPS to nc was throttled by fresnel's wifi). pacman post-transaction hook updated extlinux. mkinitcpio run manually because the standard hook trigger watches `vmlinuz` not `Image`. |
| Bar 1..3 verification | SSH heartbeat OK, `pacman -Q linux-fresnel-fourier` = `7.0-1`, post-reboot cluster0 1.704 GHz / cluster1 2.184 GHz confirmed. |
| Designed verb | What we did manually | Status |
|---|---|---|
| `ka-import fresnel-fourier <patches> --to board/pinebook-pro` (originally named `ka-promote` in this row) | Authored 3 patches with proper headers/scope tags, pushed to `marfrit/kernel-agent/patches/board/pinebook-pro/` via Gitea contents API as `claude-noether`. | still manual — `ka-import` unimplemented |
| `ka-promote fresnel` (new — manifest → cumulative.patch + manifest.lock) | n/a (didn't exist) | **automated 2026-05-18, issue #22** |
| `ka-build fresnel` | On boltzmann: cloned linux v7.0 from kernel.org, ran `makepkg -s --skipchecksums --skippgpcheck` against `marfrit-packages/arch/linux-fresnel-fourier/PKGBUILD`. Native aarch64 (boltzmann is RK3588). One headers-pkg bug discovered (`ln -sr` on missing parent dir) and fixed mid-flight. Repackaged. | still manual — next verb to implement |
| `ka-sign + push` | scp pkgs hertz → `sudo /opt/herding/bin/marfrit-publish-arch aarch64 <pkg>` per pkg. Script signs with key `92D5E96D8F63C75E4116AA1FF5C8C4603D0D250C`, runs repo-add, rsyncs to nc. | still manual — folded into `ka-build` |
| `ka-install fresnel` (consent-via-action) | `sudo pacman -U /tmp/<pkg>` over LAN scp (HTTPS to nc was throttled by fresnel's wifi). pacman post-transaction hook updated extlinux. mkinitcpio run manually because the standard hook trigger watches `vmlinuz` not `Image`. | still manual — last verb to implement |
| Bar 1..3 verification | SSH heartbeat OK, `pacman -Q linux-fresnel-fourier` = `7.0-1`, post-reboot cluster0 1.704 GHz / cluster1 2.184 GHz confirmed. | folded into `ka-install` |
### Files / locations involved
Executable
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""ka-promote — resolve fleet/<host>.yaml + emit cumulative.patch + manifest.lock.
First of the three writing verbs (ka-promote → ka-build → ka-install).
Read-mostly: only writes to ${KA_BUILD_DIR:-./build}/<host>/<baseline_ref>/.
Usage:
ka-promote <host>
ka-promote <host> --output-dir <path>
ka-promote <host> --validate-against <linux-checkout>
ka-promote --list-hosts
ka-promote --version
Exit codes:
0 success
2 missing input (manifest, patch file, series-dir)
3 --validate-against failed (ref mismatch or apply-check failure)
4 manifest parse / schema error
Language note: pure python3 (not bash like ka-status). The data shape
here — YAML in, YAML out, dict construction, per-file hashing, glob
resolution — fits python naturally; bash + python -c heredocs would be
quoting hell for no readability gain. See issue #22 comment 1132.
"""
import argparse
import glob
import hashlib
import os
import subprocess
import sys
from datetime import datetime, timezone
import yaml
VERSION = 1
SCHEMA_VERSION = 1
COVER_LETTER = "0000-cover-letter.patch"
def die(msg, code=1):
print(f"ka-promote: error: {msg}", file=sys.stderr)
sys.exit(code)
def find_repo_root():
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
if not os.path.isdir(os.path.join(root, "fleet")):
die(f"fleet/ not found relative to {here}", 4)
return root
def list_hosts(fleet_dir):
for path in sorted(glob.glob(os.path.join(fleet_dir, "*.yaml"))):
print(os.path.basename(path)[:-5])
def load_manifest(path):
try:
raw = open(path, "rb").read()
except FileNotFoundError:
die(f"manifest not found: {path}", 2)
sha = hashlib.sha256(raw).hexdigest()
try:
m = yaml.safe_load(raw)
except yaml.YAMLError as e:
die(f"manifest parse error: {e}", 4)
if not isinstance(m, dict):
die(f"manifest root must be a mapping: {path}", 4)
for key in ("host", "baseline", "includes"):
if key not in m:
die(f"manifest missing required key '{key}': {path}", 4)
if not isinstance(m["includes"], list) or not m["includes"]:
die(f"manifest.includes must be a non-empty list: {path}", 4)
return m, sha
def resolve_includes(includes, patches_root):
"""Walk manifest.includes, expand series-dirs, dedupe-check, hash."""
seen = set()
resolved = []
order = 0
for entry in includes:
if not isinstance(entry, str):
die(f"includes entry must be a string, got {type(entry).__name__}: {entry!r}", 4)
if entry in seen:
die(f"duplicate include: {entry}", 4)
seen.add(entry)
src_path = os.path.join(patches_root, entry)
if entry.endswith(".patch"):
if not os.path.isfile(src_path):
die(f"missing patch: {src_path}", 2)
order += 1
resolved.append({
"apply_order": order,
"include": entry,
"src": src_path,
"from_series": False,
})
elif entry.endswith("/"):
dir_path = src_path.rstrip("/")
if not os.path.isdir(dir_path):
die(f"missing series-dir: {dir_path}", 2)
files = sorted(glob.glob(os.path.join(dir_path, "*.patch")))
files = [f for f in files if os.path.basename(f) != COVER_LETTER]
if not files:
die(f"series-dir has no applied patches (only cover-letter or empty): {dir_path}", 2)
for f in files:
order += 1
resolved.append({
"apply_order": order,
"include": entry + os.path.basename(f),
"src": f,
"from_series": True,
})
else:
die(f"include must end in '.patch' or '/': {entry}", 4)
for r in resolved:
with open(r["src"], "rb") as f:
data = f.read()
r["sha256"] = hashlib.sha256(data).hexdigest()
r["size"] = len(data)
return resolved
def write_cumulative(resolved, out_path):
with open(out_path, "wb") as out:
for r in resolved:
with open(r["src"], "rb") as src:
out.write(src.read())
with open(out_path, "rb") as f:
b2 = hashlib.blake2b(f.read()).hexdigest()
size = os.path.getsize(out_path)
return size, b2
def write_lock(lock_path, *, host, manifest_rel, manifest_sha, baseline,
resolved, cumulative_size, cumulative_b2sum):
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if epoch:
generated_at = datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat()
else:
generated_at = datetime.now(tz=timezone.utc).isoformat()
lock = {
"ka_promote_version": VERSION,
"schema_version": SCHEMA_VERSION,
"generated_at": generated_at,
"host": host,
"manifest": {"path": manifest_rel, "sha256": manifest_sha},
"baseline": baseline,
"resolved_patches": [
{
"apply_order": r["apply_order"],
"include": r["include"],
"sha256": r["sha256"],
"size": r["size"],
"from_series": r["from_series"],
}
for r in resolved
],
"cumulative": {
"path": "cumulative.patch",
"size": cumulative_size,
"b2sum": cumulative_b2sum,
},
}
with open(lock_path, "w") as f:
yaml.dump(lock, f, sort_keys=True, default_flow_style=False)
def validate_against(checkout, baseline_ref, cumulative_path):
# `.git` is a directory in a plain checkout, a file (gitdir pointer)
# in a worktree. `os.path.exists` covers both.
if not os.path.exists(os.path.join(checkout, ".git")):
die(f"--validate-against: not a git checkout: {checkout}", 3)
def git(*args):
return subprocess.run(
["git", *args], cwd=checkout, capture_output=True, text=True
)
r = git("rev-parse", f"{baseline_ref}^{{tree}}")
if r.returncode != 0:
die(f"baseline ref '{baseline_ref}' not found in checkout {checkout}", 3)
baseline_tree = r.stdout.strip()
head_tree = git("rev-parse", "HEAD^{tree}").stdout.strip()
if head_tree != baseline_tree:
die(f"checkout HEAD tree {head_tree} != baseline.ref {baseline_ref} tree {baseline_tree}. "
"Refusing apply-check on diverged tree.", 3)
# Working tree must match HEAD too — `git apply --check` runs against
# the working tree, not HEAD, so a dirty tree gives false negatives.
r = git("status", "--porcelain")
if r.stdout.strip():
die(f"checkout {checkout} has uncommitted changes. "
"`git reset --hard {0} && git clean -fdx` first.".format(baseline_ref), 3)
r = git("apply", "--check", cumulative_path)
if r.returncode != 0:
die(f"git apply --check failed:\n{r.stderr}", 3)
def main():
p = argparse.ArgumentParser(prog="ka-promote", add_help=True)
p.add_argument("host", nargs="?", help="fleet host name (omit with --list-hosts/--version)")
p.add_argument("--output-dir", help="override ${KA_BUILD_DIR:-<repo>/build}")
p.add_argument("--validate-against", metavar="CHECKOUT",
help="run git apply --check against a clean baseline.ref checkout")
p.add_argument("--list-hosts", action="store_true", help="list available fleet/<host>.yaml manifests")
p.add_argument("--version", action="store_true", help="print ka-promote schema version + exit")
args = p.parse_args()
repo_root = find_repo_root()
fleet_dir = os.path.join(repo_root, "fleet")
patches_root = os.path.join(repo_root, "patches")
if args.version:
print(f"ka-promote version {VERSION} (schema {SCHEMA_VERSION})")
return 0
if args.list_hosts:
list_hosts(fleet_dir)
return 0
if not args.host:
p.error("host is required (or use --list-hosts / --version)")
manifest_path = os.path.join(fleet_dir, f"{args.host}.yaml")
manifest, manifest_sha = load_manifest(manifest_path)
if manifest.get("host") != args.host:
die(f"manifest.host {manifest.get('host')!r} does not match filename {args.host!r}", 4)
baseline = manifest["baseline"]
if "ref" not in baseline:
die("manifest.baseline.ref is required", 4)
baseline_ref = baseline["ref"]
resolved = resolve_includes(manifest["includes"], patches_root)
out_root = args.output_dir or os.environ.get("KA_BUILD_DIR") or os.path.join(repo_root, "build")
out_dir = os.path.join(out_root, args.host, baseline_ref)
os.makedirs(out_dir, exist_ok=True)
cumulative_path = os.path.join(out_dir, "cumulative.patch")
size, b2sum = write_cumulative(resolved, cumulative_path)
write_lock(
os.path.join(out_dir, "manifest.lock"),
host=args.host,
manifest_rel=os.path.relpath(manifest_path, repo_root),
manifest_sha=manifest_sha,
baseline=baseline,
resolved=resolved,
cumulative_size=size,
cumulative_b2sum=b2sum,
)
if args.validate_against:
validate_against(args.validate_against, baseline_ref, cumulative_path)
print(f"ka-promote: {args.host} -> {out_dir}")
print(f" cumulative: cumulative.patch ({size} bytes)")
print(f" b2sum: {b2sum}")
print(f" patches: {len(resolved)} resolved ({sum(1 for r in resolved if r['from_series'])} from series-dirs)")
return 0
if __name__ == "__main__":
sys.exit(main())
+9 -6
View File
@@ -1,9 +1,11 @@
# kernel-agent manifest for fresnel (Pinebook Pro / Rockchip RK3399)
#
# Status: bootstrap, manually-driven. ka-* CLI not yet implemented.
# This manifest is the input ka-promote / ka-build will consume once landed.
# Until then it documents the canonical patch set + baseline for the manual
# build that produces linux-fresnel-fourier.
# Status: ka-promote-consumable. Used as Phase-3 parity reference for ka-promote
# bring-up (issue #22). ka-build / ka-install CLI still pending.
#
# baseline.tree corrected 2026-05-18: mmind/linux-rockchip does not ship a
# plain v7.0 tag; baseline is torvalds/linux v7.0. mmind kept as informational
# patch_authoring_context.
host: fresnel
arch: arm64
@@ -12,10 +14,11 @@ board: pinebook-pro
distro: archlinux-arm # EndeavourOS pacman base
baseline:
tree: mmind/linux-rockchip
url: https://git.kernel.org/pub/scm/linux/kernel/git/mmind/linux-rockchip.git
tree: torvalds/linux
url: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
ref: v7.0
upstream_compat: linux-7.0 # what the patches target
patch_authoring_context: mmind/linux-rockchip # informational — patches authored against Rockchip rebase
# Scope-tagged patch includes. Each entry resolves to
# patches/<scope>/.../<file>.patch in marfrit/kernel-agent.
@@ -0,0 +1,20 @@
# Bad-include fixture: an entry that does not end in .patch or /.
# Expected: ka-promote exits 4 with a clear schema error.
host: fixture-bad-include
arch: arm64
soc: rockchip/rk3566
board: fixture
distro: archlinux-arm
baseline:
tree: torvalds/linux
url: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
ref: v7.0
includes:
- board/pinebook-pro/0001-arm64-dts-rk3399-pinebook-pro-add-OC-OPP-tables-1704-2184.patch
- this-is-not-a-patch-or-dir # neither .patch nor /
package:
name: fixture-bad-include
@@ -0,0 +1,21 @@
# Duplicate-include fixture: same include twice.
# Expected: ka-promote exits 4 with a "duplicate include" error.
host: fixture-duplicate-include
arch: arm64
soc: rockchip/rk3566
board: fixture
distro: archlinux-arm
baseline:
tree: torvalds/linux
url: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
ref: v7.0
includes:
- board/pinebook-pro/0001-arm64-dts-rk3399-pinebook-pro-add-OC-OPP-tables-1704-2184.patch
- board/pinebook-pro/0002-arm64-dts-rk3399-pinebook-pro-enable-hdmi-sound.patch
- board/pinebook-pro/0001-arm64-dts-rk3399-pinebook-pro-add-OC-OPP-tables-1704-2184.patch # dup
package:
name: fixture-duplicate-include
@@ -0,0 +1,20 @@
# Missing-patch fixture: a .patch include pointing at a file that doesn't exist.
# Expected: ka-promote exits 2 with a "missing patch" error.
host: fixture-missing-patch
arch: arm64
soc: rockchip/rk3566
board: fixture
distro: archlinux-arm
baseline:
tree: torvalds/linux
url: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
ref: v7.0
includes:
- board/pinebook-pro/0001-arm64-dts-rk3399-pinebook-pro-add-OC-OPP-tables-1704-2184.patch
- board/pinebook-pro/9999-this-patch-does-not-exist.patch
package:
name: fixture-missing-patch
+21
View File
@@ -0,0 +1,21 @@
# Synthetic fixture: single series-dir include.
# Used by tests/ka-promote/run-tests.sh to verify the series-dir resolver
# expands a directory entry to its .patch files in filename order, with
# 0000-cover-letter.patch excluded.
host: fixture-series-dir
arch: arm64
soc: rockchip/rk3566
board: pinetab2
distro: archlinux-arm
baseline:
tree: torvalds/linux
url: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
ref: v7.0
includes:
- driver/bes2600/staging-prep-series-danctnix/
package:
name: fixture-series-dir
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# ka-promote test suite.
#
# Each test runs ka-promote against a fixture from fixtures/ in a temporary
# sandboxed kernel-agent tree (bin/, fleet/, patches/ — patches/ symlinked
# from the real repo so we exercise the real scope-tagged patch files).
#
# Exit 0 iff every test passes. Non-zero on first failure.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fixtures="${repo_root}/tests/ka-promote/fixtures"
# Phase-3 ground truth — recorded 2026-05-18, fresnel cumulative b2sum.
FRESNEL_EXPECTED_B2SUM=4d9d93c655ea701b587bf1383c794f41b1aeb3bc32bca69ce3488852ec2c1474a2f47585608598b39ac05671490b8df63c5bc7d093f87e1afd5a92f908891b67
pass=0
fail=0
results=()
note() { printf ' %s\n' "$*"; }
ok() { results+=("PASS $1"); pass=$((pass+1)); note "PASS"; }
ko() { results+=("FAIL $1: $2"); fail=$((fail+1)); note "FAIL: $2"; }
make_sandbox() {
# $1 = fixture yaml path. Builds a scratch tree with bin/+fleet/+patches/
# and copies the fixture into fleet/<host>.yaml (extracting host from fixture).
local fixture="$1"
local scratch
scratch=$(mktemp -d -t ka-promote-test.XXXXXX)
mkdir -p "$scratch/bin" "$scratch/fleet"
cp "$repo_root/bin/ka-promote" "$scratch/bin/ka-promote"
ln -s "$repo_root/patches" "$scratch/patches"
local host
host=$(python3 -c "import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))['host'])" "$fixture")
cp "$fixture" "$scratch/fleet/${host}.yaml"
echo "$scratch|$host"
}
run_test() {
local name="$1"
local fixture="$2"
local expected_exit="$3"
local check_fn="${4:-}"
echo "::: $name"
local pair scratch host
pair=$(make_sandbox "$fixture")
scratch="${pair%|*}"
host="${pair#*|}"
set +e
out=$("$scratch/bin/ka-promote" "$host" --output-dir "$scratch/build" 2>&1)
actual_exit=$?
set -e
if [ "$actual_exit" -ne "$expected_exit" ]; then
ko "$name" "expected exit $expected_exit, got $actual_exit. Output: $out"
rm -rf "$scratch"
return
fi
if [ -n "$check_fn" ]; then
if ! "$check_fn" "$scratch" "$host" "$out"; then
ko "$name" "check function reported failure (see notes above)"
rm -rf "$scratch"
return
fi
fi
ok "$name"
rm -rf "$scratch"
}
check_series_dir() {
# 7 patches resolved, all from_series:true, in filename order.
local scratch="$1" host="$2"
local lock="$scratch/build/$host/v7.0/manifest.lock"
[ -f "$lock" ] || { note "manifest.lock missing"; return 1; }
local n
n=$(python3 -c "import yaml; print(len(yaml.safe_load(open('$lock'))['resolved_patches']))")
if [ "$n" -ne 7 ]; then
note "expected 7 resolved patches, got $n"
return 1
fi
python3 - "$lock" <<'PY' || { note "from_series check failed"; exit 1; }
import yaml, sys
lk = yaml.safe_load(open(sys.argv[1]))
for r in lk['resolved_patches']:
assert r['from_series'] is True, f"{r['include']} not flagged from_series"
expected_basenames = [f'0{i:03d}'[1:] for i in range(1,8)] # 0001..0007
got = [r['include'].split('/')[-1][:4] for r in lk['resolved_patches']]
assert got == ['000'+str(i) for i in range(1,8)], f'apply order mismatch: {got}'
PY
}
check_fresnel_parity() {
local scratch="$1" host="$2"
local lock="$scratch/build/$host/v7.0/manifest.lock"
[ -f "$lock" ] || { note "manifest.lock missing"; return 1; }
local b2
b2=$(python3 -c "import yaml; print(yaml.safe_load(open('$lock'))['cumulative']['b2sum'])")
if [ "$b2" != "$FRESNEL_EXPECTED_B2SUM" ]; then
note "b2sum mismatch"
note " expected: $FRESNEL_EXPECTED_B2SUM"
note " got: $b2"
return 1
fi
}
echo
echo "Running ka-promote test suite from $repo_root"
echo
# Use the real fleet/fresnel.yaml — copy into a sandbox so the test is hermetic.
mkdir -p /tmp/ka-promote-parity-fixture
cp "$repo_root/fleet/fresnel.yaml" /tmp/ka-promote-parity-fixture/fresnel.yaml
run_test "fresnel parity (= Phase-3 ground truth b2sum)" \
/tmp/ka-promote-parity-fixture/fresnel.yaml 0 check_fresnel_parity
rm -rf /tmp/ka-promote-parity-fixture
run_test "series-dir resolver (bes2600/staging-prep-series-danctnix → 7 patches)" \
"$fixtures/series-dir.yaml" 0 check_series_dir
run_test "bad-include rejection (exit 4)" \
"$fixtures/bad-include.yaml" 4
run_test "missing-patch rejection (exit 2)" \
"$fixtures/missing-patch.yaml" 2
run_test "duplicate-include rejection (exit 4)" \
"$fixtures/duplicate-include.yaml" 4
echo
echo "===================="
printf '%s\n' "${results[@]}"
echo "===================="
echo "passed: $pass"
echo "failed: $fail"
[ "$fail" -eq 0 ] || exit 1