mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
A component pin bump counted as a platform change: the layer content hash in the platform identity covered the recipe carrying the SRCREV, the platform is folded into every acceptance fingerprint, so every image that carried any component update invalidated the whole catalog (dev image 20260816191951: every test domain-changed after a one-line forgectrl bump; the two manifests differ only in platform.layers.meta-forgefirm). The component entry already identifies the pinned source file by file; the pin double-counted it. Component pins now live in <recipe>-pin.inc (SRCREV and the PV that moves with it, nothing else) - forgectrl, grblhal-glowforge and forgefirm-app here, the BSP components in meta-openglow - and forgefirm-image-manifest.bbclass leaves *-pin.inc out of the layer content (FORGEFIRM_MANIFEST_PIN_SUFFIX). Recipe bodies, patches, config fragments, init scripts and third-party pins with no manifest entry stay layer content; a pin written into a recipe body still hashes (the safe direction). manifest-from-tree.py mirrors the rule and reads pins through the recipe's requires; test_tree_manifest.py proves both (pin bump: hash unchanged; recipe body or inline pin: changed). Bitbake resolves the same SRCREV/PV for every pinned recipe. Docs: ACCEPTANCE.md (what layer content is), kas/README.md (the pin files in the push order), BRINGUP.md (the finding and the bench consequence: the first image built with the pin files is itself a platform change, so its campaign is a full one; pin bumps inherit after it). No catalog consequence: nothing in the image's behavior changes; the change is to the acceptance identity computation, proven by the unit tests and the CI lint on the tree manifest.
215 lines
9.6 KiB
Python
215 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
# (C) Copyright 2020-2026
|
|
# Scott Wiederhold, s.e.wiederhold@gmail.com
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
#
|
|
# Build a ForgeFIRM image manifest from the source tree - the same file lists
|
|
# forgefirm-image-manifest.bbclass puts in the image, computed from the recipe
|
|
# pins with git instead of a Yocto build. For the coverage lint in CI and for
|
|
# checking a coverage map on a workstation; NOT a substitute for the image's
|
|
# manifest in the release gate (the platform section carries placeholders
|
|
# where only a build knows the answer: kernel config hash, modules dir, DTB).
|
|
#
|
|
# manifest-from-tree.py [--meta-openglow PATH] [--out manifest.json]
|
|
# [--cache DIR] [--kernel-srcrev REV]
|
|
#
|
|
# Component revisions come from the recipes in meta-forgefirm and the sibling
|
|
# meta-openglow checkout (default ../meta-openglow relative to this repo); a
|
|
# recipe's `require`d files in its own directory are read too, which is
|
|
# where the pins live (<recipe>-pin.inc). Each pinned commit is fetched
|
|
# shallowly into --cache (default .manifest-cache/, gitignored) and listed
|
|
# with `git ls-tree`; a submodule gitlink is followed through .gitmodules.
|
|
# The layer content hash mirrors forgefirm-image-manifest.bbclass: every
|
|
# file under the layer except *.md and *-pin.inc.
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(REPO, "forgetest"))
|
|
from forgetest import manifest as manifest_mod # noqa: E402
|
|
|
|
RECIPES = [
|
|
# (component, recipe path relative to the repo or meta-openglow, layer)
|
|
("forgectrl", "meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb", "forgefirm"),
|
|
("grblhal-glowforge", "meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb", "forgefirm"),
|
|
("forgefirm-app", "meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app.inc", "forgefirm"),
|
|
("kernel-module-glowforge", "meta-glowforge-bsp/recipes-kernel/kernel-modules/kernel-module-glowforge.bb", "meta-openglow"),
|
|
("python3-gfhardware", "meta-glowforge-bsp/recipes-devtools/python/python3-gfhardware.bb", "meta-openglow"),
|
|
("python3-gfutilities", "meta-openglow-core/recipes-devtools/python/python3-gfutilities_git.bb", "meta-openglow"),
|
|
]
|
|
CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
|
|
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
|
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
|
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
|
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files.
|
|
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc")
|
|
|
|
|
|
def git(args, cwd=None, input=None):
|
|
return subprocess.run(["git"] + args, cwd=cwd, input=input, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, check=True).stdout
|
|
|
|
|
|
def recipe_text(path, seen=None):
|
|
"""The recipe's text with its `require`/`include`d files from the same
|
|
directory appended (bitbake resolves a relative name against the
|
|
including file's directory first). Only local files are followed;
|
|
anything else is left to BBPATH and skipped here."""
|
|
seen = seen if seen is not None else set()
|
|
path = os.path.abspath(path)
|
|
if path in seen:
|
|
return ""
|
|
seen.add(path)
|
|
with open(path, encoding="utf-8") as f:
|
|
text = f.read()
|
|
for m in re.finditer(r'^\s*(?:require|include)\s+(\S+)\s*$', text, re.M):
|
|
cand = os.path.join(os.path.dirname(path), m.group(1))
|
|
if os.path.isfile(cand):
|
|
text += "\n" + recipe_text(cand, seen)
|
|
return text
|
|
|
|
|
|
def parse_recipe(path):
|
|
text = recipe_text(path)
|
|
uri = re.search(r'^SRC_URI\s*\+?=\s*"([^"]+)"', text, re.M)
|
|
rev = re.search(r'^SRCREV\s*\??=\s*"([0-9a-fA-F]+)"', text, re.M)
|
|
if not uri or not rev:
|
|
raise SystemExit("cannot find SRC_URI/SRCREV in %s" % path)
|
|
first = uri.group(1).split()[0]
|
|
url = first.split(";")[0]
|
|
params = dict(p.split("=", 1) for p in first.split(";")[1:] if "=" in p)
|
|
proto = params.get("protocol", "https")
|
|
if url.startswith("git://") or url.startswith("gitsm://"):
|
|
url = proto + "://" + url.split("://", 1)[1]
|
|
return url, rev.group(1)
|
|
|
|
|
|
def fetch(url, rev, cache):
|
|
"""A bare cache repo containing rev (fetched shallowly)."""
|
|
name = hashlib.sha1(url.encode()).hexdigest()[:16]
|
|
repo = os.path.join(cache, name)
|
|
if not os.path.isdir(repo):
|
|
os.makedirs(repo)
|
|
git(["init", "-q", "--bare"], cwd=repo)
|
|
try:
|
|
git(["cat-file", "-e", rev + "^{commit}"], cwd=repo)
|
|
except subprocess.CalledProcessError:
|
|
git(["fetch", "-q", "--depth", "1", url, rev], cwd=repo)
|
|
return repo
|
|
|
|
|
|
def ls_tree(repo, rev, url, cache, prefix, files):
|
|
out = git(["ls-tree", "-r", "--full-tree", rev], cwd=repo).decode()
|
|
modules = None
|
|
for line in out.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
meta, path = line.split("\t", 1)
|
|
typ, obj = meta.split()[1], meta.split()[2]
|
|
files.append([prefix + path, obj])
|
|
if typ == "commit":
|
|
if modules is None:
|
|
modules = {}
|
|
try:
|
|
gm = git(["show", "%s:.gitmodules" % rev], cwd=repo).decode()
|
|
except subprocess.CalledProcessError:
|
|
gm = ""
|
|
cur = None
|
|
for l in gm.splitlines():
|
|
l = l.strip()
|
|
m = re.match(r'^path\s*=\s*(.+)$', l)
|
|
if m:
|
|
cur = m.group(1).strip()
|
|
m = re.match(r'^url\s*=\s*(.+)$', l)
|
|
if m and cur:
|
|
modules[cur] = m.group(1).strip()
|
|
sub_url = modules.get(path)
|
|
if sub_url:
|
|
if sub_url.startswith("../") or sub_url.startswith("./"):
|
|
base = url.rsplit("/", 1)[0]
|
|
sub_url = base + "/" + sub_url.lstrip("./")
|
|
sub_repo = fetch(sub_url, obj, cache)
|
|
ls_tree(sub_repo, obj, sub_url, cache, prefix + path + "/", files)
|
|
|
|
|
|
def layer_content(path):
|
|
out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], cwd=path)
|
|
paths = sorted(set(p.decode("utf-8", "replace") for p in out.split(b"\0") if p))
|
|
paths = [p for p in paths
|
|
if os.path.isfile(os.path.join(path, p)) and not p.endswith(LAYER_SKIP_SUFFIXES)]
|
|
if not paths:
|
|
return None
|
|
# hash-object --stdin-paths resolves against the repository top level
|
|
prefix = git(["rev-parse", "--show-prefix"], cwd=path).decode().strip()
|
|
ids = git(["hash-object", "--stdin-paths"], cwd=path,
|
|
input=("\n".join(prefix + p for p in paths) + "\n").encode()).decode().split()
|
|
h = hashlib.sha256()
|
|
for p, i in zip(paths, ids):
|
|
h.update(p.encode("utf-8") + b"\0" + i.encode("ascii") + b"\n")
|
|
return h.hexdigest()
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--meta-openglow", default=os.path.join(os.path.dirname(REPO), "meta-openglow"))
|
|
ap.add_argument("--out", default="-")
|
|
ap.add_argument("--cache", default=os.path.join(REPO, ".manifest-cache"))
|
|
ap.add_argument("--kernel-srcrev", default=None,
|
|
help="linux-fslc SRCREV (default: read from layers/meta-freescale if present)")
|
|
args = ap.parse_args(argv)
|
|
os.makedirs(args.cache, exist_ok=True)
|
|
|
|
components = {}
|
|
for name, rel, layer in RECIPES:
|
|
base = REPO if layer == "forgefirm" else args.meta_openglow
|
|
path = os.path.join(base, rel)
|
|
url, rev = parse_recipe(path)
|
|
repo = fetch(url, rev, args.cache)
|
|
files = []
|
|
ls_tree(repo, rev, url, args.cache, "", files)
|
|
files.sort()
|
|
components[name] = {"srcrev": rev, "source": url, "files": files, "recipes": [os.path.basename(rel)]}
|
|
print("%s: %s (%d files)" % (name, rev[:12], len(files)), file=sys.stderr)
|
|
|
|
ksrc = args.kernel_srcrev
|
|
if not ksrc:
|
|
for cand in ("layers/meta-freescale/recipes-kernel/linux/linux-fslc_6.12.bb",):
|
|
p = os.path.join(REPO, cand)
|
|
if os.path.exists(p):
|
|
m = re.search(r'^SRCREV\s*=\s*"([0-9a-f]+)"', open(p, encoding="utf-8").read(), re.M)
|
|
if m:
|
|
ksrc = m.group(1)
|
|
components["linux-fslc"] = {"srcrev": ksrc, "source": "git://github.com/Freescale/linux-fslc.git",
|
|
"config_sha256": None, "recipes": ["linux-fslc"],
|
|
"files": [["@config", "unknown-without-a-build"], ["@srcrev", ksrc or "unknown"]]}
|
|
|
|
layers = {}
|
|
for lname, (repo_key, sub) in CONTENT_LAYERS.items():
|
|
base = REPO if repo_key == "forgefirm" else args.meta_openglow
|
|
lpath = os.path.join(base, sub)
|
|
if os.path.isdir(lpath):
|
|
layers[lname] = {"content_sha256": layer_content(lpath)}
|
|
platform = {"machine": "glowforge", "layers": layers, "kernel_modules": [], "dtb": {}}
|
|
canonical = manifest_mod.canonical({"components": components, "platform": platform})
|
|
out = {"format": 1, "image": {"name": "tree", "version": "tree (no build)"},
|
|
"content_sha256": hashlib.sha256(canonical.encode()).hexdigest(),
|
|
"components": components, "platform": platform}
|
|
text = json.dumps(out, sort_keys=True, indent=1) + "\n"
|
|
if args.out == "-":
|
|
sys.stdout.write(text)
|
|
else:
|
|
with open(args.out, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
print("wrote %s" % args.out, file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|