Release acceptance gate: release.sh refuses to sign without a matching artifact

scripts/acceptance-gate.py recomputes every catalog test's domain fingerprint
from /etc/forgefirm-manifest.json inside the release rootfs and requires the
committed releases/v<version>/acceptance.json to carry a matching PASS
(inherited results not core and newer than the invalidate epoch; the artifact
self-hashed; the catalog identical to the tree). release.sh runs it after the
build and stages the artifact as a release asset; FORGEFIRM_ACCEPTANCE_SKIP=1
bypasses loudly. scripts/manifest-from-tree.py builds the same manifest from
the recipe pins with git for CI and the workstation; forgetest-ci.yml runs the
unit tests and enforces the coverage lint (every manifest path covered by some
test). docs/ACCEPTANCE.md is the contract; the coverage currency rule and the
status live in BRINGUP.
This commit is contained in:
ScottW514
2026-08-15 15:57:12 -04:00
parent c0f53a865f
commit 1179d5e7c1
10 changed files with 644 additions and 9 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# (C) Copyright 2020-2026
# Scott Wiederhold, s.e.wiederhold@gmail.com
# https://community.openglow.org
# SPDX-License-Identifier: MIT
#
# Release acceptance gate: does the committed acceptance artifact authorize
# THIS release build?
#
# acceptance-gate.py <acceptance.json> <release-manifest.json> [--machine glowforge]
#
# The artifact is what forgetest exported on the bench (releases/v<version>/
# acceptance.json); the release manifest is /etc/forgefirm-manifest.json read
# out of the release rootfs release.sh just built. For every catalog test the
# gate recomputes the domain fingerprint from the release manifest with the
# catalog in this source tree - the same code the bench ran - and requires the
# recorded PASS to match. Inherited results must not be core tests and must
# be newer than the last invalidate-all. Exit 0 = authorized, 1 = refused,
# 2 = usage/load error. release.sh dies on anything but 0.
import argparse
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "forgetest"))
from forgetest import artifact, catalog, manifest # noqa: E402
def main(argv=None):
ap = argparse.ArgumentParser(description="ForgeFIRM release acceptance gate")
ap.add_argument("artifact")
ap.add_argument("release_manifest")
ap.add_argument("--machine", default="glowforge")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args(argv)
try:
with open(args.artifact, "r", encoding="utf-8") as f:
art = json.load(f)
rel = manifest.Manifest.load(args.release_manifest)
except (OSError, ValueError) as e:
print("acceptance-gate: cannot load inputs: %s" % e, file=sys.stderr)
return 2
registry = catalog.load_suite()
tests = catalog.all_tests(registry)
ok, rows, problems = artifact.verify(art, rel, tests, catalog.catalog_hash(registry),
expect_machine=args.machine)
if not args.quiet:
print("acceptance artifact: image %s, campaign %s, exported %s, authorized=%s"
% (art.get("image", {}).get("version"), (art.get("campaign") or {}).get("id"),
art.get("exported_at"), art.get("authorized")))
print("release manifest: %s identity %s" % (rel.version, rel.identity_sha()[:16]))
if art.get("identity_sha") == rel.identity_sha():
print("identity: the release build's inputs are identical to the bench image's")
else:
print("identity: the release build differs from the bench image (per-test check decides)")
for r in rows:
flag = "ok " if r["ok"] else "FAIL"
print(" %s %-34s %s%s%s" % (flag, r["id"], "core " if r["always"] else "",
"inherited " if r["inherited"] else "",
("; ".join(r["why"]) if r["why"] else r.get("ts", ""))))
for p in problems:
print("PROBLEM: %s" % p)
print("acceptance gate: %s" % ("AUTHORIZED" if ok else "REFUSED"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+188
View File
@@ -0,0 +1,188 @@
#!/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).
# 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.
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")}
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 parse_recipe(path):
text = open(path, encoding="utf-8").read()
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(".md")]
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())
+37 -2
View File
@@ -8,6 +8,8 @@
#
# release.sh <version> [--publish] full release: gates, build, pack,
# sign, checksums, stage, publish cmd
# (the acceptance gate reads
# releases/v<version>/acceptance.json)
# release.sh --dev build + pack a dev-signed .fw for
# the GUI upload path; no staging
#
@@ -21,6 +23,8 @@
# FORGEFIRM_DEV_KEY private key for --dev mode (REQUIRED for --dev)
# RELEASE_STAGING_DIR where release assets are staged
# (default: <repo>/release-staging)
# FORGEFIRM_ACCEPTANCE_SKIP set to 1 to bypass the acceptance gate
# deliberately (never the default; docs/ACCEPTANCE.md)
#
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-image.bb
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
@@ -135,6 +139,27 @@ STAMP=$(debugfs -R "cat /etc/forgefirm-version" "$EXT4" 2>/dev/null)
[ "$STAMP" = "v$VERSION" ] \
|| die "rootfs stamp is '$STAMP', expected 'v$VERSION'"
# Acceptance gate: the committed acceptance artifact must authorize THIS
# build. scripts/acceptance-gate.py recomputes every catalog test's domain
# fingerprint from the manifest inside the release rootfs and requires the
# recorded PASS to match (docs/ACCEPTANCE.md). A release is never signed
# without it; FORGEFIRM_ACCEPTANCE_SKIP=1 bypasses deliberately and loudly.
ART="$REPO/releases/v$VERSION/acceptance.json"
if [ -n "${FORGEFIRM_ACCEPTANCE_SKIP:-}" ]; then
warn "acceptance gate SKIPPED by FORGEFIRM_ACCEPTANCE_SKIP - this release carries no acceptance proof"
else
[ -f "$ART" ] \
|| die "no acceptance artifact at releases/v$VERSION/acceptance.json - run the campaign on the bench, export, commit"
REL_MANIFEST=$(mktemp)
debugfs -R "cat /etc/forgefirm-manifest.json" "$EXT4" > "$REL_MANIFEST" 2>/dev/null
[ -s "$REL_MANIFEST" ] \
|| { rm -f "$REL_MANIFEST"; die "release rootfs carries no /etc/forgefirm-manifest.json"; }
python3 "$REPO/scripts/acceptance-gate.py" "$ART" "$REL_MANIFEST" --machine glowforge \
|| { rm -f "$REL_MANIFEST"; die "acceptance gate refused this build (see the table above)"; }
rm -f "$REL_MANIFEST"
echo "acceptance gate OK ($ART)"
fi
# Back-door gate: the release image must not ship a passwordless root. A
# debug-tweaks image sets root's password field empty (root::...); a
# hardened image leaves it locked (root:*: / root:!:) or hashed. Read the
@@ -177,6 +202,16 @@ fi
echo "== stage assets =="
cp -L "$DEPLOY/forgefirm-image-glowforge.rootfs.wic.gz" "$STAGE/forgefirm-image-glowforge.rootfs.wic.gz"
# The acceptance artifact travels with the release (docs/ACCEPTANCE.md).
ASSETS="forgefirm.fw sha256sums.txt forgefirm-image-glowforge.rootfs.wic.gz"
if [ -f "$ART" ]; then
cp "$ART" "$STAGE/acceptance.json"
ASSETS="$ASSETS acceptance.json"
if [ -f "${ART%.json}.md" ]; then
cp "${ART%.json}.md" "$STAGE/acceptance.md"
ASSETS="$ASSETS acceptance.md"
fi
fi
( cd "$STAGE" && sha256sum forgefirm.fw forgefirm-image-glowforge.rootfs.wic.gz > sha256sums.txt )
ls -la "$STAGE"
@@ -193,14 +228,14 @@ Publish (from a directory with an authenticated gh):
cd "$STAGE"
gh release create "v$VERSION" --repo ScottW514/forgefirm \\
--title "ForgeFIRM v$VERSION" --generate-notes \\
forgefirm.fw sha256sums.txt forgefirm-image-glowforge.rootfs.wic.gz
$ASSETS
EOF
if [ "$PUBLISH" = "1" ]; then
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
( cd "$STAGE" && gh release create "v$VERSION" --repo ScottW514/forgefirm \
--title "ForgeFIRM v$VERSION" --generate-notes \
forgefirm.fw sha256sums.txt forgefirm-image-glowforge.rootfs.wic.gz ) \
$ASSETS ) \
|| die "gh release create failed"
echo "== published v$VERSION =="
fi