Files
forgefirm/.github/workflows/forgetest-ci.yml
T
ScottW514 41ca7fab82 forgetest-ci: the coverage lint step fails when the lint fails, not only when tee does
The "Coverage lint (enforced)" step pipes the lint into tee for the
step summary. A step with no shell: runs under bash -e with no
pipefail, so the status of the step was tee's. An uncovered path, an
empty covers entry, a host-tested row problem (the lint exits 1) and a
manifest the lint cannot load (exit 2) each passed the job: the first
three after they printed the report, the last after one error line on
stderr and an empty step summary. The header of the workflow and the
Testing page say that an uncovered path fails the job, and this step
is the only place that runs the lint with --enforce.

The run block now starts with `set -o pipefail`, so the status of the
step is the lint's. The tee to the step summary stays. A comment above
the block says why.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e from
forgefirm/forgetest of a copy of the tree on Ubuntu 24.04 with python
3.12, GITHUB_STEP_SUMMARY a file. The tree manifest from the recipe
pins: both bodies exit 0 and write the summary (0 uncovered paths, 0
host-tested row problems, 43 tests). The same manifest with one
uncovered forgectrl path planted: the lint reports 1 uncovered path,
the former body exits 0, this one exits 1, and both write the summary.
The manifest missing: the former body exits 0, this one exits 2.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:27:06 -04:00

178 lines
7.8 KiB
YAML

# forgetest: the release acceptance tool's host-side proof.
#
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
# release gate's decision, including the negative fixtures), runner +
# HTTP API end to end with a fake catalog and a fake bench tool, the
# suites replayed on the machine's own log lines, and the check that
# every log phrase the cloud suite greps for is one the pinned cloud
# app can log (it reads the app sources from the manifest cache)
# - the source bundle of a release: which recipe of the image needs
# source, which archive answers for it, and the refusal that stops a
# release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision
# - the extension key: the public key the image installs for the official
# tier is byte-identical to the one the extension catalog publishes
# (keys/forgefirm-ext.pub on its main branch)
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
# src/extpkg.h is the catalog's latest-release download of the file its
# tools/publish.py passes to gh release create; read, never requested
# (every request of it is counted as a download)
# - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs or by a
# host-tested row naming the host test that proves it (the tree manifest
# is generated from the pins with git - no Yocto build); enforced: an
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
name: forgetest-ci
on:
push:
branches: [master]
paths:
- 'forgetest/**'
- 'scripts/acceptance-gate.py'
- 'scripts/manifest-from-tree.py'
- 'scripts/source-bundle.py'
- 'kas/source-bundle.yml'
- 'meta-forgefirm/recipes-forgefirm/**'
- '.github/workflows/forgetest-ci.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout forgefirm
uses: actions/checkout@v4
with:
path: forgefirm
- name: Checkout meta-openglow (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/meta-openglow
ref: scarthgap
path: meta-openglow
- name: Checkout forgefirm-extensions-catalog (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/forgefirm-extensions-catalog
ref: main
path: forgefirm-extensions-catalog
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: The image's extension key is the catalog's published key
run: |
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
for f in "$image" "$published"; do
test -f "$f" || { echo "missing: $f"; exit 1; }
done
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
run: |
python3 - <<'EOF'
import ast, glob, os, re, subprocess, sys
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
def fail(why):
print(why)
sys.exit(1)
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
if not m:
fail("forgectrl-pin.inc names no SRCREV")
rev = m.group(1)
header = None
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
if p.returncode == 0:
header = p.stdout
break
if header is None:
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
if not m:
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
url = m.group(1)
# the file the catalog's publish tool passes to gh release create
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
def file_name(v):
if isinstance(v, ast.Name):
v = assigned.get(v.id)
if isinstance(v, ast.Call) and v.args:
v = v.args[-1]
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
if len(names) != 1 or not names[0]:
fail("tools/publish.py passes no one file to gh release create: %s" % names)
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
if url != PREFIX + names[0]:
fail("the address is not %s" % (PREFIX + names[0]))
EOF
- name: Unit tests
working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch
# checksum would otherwise move without a source change)
run: python -B -m unittest discover -s tests -v
- name: No undefined names (the live drills never execute on the host)
working-directory: forgefirm/forgetest
# pyflakes exits 1 for any message, unused imports included, so the
# grep is the gate; any other status (a signal, a usage error) or a
# capture that cannot be written fails the step.
run: |
python -m pip install pyflakes
python -B -m pyflakes --version
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1
if grep -E "undefined name|Traceback" pyflakes.txt; then exit 1; fi
- name: Shared UI files match forgectrl at its pinned revision
working-directory: forgefirm
run: python scripts/check-ui-vendor.py
- name: Coverage lint (enforced)
working-directory: forgefirm/forgetest
# The step runs under bash -e with no pipefail of its own: set here,
# the status of the step is the lint's, not tee's.
run: |
set -o pipefail
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
- name: Gate self-check on the tree manifest
working-directory: forgefirm
run: |
# The gate must load the catalog and refuse a missing/unauthorized
# artifact cleanly (exit 1/2, never a traceback).
set +e
echo '{}' > empty.json
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
rc=$?
set -e
test "$rc" -eq 1