diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0214b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# ka-promote / ka-build output +/build/ + +# transient +*.pyc +__pycache__/ +.DS_Store diff --git a/README.md b/README.md index 13a981f..3d89f7d 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,12 @@ persistent, audit trail per item). ## Verbs (explicit, parameterized, audit-issue auto-filed) ``` -ka-promote --to +ka-promote # resolve fleet/.yaml → cumulative.patch + manifest.lock [bin/ka-promote — implemented Phase 6, issue #22] +ka-import --to # patches from campaign → scope-tagged tree (today: manual git workflow) ka-close --status success ka-abandon --keep-as-archive | --purge-from-fleet -ka-install +ka-build # render PKGBUILD template with cumulative b2sum, run makepkg [next verb, issue TBD] +ka-install # scp + pacman -U + extlinux/mkinitcpio + heartbeat [last verb, issue TBD] ka-keep [--for ] ka-pause-prune / ka-resume-prune ka-restore-archive @@ -120,6 +122,13 @@ ka-migrate-tree --from

--to

ka-wake-data # wraps wake-host data through His ``` +Note: the original spec had `ka-promote --to ` +("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 [--for ]`. ## 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 --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 ` per pkg. Script signs with key `92D5E96D8F63C75E4116AA1FF5C8C4603D0D250C`, runs repo-add, rsyncs to nc. | -| `ka-install fresnel` (consent-via-action) | `sudo pacman -U /tmp/` 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 --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 ` 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/` 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 diff --git a/bin/ka-promote b/bin/ka-promote new file mode 100755 index 0000000..70fa1fb --- /dev/null +++ b/bin/ka-promote @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""ka-promote — resolve fleet/.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}///. + +Usage: + ka-promote + ka-promote --output-dir + ka-promote --validate-against + 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:-/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/.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()) diff --git a/fleet/fresnel.yaml b/fleet/fresnel.yaml index c59d812..3bb7bd3 100644 --- a/fleet/fresnel.yaml +++ b/fleet/fresnel.yaml @@ -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//.../.patch in marfrit/kernel-agent. diff --git a/tests/ka-promote/fixtures/bad-include.yaml b/tests/ka-promote/fixtures/bad-include.yaml new file mode 100644 index 0000000..cd1763a --- /dev/null +++ b/tests/ka-promote/fixtures/bad-include.yaml @@ -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 diff --git a/tests/ka-promote/fixtures/duplicate-include.yaml b/tests/ka-promote/fixtures/duplicate-include.yaml new file mode 100644 index 0000000..550eeda --- /dev/null +++ b/tests/ka-promote/fixtures/duplicate-include.yaml @@ -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 diff --git a/tests/ka-promote/fixtures/missing-patch.yaml b/tests/ka-promote/fixtures/missing-patch.yaml new file mode 100644 index 0000000..abacf1b --- /dev/null +++ b/tests/ka-promote/fixtures/missing-patch.yaml @@ -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 diff --git a/tests/ka-promote/fixtures/series-dir.yaml b/tests/ka-promote/fixtures/series-dir.yaml new file mode 100644 index 0000000..9705313 --- /dev/null +++ b/tests/ka-promote/fixtures/series-dir.yaml @@ -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 diff --git a/tests/ka-promote/run-tests.sh b/tests/ka-promote/run-tests.sh new file mode 100755 index 0000000..d033168 --- /dev/null +++ b/tests/ka-promote/run-tests.sh @@ -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/.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