Files
Dario Gabriel LipicarandClaude Opus 5 f0e1c12d03 Split validation into one job per spec per platform, and poll for nodeStarted
Two changes, both belonging on main rather than a release branch.

One job per (spec, platform) instead of one job per platform running all five
specs in sequence — 15 jobs. The results model was already keyed on
(spec, platform), so this makes the jobs match it and pays off three ways: a
failure names itself in the job list instead of hiding inside a 900-line log;
basecamp-ui, which builds Qt, stops gating the four quick specs; and one
flaky spec can be re-run without repeating the other fourteen. Each job also
produces its own report, so every cell of the validation table links to the
report for exactly that cell rather than to a combined per-platform one.

select-specs.py grew --only so each job asks the skip question about its own
spec. The skip path is unchanged and still tested: against a lock pinning
basecamp 0.2.2-RC1 it reports any=false with the reason, and the job records
"skipped" without failing. Fifteen HTML reports would bury the two assets
that matter on a release, so they travel as one reports.tar.gz while the
per-cell links point at the browsable copies on gh-pages.

The delivery spec's nodeStarted assertion now polls instead of sleeping 3s.
`start` returns when the node accepts the call; the event is emitted later by
the completion callback, and 3s was enough on some runs and not others —
run 30493468508 failed it on all three platforms with an empty latch, having
passed on all three the run before. It now retries for up to 60s and still
fails honestly if the event never arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:24:26 -03:00

157 lines
6.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Decide which doc-tests can run on a platform, and why the rest cannot.
A pinned release may simply not publish an artifact for every platform — a
release with no linux-arm64 AppImage leaves nothing to smoke-test on an arm64
Linux runner. That is not a failure of the release set, so the workflow skips
the affected spec and flags it in the release description instead of going red.
This decides that up front, from release-set.lock.json, rather than letting a
spec discover it half-way through and fail.
Usage:
python3 scripts/select-specs.py release-set.lock.json --platform linux-arm64
python3 scripts/select-specs.py release-set.lock.json --platform linux-arm64 --format github
"""
import argparse
import json
import sys
TOOLS = ["logos-logoscore-cli", "logos-package-downloader", "logos-package-manager"]
PM_TOOLS = ["logos-package-downloader", "logos-package-manager"]
BUILDER = ["logos-module-builder"]
# What each spec needs on the runner's own platform.
# apps — released binaries that must exist for this platform
# packages — catalog packages that must publish this platform's variant
# ("*" means every module and UI app in the set)
# devUtils — dev utils the spec exercises. Not gating (they are consumed as
# flake refs and publish no per-platform binaries), but recorded
# so each artifact can point at the doc-tests that covered it.
SPECS = {
# Each headless spec also loads `openmetrics` and scrapes /metrics, so that
# package must publish this platform's variant too.
"headless-storage-module": {"apps": TOOLS,
"packages": ["storage_module", "openmetrics"],
"devUtils": BUILDER},
"headless-delivery-module": {"apps": TOOLS,
"packages": ["delivery_module", "openmetrics"],
"devUtils": BUILDER},
"headless-blockchain-module": {"apps": TOOLS,
"packages": ["blockchain_module", "openmetrics"],
"devUtils": BUILDER},
# Launches the shipped Basecamp artifact, so that artifact must exist.
"basecamp-appimage-smoke": {"apps": PM_TOOLS + ["logos-basecamp"],
"packages": ["*"], "devUtils": []},
# Builds Basecamp from the pinned commit, so it needs no Basecamp asset.
"basecamp-ui": {"apps": PM_TOOLS, "packages": ["*"], "devUtils": []},
}
def specs_covering(name, lock):
"""Which doc-tests exercise this component."""
every_package = [i["name"] for i in lock.get("modules", []) + lock.get("uiApps", [])]
covering = []
for spec, needs in sorted(SPECS.items()):
packages = every_package if needs["packages"] == ["*"] else needs["packages"]
if name in needs["apps"] or name in packages or name in needs.get("devUtils", []):
covering.append(spec)
return covering
def index_by_name(lock):
entries = {}
for group in ("apps", "devUtils", "modules", "uiApps"):
for item in lock.get(group, []):
entries[item["name"]] = item
return entries
def missing_for(spec, lock, entries, platform):
"""Reasons this spec cannot run on this platform. Empty list = it can."""
reasons = []
needs = SPECS[spec]
for name in needs["apps"]:
item = entries.get(name)
if item is None:
reasons.append(f"{name} is not in the release set")
elif platform not in (item.get("platforms") or []):
# `apps` entries are pinned by tag; never fall back to a catalog
# entry's source-repo tag here.
label = item.get("tag") or item.get("version")
reasons.append(f"no {platform} artifact published for {name}@{label}")
packages = needs["packages"]
if packages == ["*"]:
packages = [i["name"] for i in lock.get("modules", []) + lock.get("uiApps", [])]
for name in packages:
item = entries.get(name)
if item is None:
reasons.append(f"{name} is not in the release set")
elif platform not in (item.get("platforms") or []):
reasons.append(
f"no {platform} variant published for {name}@{item.get('version')}"
)
return reasons
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("lock")
parser.add_argument("--platform", required=True)
parser.add_argument("--only", default=None,
help="consider just this one spec (the workflow runs one "
"spec per job, so each job asks only about its own)")
parser.add_argument("--format", choices=("text", "json", "github"), default="text")
args = parser.parse_args()
lock = json.load(open(args.lock, encoding="utf-8"))
entries = index_by_name(lock)
if args.only and args.only not in SPECS:
sys.exit(f"unknown spec {args.only!r}; known: {', '.join(sorted(SPECS))}")
considered = [args.only] if args.only else sorted(SPECS)
runnable, skipped = [], []
for spec in considered:
reasons = missing_for(spec, lock, entries, args.platform)
if reasons:
skipped.append({
"spec": spec,
"platform": args.platform,
"status": "skipped",
# One spec can be blocked by several missing artifacts; the first
# is the actionable one, the rest are listed for completeness.
"reason": reasons[0],
"allReasons": reasons,
})
else:
runnable.append(spec)
if args.format == "json":
json.dump({"run": runnable, "skipped": skipped}, sys.stdout, indent=2)
sys.stdout.write("\n")
elif args.format == "github":
# Consumed by the workflow: a space-separated spec list plus the skip
# records, written to $GITHUB_OUTPUT.
specs = " ".join(f"doctests/{name}.test.yaml" for name in runnable)
print(f"specs={specs}")
print(f"skipped={json.dumps(skipped)}")
print(f"any={'true' if runnable else 'false'}")
else:
print(f"platform: {args.platform}")
for name in runnable:
print(f" RUN {name}")
for entry in skipped:
print(f" SKIP {entry['spec']}: {entry['reason']}")
return 0
if __name__ == "__main__":
sys.exit(main())