diff --git a/.gitlab/tests/cram/README.md b/.gitlab/tests/cram/README.md index eb67f7a63e..7288422cad 100644 --- a/.gitlab/tests/cram/README.md +++ b/.gitlab/tests/cram/README.md @@ -76,6 +76,74 @@ uses those paths to distinguish full, prplMesh, and prplOS coverage. `cram_component_bucketing.py` uses underscores because it is an importable module. Executable scripts follow the repository's hyphenated CLI convention. +## Diff-based selection + +Merge request pipelines map changed paths to component lists for six hardware +boards. Board-specific paths select their owning board. Cross-cutting paths +select only the two priority boards, Freedom and OSPv2; the other four boards +remain available for manual runs and weekly full coverage. + +| Changed path | Board scope | Selection | +|---|---|---| +| `profiles/mvebu.yml`, `ipq807x.yml`, `mtk_filogic.yml`, or `airoha_an7581.yml` | Omnia, Haze, Mozart, or Valyrian respectively | `full` | +| `profiles/qca_ipq95xx.yml` | Freedom | `full` | +| `profiles/mxl_x86_osp_tb341_v2.yml` or `mxl_wlan_hostap_ng_wav700.yml` | OSPv2 | `full` | +| `profiles/secure_boot_emmc.yml` | Freedom + OSPv2 | `full` | +| `profiles/feed_amx.yml` | Freedom + OSPv2 | `full` | +| `profiles/feed_prplmesh.yml` | Freedom + OSPv2 | `prplmesh` | +| `profiles/feed_prplos.yml` | Freedom + OSPv2 | `prplos` | +| `profiles/lcm.yml` | Freedom + OSPv2 | `lcm` | +| `profiles/prpl_core.yml`, `prpl.yml`, or `security.yml` | Freedom + OSPv2 | `full` | +| `profiles/mgmt.yml`, `cellular.yml`, or `thread.yml` | Freedom + OSPv2 | `prplos` | +| `.gitlab/tests/cram//**` | owning board | touched test's bucket | +| `.gitlab/tests/cram/generic/**` | Freedom + OSPv2 | touched test's bucket | +| `.gitlab/tests/cram/post/**` or a sanity-listed test | Freedom + OSPv2 | `prplos` | +| `.gitlab/testbed/.yml` | owning board | `prplos` | +| `.gitlab/testbed.yml` | Freedom + OSPv2 | `prplos` | +| `package/**`, `target/**`, `toolchain/**`, `include/**`, `config/**`, `tools/**`, `feeds.conf.default`, `Makefile`, or `rules.mk` | Freedom + OSPv2 | `full` | + +A `by-test` row asks the manifest which bucket owns each touched test. Multiple +matches form an ordered component union; a `full` match or all three split +buckets collapse to `full`. Unmatched paths, including documentation and other +CI files, do not arm a board. + +Profiles without a cram build consumer are explicit no-ops: +`debug.yml`, `extender_full.yml`, `extender_minimal.yml`, `ipq40xx.yml`, +`mxl_x86.yml`, `mxl_x86_sec.yml`, `mxl_x86_osp_tb341.yml`, `mxl_wlan.yml`, +`mxl_wlan_hostap_ng.yml`, and `mxl_wlan_hostap_ng_gw.yml`. Changes to +`components.yml` and its scripts are also no-ops because lint validates them +and the author deliberately chooses any DUT run. + +### Labels and precedence + +The scoped labels `cram::full`, `cram::prplmesh`, `cram::lcm`, and +`cram::prplos` override the computed selection. `cram::skip` suppresses +automatic selection for the merge request. The labels share the `cram::` +scope, so GitLab keeps at most one on an MR. + +Selection precedence is: + +1. A set `CRAM_COMPONENTS` pipeline variable stands the `[auto]` jobs down and + controls the existing component jobs. `CRAM_COMPONENTS=none` matches no + component. +2. `cram::skip` stands `[auto]` jobs down; another `cram::*` label forces that + component. +3. Otherwise, the merge request diff is resolved through the matrix above. +4. Any `full` result, or the union of all split buckets, collapses to `full`. + +Labels are checked both when GitLab creates the pipeline and again when the +job starts. A label added after pipeline creation but before the build is +played therefore still controls the tests that run. + +### QEMU end-state + +CI-5 will add QEMU as a selection board without changing the resolver or job +template. `test:qemu:smoke` remains an unfiltered MR pre-gate, +`test:qemu:full` remains an always-full non-blocking equivalence run, and a +future `cram qemu [auto]` receives cross-cutting selections. At that point +`profiles/x86_64.yml`, currently an explicit no-op reserved for CI-5, maps to +QEMU `full`. + ## Selecting pipeline jobs Non-scheduled pipelines create every component job and the non-DUT diff --git a/.gitlab/tests/cram/components.yml b/.gitlab/tests/cram/components.yml index 5b7f0fb0c0..c710445aac 100644 --- a/.gitlab/tests/cram/components.yml +++ b/.gitlab/tests/cram/components.yml @@ -49,3 +49,69 @@ hw_only: - "generic/acceleration-plan-components/040-usb-filter.t" # post/** is invoked separately for every component job and is not bucketed. + +selection: + schema: 1 + boards: + wnc-freedom: {alias: Freedom, priority: true} + mxl25641-hdk-6: {alias: OSPv2, priority: true} + turris-omnia: {alias: Omnia, priority: false} + prpl-haze: {alias: Haze, priority: false} + arcadyan-mozart: {alias: Mozart, priority: false} + nokia-valyrian: {alias: Valyrian, priority: false} + paths: + - {glob: profiles/mvebu.yml, boards: [turris-omnia], component: full} + - {glob: profiles/ipq807x.yml, boards: [prpl-haze], component: full} + - {glob: profiles/mtk_filogic.yml, boards: [arcadyan-mozart], component: full} + - {glob: profiles/airoha_an7581.yml, boards: [nokia-valyrian], component: full} + - {glob: profiles/qca_ipq95xx.yml, boards: [wnc-freedom], component: full} + - {glob: profiles/mxl_x86_osp_tb341_v2.yml, boards: [mxl25641-hdk-6], component: full} + - {glob: profiles/mxl_wlan_hostap_ng_wav700.yml, boards: [mxl25641-hdk-6], component: full} + - {glob: profiles/secure_boot_emmc.yml, boards: priority, component: full} + - {glob: profiles/feed_amx.yml, boards: priority, component: full} + - {glob: profiles/feed_prplmesh.yml, boards: priority, component: prplmesh} + - {glob: profiles/feed_prplos.yml, boards: priority, component: prplos} + - {glob: profiles/lcm.yml, boards: priority, component: lcm} + - {glob: profiles/prpl_core.yml, boards: priority, component: full} + - {glob: profiles/prpl.yml, boards: priority, component: full} + - {glob: profiles/mgmt.yml, boards: priority, component: prplos} + - {glob: profiles/security.yml, boards: priority, component: full} + - {glob: profiles/cellular.yml, boards: priority, component: prplos} + - {glob: profiles/thread.yml, boards: priority, component: prplos} + - {glob: .gitlab/tests/cram/generic/**, boards: priority, component: by-test} + - {glob: .gitlab/tests/cram/post/**, boards: priority, component: by-test} + - {glob: .gitlab/testbed.yml, boards: priority, component: prplos} + - {glob: package/**, boards: priority, component: full} + - {glob: target/**, boards: priority, component: full} + - {glob: toolchain/**, boards: priority, component: full} + - {glob: include/**, boards: priority, component: full} + - {glob: config/**, boards: priority, component: full} + - {glob: tools/**, boards: priority, component: full} + - {glob: feeds.conf.default, boards: priority, component: full} + - {glob: Makefile, boards: priority, component: full} + - {glob: rules.mk, boards: priority, component: full} + - {glob: .gitlab/tests/cram/turris-omnia/**, boards: [turris-omnia], component: by-test} + - {glob: .gitlab/tests/cram/prpl-haze/**, boards: [prpl-haze], component: by-test} + - {glob: .gitlab/tests/cram/arcadyan-mozart/**, boards: [arcadyan-mozart], component: by-test} + - {glob: .gitlab/tests/cram/nokia-valyrian/**, boards: [nokia-valyrian], component: by-test} + - {glob: .gitlab/tests/cram/wnc-freedom/**, boards: [wnc-freedom], component: by-test} + - {glob: .gitlab/tests/cram/mxl25641-hdk-6/**, boards: [mxl25641-hdk-6], component: by-test} + - {glob: .gitlab/testbed/turris-omnia.yml, boards: [turris-omnia], component: prplos} + - {glob: .gitlab/testbed/prpl-haze.yml, boards: [prpl-haze], component: prplos} + - {glob: .gitlab/testbed/arcadyan-mozart.yml, boards: [arcadyan-mozart], component: prplos} + - {glob: .gitlab/testbed/nokia-valyrian.yml, boards: [nokia-valyrian], component: prplos} + - {glob: .gitlab/testbed/wnc-freedom.yml, boards: [wnc-freedom], component: prplos} + - {glob: .gitlab/testbed/mxl25641-hdk-6.yml, boards: [mxl25641-hdk-6], component: prplos} + - {glob: profiles/debug.yml, component: noop} + - {glob: profiles/extender_full.yml, component: noop} + - {glob: profiles/extender_minimal.yml, component: noop} + - {glob: profiles/x86_64.yml, component: noop} + - {glob: profiles/ipq40xx.yml, component: noop} + - {glob: profiles/mxl_x86.yml, component: noop} + - {glob: profiles/mxl_x86_sec.yml, component: noop} + - {glob: profiles/mxl_x86_osp_tb341.yml, component: noop} + - {glob: profiles/mxl_wlan.yml, component: noop} + - {glob: profiles/mxl_wlan_hostap_ng.yml, component: noop} + - {glob: profiles/mxl_wlan_hostap_ng_gw.yml, component: noop} + - {glob: .gitlab/tests/cram/components.yml, component: noop} + - {glob: .gitlab/tests/cram/scripts/**, component: noop} diff --git a/.gitlab/tests/cram/scripts/cram_component_bucketing.py b/.gitlab/tests/cram/scripts/cram_component_bucketing.py index 208c2678b0..bbc9fe1f99 100644 --- a/.gitlab/tests/cram/scripts/cram_component_bucketing.py +++ b/.gitlab/tests/cram/scripts/cram_component_bucketing.py @@ -5,6 +5,7 @@ from __future__ import annotations import glob import os +import re from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Dict, Iterable, List, Mapping, Sequence, Set, Tuple @@ -15,12 +16,39 @@ import yaml SCHEMA_VERSION = 1 COMPONENT_NAMES = ("lcm", "prplmesh") RESERVED_NAMES = {"full", "prplos", "sanity", "smoke", "hw_only"} +SELECTION_COMPONENT_ORDER = ("prplmesh", "lcm", "prplos") class BucketingError(Exception): """An input or bucketing invariant is invalid.""" +@dataclass(frozen=True) +class SelectionBoard: + """A DUT board available to diff-based selection.""" + + name: str + alias: str + priority: bool + + +@dataclass(frozen=True) +class SelectionPath: + """One repository path mapping in the selection matrix.""" + + glob: str + boards: Tuple[str, ...] + component: str + + +@dataclass(frozen=True) +class Selection: + """Validated diff-based selection data.""" + + boards: Mapping[str, SelectionBoard] + paths: Tuple[SelectionPath, ...] + + @dataclass(frozen=True) class Manifest: """Validated component manifest and its expanded file matches.""" @@ -30,6 +58,7 @@ class Manifest: component_patterns: Mapping[str, Tuple[str, ...]] smoke_patterns: Tuple[str, ...] hw_only_patterns: Tuple[str, ...] + selection: Selection @dataclass(frozen=True) @@ -167,6 +196,136 @@ def _validate_include_section( return tuple(patterns) +def _glob_regex(pattern: str) -> re.Pattern[str]: + """Compile a repository glob where ``*`` never crosses a slash.""" + + expression = "" + index = 0 + while index < len(pattern): + character = pattern[index] + if character != "*": + expression += re.escape(character) + index += 1 + continue + if pattern[index : index + 2] == "**": + index += 2 + if index < len(pattern) and pattern[index] == "/": + expression += "(?:.*/)?" + index += 1 + else: + expression += ".*" + else: + expression += "[^/]*" + index += 1 + return re.compile(f"^{expression}$") + + +def path_matches_glob(path: str, pattern: str) -> bool: + """Return whether a POSIX repository path matches a validated glob.""" + + return bool(_glob_regex(pattern).fullmatch(path)) + + +def _validate_selection(value: object) -> Selection: + selection = _require_mapping(value, "selection") + unknown = set(selection) - {"schema", "boards", "paths"} + missing = {"schema", "boards", "paths"} - set(selection) + if missing: + raise BucketingError( + f"selection is missing fields: {', '.join(sorted(missing))}" + ) + if unknown: + raise BucketingError( + f"selection has unknown fields: {', '.join(sorted(unknown))}" + ) + if selection["schema"] != SCHEMA_VERSION: + raise BucketingError( + f"selection.schema must be {SCHEMA_VERSION}, got " + f"{selection['schema']!r}" + ) + + raw_boards = _require_mapping(selection["boards"], "selection.boards") + if not raw_boards: + raise BucketingError("selection.boards must not be empty") + boards: Dict[str, SelectionBoard] = {} + aliases: Set[str] = set() + for name, raw_board in raw_boards.items(): + board = _require_mapping(raw_board, f"selection.boards.{name}") + if set(board) != {"alias", "priority"}: + raise BucketingError( + f"selection.boards.{name} must contain alias and priority" + ) + alias = board["alias"] + priority = board["priority"] + if not isinstance(alias, str) or not alias: + raise BucketingError(f"selection.boards.{name}.alias must be a string") + if alias in aliases: + raise BucketingError(f"duplicate selection board alias: {alias}") + if not isinstance(priority, bool): + raise BucketingError(f"selection.boards.{name}.priority must be a boolean") + aliases.add(alias) + boards[name] = SelectionBoard(name, alias, priority) + + raw_paths = selection["paths"] + if not isinstance(raw_paths, list) or not raw_paths: + raise BucketingError("selection.paths must be a non-empty list") + paths: List[SelectionPath] = [] + seen_globs: Set[str] = set() + allowed_components = {"full", "prplmesh", "lcm", "prplos", "by-test", "noop"} + for index, raw_path in enumerate(raw_paths): + context = f"selection.paths[{index}]" + path = _require_mapping(raw_path, context) + if set(path) - {"glob", "boards", "component"}: + raise BucketingError(f"{context} has unknown fields") + if {"glob", "component"} - set(path): + raise BucketingError(f"{context} requires glob and component") + pattern = path["glob"] + component = path["component"] + if not isinstance(pattern, str) or not pattern: + raise BucketingError(f"{context}.glob must be a non-empty string") + posix_pattern = PurePosixPath(pattern) + if ( + posix_pattern.is_absolute() + or ".." in posix_pattern.parts + or "\\" in pattern + or any(character in pattern for character in "?[]") + ): + raise BucketingError(f"{context}.glob is invalid: {pattern!r}") + _glob_regex(pattern) + if pattern in seen_globs: + raise BucketingError(f"duplicate selection glob: {pattern}") + seen_globs.add(pattern) + if component not in allowed_components: + raise BucketingError(f"{context}.component is invalid: {component!r}") + + raw_targets = path.get("boards") + if component == "noop": + if raw_targets is not None: + raise BucketingError(f"{context} noop must not name boards") + targets: Tuple[str, ...] = () + elif raw_targets == "priority": + targets = tuple(name for name, board in boards.items() if board.priority) + if not targets: + raise BucketingError(f"{context} priority selects no boards") + elif isinstance(raw_targets, list) and raw_targets: + if any(not isinstance(target, str) for target in raw_targets): + raise BucketingError(f"{context}.boards must contain strings") + targets = tuple(raw_targets) + if len(targets) != len(set(targets)): + raise BucketingError(f"{context}.boards contains duplicates") + missing_targets = set(targets) - set(boards) + if missing_targets: + raise BucketingError( + f"{context}.boards names unknown boards: " + f"{', '.join(sorted(missing_targets))}" + ) + else: + raise BucketingError(f"{context}.boards must be priority or a list") + paths.append(SelectionPath(pattern, targets, component)) + + return Selection(boards=boards, paths=tuple(paths)) + + def load_manifest(path: Path) -> Manifest: """Load and validate manifest structure without expanding its globs.""" @@ -179,7 +338,14 @@ def load_manifest(path: Path) -> Manifest: raise BucketingError(f"cannot parse manifest {path}: {error}") from error document = _require_mapping(raw, "manifest") - expected_top_level = {"version", "sanity", "components", "smoke", "hw_only"} + expected_top_level = { + "version", + "sanity", + "components", + "smoke", + "hw_only", + "selection", + } missing = expected_top_level - set(document) unknown = set(document) - expected_top_level if missing: @@ -221,12 +387,11 @@ def load_manifest(path: Path) -> Manifest: sanity_patterns = _validate_include_section( document["sanity"], "sanity", {"include"} ) - smoke_patterns = _validate_include_section( - document["smoke"], "smoke", {"include"} - ) + smoke_patterns = _validate_include_section(document["smoke"], "smoke", {"include"}) hw_only_patterns = _validate_include_section( document["hw_only"], "hw_only", {"include"} ) + selection = _validate_selection(document["selection"]) return Manifest( path=path, @@ -234,6 +399,7 @@ def load_manifest(path: Path) -> Manifest: component_patterns=component_patterns, smoke_patterns=smoke_patterns, hw_only_patterns=hw_only_patterns, + selection=selection, ) @@ -241,7 +407,9 @@ def _relative_test_path(test_root: Path, path: Path) -> str: try: return path.relative_to(test_root).as_posix() except ValueError as error: - raise BucketingError(f"test is outside test root {test_root}: {path}") from error + raise BucketingError( + f"test is outside test root {test_root}: {path}" + ) from error def discover_tests(test_root: Path, include_post: bool = False) -> List[Path]: @@ -355,9 +523,7 @@ def partition_tests( rendered = "\n".join( f" - {path}: {', '.join(names)}" for path, names in overlaps ) - raise BucketingError( - "tests match more than one partition bucket:\n" + rendered - ) + raise BucketingError("tests match more than one partition bucket:\n" + rendered) return buckets @@ -391,7 +557,67 @@ def select_component_tests( sanity = list(buckets["sanity"]) sanity_set = set(sanity) - return sanity + [path for path in full if path in selected and path not in sanity_set] + return sanity + [ + path for path in full if path in selected and path not in sanity_set + ] + + +def classify_changed_test(manifest: Manifest, repository_path: str) -> str | None: + """Classify one changed cram test path for automatic selection.""" + + prefix = ".gitlab/tests/cram/" + if not repository_path.startswith(prefix): + raise BucketingError(f"not a cram test path: {repository_path}") + relative = repository_path[len(prefix) :] + if not relative.endswith(".t"): + return None + if relative.startswith("post/") or any( + path_matches_glob(relative, pattern) for pattern in manifest.sanity_patterns + ): + return "prplos" + explicit = [ + component + for component, patterns in manifest.component_patterns.items() + if any(path_matches_glob(relative, pattern) for pattern in patterns) + ] + if len(explicit) > 1: + raise BucketingError( + f"changed test matches multiple components: {repository_path}: " + f"{', '.join(explicit)}" + ) + return explicit[0] if explicit else "prplos" + + +def select_changed_components( + manifest: Manifest, board: str, changed_paths: Sequence[str] +) -> tuple[str, bool]: + """Resolve changed paths to one board's ordered component selection.""" + + if board not in manifest.selection.boards: + raise BucketingError(f"unknown selection board: {board}") + selected: Set[str] = set() + matched_for_board = False + for changed_path in changed_paths: + for mapping in manifest.selection.paths: + if not path_matches_glob(changed_path, mapping.glob): + continue + if board not in mapping.boards: + continue + matched_for_board = True + component = mapping.component + if component == "by-test": + component = classify_changed_test(manifest, changed_path) + if component is None: + continue + if component == "full": + return "full", False + if component != "noop": + selected.add(component) + + if set(SELECTION_COMPONENT_ORDER).issubset(selected): + return "full", False + ordered = [name for name in SELECTION_COMPONENT_ORDER if name in selected] + return ",".join(ordered), matched_for_board and not ordered def render_test_paths(paths: Sequence[Path]) -> str: diff --git a/.gitlab/tests/cram/scripts/resolve-changed-components.py b/.gitlab/tests/cram/scripts/resolve-changed-components.py new file mode 100755 index 0000000000..0ce7b2991d --- /dev/null +++ b/.gitlab/tests/cram/scripts/resolve-changed-components.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Resolve changed repository paths to per-board cram components.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import Sequence + +from cram_component_bucketing import ( + BucketingError, + Manifest, + load_manifest, + resolve_manifest_path, + select_changed_components, +) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--test-root", required=True, type=Path) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--base-ref", required=True) + parser.add_argument("--head-ref", default="HEAD") + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--board") + target.add_argument("--report", action="store_true") + parser.add_argument("--cram-components", default=None) + parser.add_argument("--labels", default="") + return parser.parse_args(argv) + + +def _run_git(test_root: Path, arguments: Sequence[str]) -> str: + command = ["git", "-C", os.fspath(test_root), *arguments] + try: + result = subprocess.run( + command, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except FileNotFoundError as error: + raise BucketingError("git is required for selection") from error + except subprocess.CalledProcessError as error: + detail = error.stderr.strip() or error.stdout.strip() + raise BucketingError( + f"command failed ({' '.join(command)}): {detail}" + ) from error + return result.stdout + + +def _changed_paths(test_root: Path, base_ref: str, head_ref: str) -> list[str]: + output = _run_git( + test_root, + ["diff", "--name-only", "--no-renames", f"{base_ref}..{head_ref}"], + ) + return [path for path in output.splitlines() if path] + + +def _label_override(labels: str) -> tuple[str | None, str | None]: + values = [label.strip() for label in labels.split(",") if label.strip()] + cram_labels = [label for label in values if label.startswith("cram::")] + if len(cram_labels) > 1: + raise BucketingError( + "multiple cram selection labels: " + ", ".join(cram_labels) + ) + if not cram_labels: + return None, None + label = cram_labels[0] + value = label.removeprefix("cram::") + if value == "skip": + return "", "stand-down:label" + if value not in {"full", "prplmesh", "lcm", "prplos"}: + raise BucketingError(f"unknown cram selection label: {label}") + return value, f"label:{value}" + + +def _resolve_board( + manifest: Manifest, + board: str, + changed_paths: Sequence[str] | None, + cram_components: str | None, + labels: str, +) -> tuple[str, str]: + if board not in manifest.selection.boards: + raise BucketingError(f"unknown selection board: {board}") + if cram_components is not None: + return "", "stand-down:variable" + label_selection, label_reason = _label_override(labels) + if label_reason is not None: + return label_selection or "", label_reason + assert changed_paths is not None + selection, drift = select_changed_components(manifest, board, changed_paths) + if selection: + return selection, "computed" + return "", "DRIFT:coarse-match-without-component" if drift else "nothing-mapped" + + +def _empty_reason(alias: str, reason: str) -> str: + return f"nothing selected for {alias}: {reason}" + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + manifest_path = resolve_manifest_path(args.test_root, args.manifest) + manifest = load_manifest(manifest_path) + _, label_reason = _label_override(args.labels) + needs_diff = args.cram_components is None and label_reason is None + changed = ( + _changed_paths(args.test_root, args.base_ref, args.head_ref) + if needs_diff + else None + ) + boards = tuple(manifest.selection.boards) if args.report else (args.board,) + results = [] + for board in boards: + selection, reason = _resolve_board( + manifest, + board, + changed, + args.cram_components, + args.labels, + ) + results.append((board, selection, reason)) + except BucketingError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + if args.report: + for board, selection, reason in results: + alias = manifest.selection.boards[board].alias + rendered = selection or "(empty)" + print(f"{alias}: {rendered} [{reason}]") + if not selection: + print(_empty_reason(alias, reason), file=sys.stderr) + else: + board, selection, reason = results[0] + print(selection) + if not selection: + alias = manifest.selection.boards[board].alias + print(_empty_reason(alias, reason), file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/profiles/x86_64.yml b/profiles/x86_64.yml index b0439665df..a3b30bb467 100644 --- a/profiles/x86_64.yml +++ b/profiles/x86_64.yml @@ -1,4 +1,5 @@ --- +# reserved: QEMU board (CI-5) profiles: - generic target: x86