diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 16385a01db..88ccf82bb2 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -109,6 +109,13 @@ class LinuxConfig: def __post_init__(self) -> None: if isinstance(self.package, dict): self.package = PackageConfig(**self.package) + # The two flags pull in opposite directions: 'minimal' asks for the + # smallest matrix, 'extended' for the largest. The filtering would drop + # such a config from the minimal matrix, which reads as the opposite of + # what it declared, so reject the pair instead. + assert not ( + self.minimal and self.extended + ), "a config cannot be both 'minimal' and 'extended'." @dataclasses.dataclass @@ -221,6 +228,49 @@ _ARCHS: dict[str, Architecture] = { } +def expand_linux_config( + distro: str, cfg: LinuxConfig, image_tag: str +) -> list[MatrixEntry]: + """Expand one Linux config over the cross-product of its lists. + + Kept apart from the size filtering in expand_linux_matrix so that + validate_linux_matrices can ask what a single config expands to without + repeating the cross-product. + + @param distro The distro key the config is listed under in linux.json. + @param cfg The config to expand. + @param image_tag The tag of the nix image the entries build in. + @return One entry per (compiler, build type, sanitizer, architecture) + combination the config's lists produce. + """ + # An empty sanitizers list means "one entry with no sanitizer". + effective_sanitizers = cfg.sanitizers or [""] + effective_archs = {arch: _ARCHS[arch] for arch in cfg.arch} + + return [ + MatrixEntry( + config_name=config_name( + distro, compiler, build_type, arch, cfg.suffix, sanitizer + ), + image=f"ghcr.io/xrplf/xrpld/nix-{distro}:{image_tag}", + cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), + cmake_target="all", + build_only=False, + benchmark=cfg.benchmark, + build_type=build_type, + architecture=arch_info, + sanitizers=sanitizer, + compiler=compiler, + ) + for compiler, build_type, sanitizer, (arch, arch_info) in itertools.product( + cfg.compiler, + cfg.build_type, + effective_sanitizers, + effective_archs.items(), + ) + ] + + def expand_linux_matrix( linux: LinuxFile, minimal: bool, extended: bool = False ) -> list[MatrixEntry]: @@ -246,37 +296,94 @@ def expand_linux_matrix( continue if not extended and cfg.extended: continue - # An empty sanitizers list means "one entry with no sanitizer". - effective_sanitizers = cfg.sanitizers or [""] - effective_archs = {arch: _ARCHS[arch] for arch in cfg.arch} - - for compiler, build_type, sanitizer, (arch, arch_info) in itertools.product( - cfg.compiler, - cfg.build_type, - effective_sanitizers, - effective_archs.items(), - ): - name = config_name( - distro, compiler, build_type, arch, cfg.suffix, sanitizer - ) - entries.append( - MatrixEntry( - config_name=name, - image=f"ghcr.io/xrplf/xrpld/nix-{distro}:{linux.image_tag}", - cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), - cmake_target="all", - build_only=False, - benchmark=cfg.benchmark, - build_type=build_type, - architecture=arch_info, - sanitizers=sanitizer, - compiler=compiler, - ) - ) + entries += expand_linux_config(distro, cfg, linux.image_tag) return entries +def validate_linux_matrices(linux: LinuxFile) -> None: + """Check that the three matrix sizes nest, and hold the configs they should. + + CI runs only the jobs this script emits, so a config that falls out of the + size it belongs to takes its coverage with it and fails nothing. These checks + run on every invocation, so the drop fails matrix generation instead. + + The checks name no config, only the flags, so adding or removing a config + needs no edit here. + + @param linux The parsed linux.json. + @raise AssertionError If two configs expand to the same name, if the sizes do + not nest, or if a config reaches a size it does not belong to. + """ + minimal_names = {e.config_name for e in expand_linux_matrix(linux, minimal=True)} + full_names = {e.config_name for e in expand_linux_matrix(linux, minimal=False)} + extended_names = { + e.config_name for e in expand_linux_matrix(linux, minimal=False, extended=True) + } + + # The names each config expands to, paired with the config, so that the + # checks below expand every config once. + per_config = [ + ( + distro, + cfg, + {e.config_name for e in expand_linux_config(distro, cfg, linux.image_tag)}, + ) + for distro, configs in linux.configs.items() + for cfg in configs + ] + + # A config name is also the name of the artifacts the job uploads, so two + # configs that expand to the same name overwrite each other. The per-config + # checks below also need a name to belong to one config only. + all_names = [n for _, _, names in per_config for n in names] + duplicates = sorted({n for n in all_names if all_names.count(n) > 1}) + assert not duplicates, f"configs expand to duplicate names: {duplicates}." + + # The sizes nest, so a larger one only ever adds. Were 'extended' to replace + # the full matrix rather than widen it, the nightly would test less than a + # labeled pull request does, which is the opposite of the intent. + assert minimal_names <= full_names, ( + "the minimal matrix is not part of the full one, missing: " + f"{sorted(minimal_names - full_names)}." + ) + assert full_names <= extended_names, ( + "the full matrix is not part of the extended one, missing: " + f"{sorted(full_names - extended_names)}." + ) + + # Every config reaches the sizes its flags ask for, and no others. + for distro, cfg, names in per_config: + if cfg.extended: + assert names <= extended_names, ( + f"{distro} config flagged 'extended' is missing from the " + f"extended matrix: {sorted(names - extended_names)}." + ) + assert not names & full_names, ( + f"{distro} config flagged 'extended' also reaches the full " + f"matrix: {sorted(names & full_names)}." + ) + else: + assert names <= full_names, ( + f"{distro} config is missing from the full matrix: " + f"{sorted(names - full_names)}." + ) + if cfg.minimal: + assert names <= minimal_names, ( + f"{distro} config flagged 'minimal' is missing from the " + f"minimal matrix: {sorted(names - minimal_names)}." + ) + + # The 'extended' tier costs a flag here, a condition in + # reusable-strategy-matrix.yml, and the check after it. An empty tier leaves + # all three as dead weight that still reads as working, so require a holder. + # Checked last, because the checks above name the config that went missing. + assert extended_names > full_names, ( + "no config is flagged 'extended', so the extended matrix is the full one. " + "Either flag the config that needs the tier, or remove the tier." + ) + + def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: """Generate the packaging matrix from the configs that carry a 'package' map. @@ -410,17 +517,20 @@ if __name__ == "__main__": matrix: list[MatrixEntry] | list[PackagingEntry] = [] + # Checked on every invocation, including the ones that emit another platform + # or the packaging matrix, so that no call can pass a broken linux.json. + linux = LinuxFile.load(THIS_DIR / "linux.json") + validate_linux_matrices(linux) + if args.packaging: - matrix = expand_linux_packaging(LinuxFile.load(THIS_DIR / "linux.json")) + matrix = expand_linux_packaging(linux) # One list per format, so each install-test job installs the packages its # own format produced. for package_type, names in package_names_by_type(matrix).items(): print(f"{package_type}_package_names={json.dumps(names)}") else: if args.config in ("linux", None): - matrix += expand_linux_matrix( - LinuxFile.load(THIS_DIR / "linux.json"), args.minimal, args.extended - ) + matrix += expand_linux_matrix(linux, args.minimal, args.extended) if args.config in ("macos", None): matrix += expand_platform_matrix( PlatformFile.load(THIS_DIR / "macos.json"), args.minimal diff --git a/.github/workflows/reusable-strategy-matrix.yml b/.github/workflows/reusable-strategy-matrix.yml index 6d1daea428..c2dbcc4d1e 100644 --- a/.github/workflows/reusable-strategy-matrix.yml +++ b/.github/workflows/reusable-strategy-matrix.yml @@ -21,6 +21,19 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate.outputs.matrix }} + # Set at job level rather than on the step, so that the generate step and the + # check after it read the same values and cannot disagree. + env: + GENERATE_CONFIG: ${{ inputs.os != '' && format('--config={0}', inputs.os) || '' }} + # Run only the minimal matrix for pull requests that are not yet + # labeled "Ready to merge" or "Full CI build". Any other event (merge + # queue, push, schedule, manual dispatch) runs the full matrix. + GENERATE_MINIMAL: ${{ (github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'Ready to merge') && !contains(github.event.pull_request.labels.*.name, 'Full CI build')) && '--minimal' || '' }} + # Configs flagged "extended" in the JSON are too expensive to run per + # pull request, so only the nightly schedule adds them. A manual run + # counts as well, because otherwise the only way to exercise one is to + # wait for the night. + GENERATE_EXTENDED: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '--extended' || '' }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -33,15 +46,22 @@ jobs: - name: Generate strategy matrix working-directory: .github/scripts/strategy-matrix id: generate - env: - GENERATE_CONFIG: ${{ inputs.os != '' && format('--config={0}', inputs.os) || '' }} - # Run only the minimal matrix for pull requests that are not yet - # labeled "Ready to merge" or "Full CI build". Any other event (merge - # queue, push, schedule, manual dispatch) runs the full matrix. - GENERATE_MINIMAL: ${{ (github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'Ready to merge') && !contains(github.event.pull_request.labels.*.name, 'Full CI build')) && '--minimal' || '' }} - # Configs flagged "extended" in the JSON are too expensive to run per - # pull request, so only the nightly schedule adds them. A manual run - # counts as well, because otherwise the only way to exercise one is to - # wait for the night. - GENERATE_EXTENDED: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '--extended' || '' }} run: ./generate.py ${GENERATE_CONFIG} ${GENERATE_MINIMAL} ${GENERATE_EXTENDED} >>"${GITHUB_OUTPUT}" + + # generate.py checks that the extended matrix holds every config flagged + # "extended", but it never sees the event, so it cannot tell whether this + # run asked for them. A GENERATE_EXTENDED that stops matching the events + # would drop those configs and fail nothing, because CI runs only the jobs + # the matrix names. So compare what was generated against the extended + # matrix, on the events that must produce it. + - name: Check the extended configs reached the matrix + if: ${{ env.GENERATE_EXTENDED != '' }} + working-directory: .github/scripts/strategy-matrix + env: + GENERATED: ${{ steps.generate.outputs.matrix }} + run: | + expected="$(./generate.py ${GENERATE_CONFIG} --extended)" + if [[ "matrix=${GENERATED}" != "${expected}" ]]; then + echo "::error::the ${GITHUB_EVENT_NAME} matrix is not the extended one" + exit 1 + fi