diff --git a/.github/workflows/check_build_status.yml b/.github/workflows/check_build_status.yml new file mode 100644 index 0000000000..65c13440bc --- /dev/null +++ b/.github/workflows/check_build_status.yml @@ -0,0 +1,32 @@ +# documentation: https://help.github.com/en/articles/workflow-syntax-for-github-actions +# +# This workflow checks the bot's status table (posted in PR comments) to verify +# that all supported CPU targets have been successfully built before deployment. +name: Check bot build status +on: + issue_comment: + types: [created, edited] +permissions: + contents: read # to fetch code (actions/checkout) +jobs: + check_builds: + if: > + github.event.issue.pull_request && + contains(github.event.comment.body, 'This is the status of all the `bot: build` commands:') + runs-on: ubuntu-24.04 + steps: + - name: Check out software-layer repository (shallow) + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: 1 + - name: Check bot build status + # We pass the comment body via env variable instead of a command-line argument + # because the body contains special characters (pipes |, backticks, newlines, etc.) + # that would need complex quoting/escaping in bash. Passing via env is safer + # and preserves the content exactly as-is. The Python script reads both + # PR_NUMBER and COMMENT_BODY from the environment. + env: + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + python3 .github/workflows/scripts/check_builds.py diff --git a/.github/workflows/eessi_targets.toml b/.github/workflows/eessi_targets.toml new file mode 100644 index 0000000000..a328646738 --- /dev/null +++ b/.github/workflows/eessi_targets.toml @@ -0,0 +1,182 @@ +# Supported targets per EESSI version, shared by the CI workflows and scripts. +# +# cpu_targets: should correspond with the table @ https://eessi.io/docs/software_layer/cpu_targets/ +# Per EESSI version, the CPU targets for each CPU family. +# accelerator_targets: should correspond with the table @ https://eessi.io/docs/software_layer/gpu_targets/ +# Per EESSI version, the accelerator targets to build for. 'default' applies to every CPU target, +# unless a CPU target has an entry of its own (which may be empty, i.e. no accelerators). + +[cpu_targets."2023.06"] +aarch64 = [ + "aarch64/a64fx", + "aarch64/generic", + "aarch64/neoverse_n1", + "aarch64/neoverse_v1", + "aarch64/nvidia/grace", +] +x86_64 = [ + "x86_64/amd/zen2", + "x86_64/amd/zen3", + "x86_64/amd/zen4", + "x86_64/generic", + "x86_64/intel/cascadelake", + "x86_64/intel/haswell", + "x86_64/intel/icelake", + "x86_64/intel/sapphirerapids", + "x86_64/intel/skylake_avx512", +] + +[cpu_targets."2025.06"] +aarch64 = [ + "aarch64/a64fx", + "aarch64/generic", + "aarch64/neoverse_n1", + "aarch64/neoverse_v1", + "aarch64/nvidia/grace", +] +x86_64 = [ + "x86_64/amd/zen2", + "x86_64/amd/zen3", + "x86_64/amd/zen4", + "x86_64/amd/zen5", + "x86_64/generic", + "x86_64/intel/cascadelake", + "x86_64/intel/haswell", + "x86_64/intel/icelake", + "x86_64/intel/sapphirerapids", + "x86_64/intel/skylake_avx512", +] + +[cpu_targets."2026.06"] +aarch64 = [ + "aarch64/a64fx", + "aarch64/aws/graviton4", + "aarch64/generic", + "aarch64/neoverse_n1", + "aarch64/neoverse_v1", + "aarch64/nvidia/grace", +] +x86_64 = [ + "x86_64/amd/zen2", + "x86_64/amd/zen3", + "x86_64/amd/zen4", + "x86_64/amd/zen5", + "x86_64/generic", + "x86_64/intel/cascadelake", + "x86_64/intel/graniterapids", + "x86_64/intel/haswell", + "x86_64/intel/icelake", + "x86_64/intel/sapphirerapids", + "x86_64/intel/skylake_avx512", +] + +[accelerator_targets."2023.06"] +# Provide a default set of compute capabilities +default = [ + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] +# and then allow for special cases for specific CPU targets +"aarch64/a64fx" = [] +"x86_64/amd/zen2" = [ + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] + +[accelerator_targets."2025.06"] +# Provide a default set of compute capabilities +default = [ + "amd/gfx1030", + "amd/gfx1100", + "amd/gfx1101", + "amd/gfx1200", + "amd/gfx1201", + "amd/gfx908", + "amd/gfx90a", + "amd/gfx942", + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] +# and then allow for special cases for specific CPU targets +"aarch64/a64fx" = [] +"aarch64/generic" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/neoverse_n1" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/neoverse_v1" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/nvidia/grace" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc70", + "nvidia/cc80", + "nvidia/cc90", +] + +[accelerator_targets."2026.06"] +# Provide a default set of compute capabilities +default = [ + "amd/gfx1030", + "amd/gfx1100", + "amd/gfx1101", + "amd/gfx1200", + "amd/gfx1201", + "amd/gfx908", + "amd/gfx90a", + "amd/gfx942", + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc75", + "nvidia/cc80", + "nvidia/cc90", +] +# and then allow for special cases for specific CPU targets +"aarch64/a64fx" = [] +"aarch64/generic" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc75", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/neoverse_n1" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc75", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/neoverse_v1" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc75", + "nvidia/cc80", + "nvidia/cc90", +] +"aarch64/nvidia/grace" = [ + "nvidia/cc100", + "nvidia/cc120", + "nvidia/cc75", + "nvidia/cc80", + "nvidia/cc90", +] diff --git a/.github/workflows/scripts/check_builds.py b/.github/workflows/scripts/check_builds.py new file mode 100644 index 0000000000..4bd7d80f6b --- /dev/null +++ b/.github/workflows/scripts/check_builds.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# Check that all supported CPU targets have been successfully built, based on +# the bot's status table in the PR comment. +# +# Usage: check_builds.py +# Reads PR_NUMBER and COMMENT_BODY from environment variables. +# No API calls required — the triggering comment body is already available. +# +# Exit codes: +# 0: Success (all supported targets have successful builds) +# 1: Failure (missing/failed builds, or commit SHA inconsistency) + +import datetime +import os +import sys +import re +import tomllib + + +# The supported CPU targets per EESSI version are defined in a single place, shared +# with the other workflows: .github/workflows/eessi_targets.toml +TARGETS_FILE = os.environ.get( + "EESSI_TARGETS_FILE", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "eessi_targets.toml"), +) + + +def load_supported_targets(targets_file=TARGETS_FILE): + """ + Build the mapping from repository name (value of the 'repo' column in the bot's + status table, e.g. 'eessi.io-2025.06-software') to the list of CPU targets (values + of the 'for' column) that must have been successfully built for it. + The TOML file lists the targets per EESSI version and per CPU family; those are flattened here. + """ + with open(targets_file, "rb") as f: + cpu_targets = tomllib.load(f)["cpu_targets"] + return { + f"eessi.io-{version}-software": [t for family in families.values() for t in family] + for version, families in cpu_targets.items() + } + + +def parse_date(date_str): + """ + Parse a date string like 'Aug 04 18:11:14 UTC 2026' into a datetime object. + Returns None if parsing fails. + """ + try: + return datetime.datetime.strptime(date_str, "%b %d %X %Z %Y") + except ValueError: + return None + + +def parse_markdown_table(body): + """ + Parse the bot's status table from the comment body. + Returns a list of dicts, one per row, with column names as keys. + Uses header-driven parsing (column names from the header row) so it + tolerates extra/missing columns like 'commit SHA'. + """ + marker = "This is the status of all the `bot: build` commands:" + if marker not in body: + return None + + # Find the table: start from the marker line, look for header row + lines = body.splitlines() + header_idx = None + for i, line in enumerate(lines): + if line.startswith("|on|"): + header_idx = i + break + + if header_idx is None: + return None + + # Parse header row + header_line = lines[header_idx] + # Split by | and strip whitespace, remove empty first/last from leading/trailing | + columns = [c.strip() for c in header_line.strip("|").split("|")] + + # Find separator row (line of dashes) + separator_idx = header_idx + 1 + while separator_idx < len(lines) and not lines[separator_idx].startswith("|"): + separator_idx += 1 + if separator_idx >= len(lines): + return None + + # Parse data rows + rows = [] + for line in lines[separator_idx + 1:]: + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) != len(columns): + continue + row = dict(zip(columns, cells)) + # Strip backticks from 'on' and 'for' values + for key in ["on", "for"]: + if key in row: + row[key] = row[key].strip("`") + rows.append(row) + + return rows + + +def dedup_by_for_repo(rows): + """ + Deduplicate rows by (for, repo) pair, keeping only the last build + (by date) for each pair. + Returns a dict: (for, repo) -> row + """ + latest = {} + for row in rows: + key = (row.get("for"), row.get("repo")) + if key == (None, None) or key[0] is None: + continue + date_str = row.get("date", "") + date = parse_date(date_str) + if date is None: + # Treat as oldest if date can't be parsed + date = datetime.datetime.min + if key not in latest or date > latest[key][0]: + latest[key] = (date, row) + return {k: v[1] for k, v in latest.items()} + + +def main(): + pr_number = os.environ.get("PR_NUMBER") + comment_body = os.environ.get("COMMENT_BODY") + + if not pr_number or not comment_body: + print("ERROR: PR_NUMBER and COMMENT_BODY must be set in environment") + sys.exit(1) + + # Parse the table + rows = parse_markdown_table(comment_body) + if rows is None: + print("ERROR: Could not find status table in comment") + sys.exit(1) + + if not rows: + print("ERROR: Status table is empty") + sys.exit(1) + + # Deduplicate by (for, repo) + deduped = dedup_by_for_repo(rows) + if not deduped: + print("ERROR: No valid builds found in table") + sys.exit(1) + + SUPPORTED_TARGETS = load_supported_targets() + + # Determine which repos are present in both table and mapping + table_repos = set(row.get("repo") for row in deduped.values() if row.get("repo")) + mapping_repos = set(SUPPORTED_TARGETS.keys()) + repos_to_check = table_repos & mapping_repos + + # Warn if repos in table are missing from mapping + for repo in table_repos - mapping_repos: + print(f"WARNING: Repo '{repo}' found in table but not in eessi_targets.toml; skipping checks for this repo") + + # Check commit SHA consistency across all deduplicated builds (if column present) + commit_shas = set() + for row in deduped.values(): + sha = row.get("commit SHA") + if sha: + commit_shas.add(sha) + + if len(commit_shas) > 1: + print("ERROR: Commit SHA inconsistency detected across builds") + for sha in commit_shas: + matching_rows = [r for r in deduped.values() if r.get("commit SHA") == sha] + targets = [r.get("for") for r in matching_rows] + print(f" SHA {sha}: targets {targets}") + print("All builds must reference the same commit SHA.") + sys.exit(1) + + # Verify each supported target for each repo + failed = False + for repo in repos_to_check: + supported = SUPPORTED_TARGETS[repo] + print(f"\nChecking repo: {repo}") + builds_for_repo = {k: v for k, v in deduped.items() if v.get("repo") == repo} + + for target in supported: + key = (target, repo) + row = deduped.get(key) + if row is None: + print(f" FAIL: Target '{target}' not found in builds for {repo}") + failed = True + continue + + status = row.get("status", "").strip() + result = row.get("result", "") + if status != "finished" or "SUCCESS" not in result: + print(f" FAIL: Target '{target}' is not SUCCESS/finished") + print(f" status={status}, result={result}") + if row.get("url"): + print(f" URL: {row['url']}") + failed = True + else: + print(f" OK: Target '{target}' built successfully") + + if failed: + print("\nERROR: Build verification failed. Please fix the above issues.") + sys.exit(1) + + print("\nSUCCESS: All supported targets have been successfully built.") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/test-software.eessi.io.yml b/.github/workflows/test-software.eessi.io.yml index 4f0fa8ffa2..b7b2ba904e 100644 --- a/.github/workflows/test-software.eessi.io.yml +++ b/.github/workflows/test-software.eessi.io.yml @@ -9,162 +9,11 @@ on: permissions: contents: read # to fetch code (actions/checkout) env: - # list below should correspond with table @ https://eessi.io/docs/software_layer/cpu_targets/ - EESSI_CPU_TARGETS: | - 2023.06: - x86_64: - - x86_64/amd/zen2 - - x86_64/amd/zen3 - - x86_64/amd/zen4 - - x86_64/intel/haswell - - x86_64/intel/sapphirerapids - - x86_64/intel/skylake_avx512 - - x86_64/intel/icelake - - x86_64/intel/cascadelake - - x86_64/generic - aarch64: - - aarch64/generic - - aarch64/neoverse_n1 - - aarch64/neoverse_v1 - - aarch64/nvidia/grace - - aarch64/a64fx - - 2025.06: - x86_64: - - x86_64/amd/zen2 - - x86_64/amd/zen3 - - x86_64/amd/zen4 - - x86_64/amd/zen5 - - x86_64/intel/haswell - - x86_64/intel/sapphirerapids - - x86_64/intel/skylake_avx512 - - x86_64/intel/icelake - - x86_64/intel/cascadelake - - x86_64/generic - aarch64: - - aarch64/generic - - aarch64/neoverse_n1 - - aarch64/neoverse_v1 - - aarch64/nvidia/grace - - aarch64/a64fx - - 2026.06: - x86_64: - - x86_64/amd/zen2 - - x86_64/amd/zen3 - - x86_64/amd/zen4 - - x86_64/amd/zen5 - - x86_64/intel/haswell - - x86_64/intel/sapphirerapids - - x86_64/intel/graniterapids - - x86_64/intel/skylake_avx512 - - x86_64/intel/icelake - - x86_64/intel/cascadelake - - x86_64/generic - aarch64: - - aarch64/generic - - aarch64/neoverse_n1 - - aarch64/neoverse_v1 - - aarch64/nvidia/grace - - aarch64/a64fx - - aarch64/aws/graviton4 - - # list below should correspond with table @ https://eessi.io/docs/software_layer/gpu_targets/ - EESSI_ACCELERATOR_TARGETS: | - 2023.06: - # Provide a default set of compute capabilities - default: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - # and then allow for special cases for specific architectures - x86_64/amd/zen2: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - aarch64/a64fx: [] - - 2025.06: - default: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - - amd/gfx908 - - amd/gfx90a - - amd/gfx942 - - amd/gfx1030 - - amd/gfx1100 - - amd/gfx1101 - - amd/gfx1200 - - amd/gfx1201 - aarch64/generic: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/neoverse_n1: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/neoverse_v1: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/nvidia/grace: - - nvidia/cc70 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/a64fx: [] - - 2026.06: - default: - - nvidia/cc75 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - - amd/gfx908 - - amd/gfx90a - - amd/gfx942 - - amd/gfx1030 - - amd/gfx1100 - - amd/gfx1101 - - amd/gfx1200 - - amd/gfx1201 - aarch64/generic: - - nvidia/cc75 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/neoverse_n1: - - nvidia/cc75 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/neoverse_v1: - - nvidia/cc75 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/nvidia/grace: - - nvidia/cc75 - - nvidia/cc80 - - nvidia/cc90 - - nvidia/cc100 - - nvidia/cc120 - aarch64/a64fx: [] + # Supported CPU and accelerator targets per EESSI version live in a single TOML file, so that + # other workflows/scripts can reuse them. The CPU targets should correspond with the table @ + # https://eessi.io/docs/software_layer/cpu_targets/ and the accelerator targets with the table @ + # https://eessi.io/docs/software_layer/gpu_targets/ + EESSI_TARGETS_FILE: ${{ github.workspace }}/.github/workflows/eessi_targets.toml jobs: # Checks whether this PR modifies any easystack files and, if so, # determines which EESSI versions are affected. @@ -298,7 +147,7 @@ jobs: run: | # From this extract the CPUs we need to loop over - cpu_subdirs=$(echo "${EESSI_CPU_TARGETS}" | yq ".\"${EESSI_VERSION}\".\"${EESSI_CPU_FAMILY}\" | .[]") + cpu_subdirs=$(yq -p toml -o yaml ".cpu_targets.\"${EESSI_VERSION}\".\"${EESSI_CPU_FAMILY}\" | .[]" "${EESSI_TARGETS_FILE}") if [[ -z "${cpu_subdirs}" ]]; then echo "ERROR: No CPU subdirs resolved for ${EESSI_VERSION} / ${EESSI_CPU_FAMILY}" exit 1 @@ -363,7 +212,7 @@ jobs: echo echo "now check accelerator builds for ${cpu}" - accelerators=$(echo "${EESSI_ACCELERATOR_TARGETS}" | yq ".\"${{matrix.EESSI_VERSION}}\".\"${cpu}\" // .\"${{matrix.EESSI_VERSION}}\".default | .[]") + accelerators=$(yq -p toml -o yaml ".accelerator_targets.\"${{matrix.EESSI_VERSION}}\".\"${cpu}\" // .accelerator_targets.\"${{matrix.EESSI_VERSION}}\".default | .[]" "${EESSI_TARGETS_FILE}") if [[ -z "${accelerators}" ]]; then echo "no accelerator targets defined for ${cpu}" @@ -425,6 +274,17 @@ jobs: done + # The per-CPU-target logs are only printed to the job output when a target fails (and then only + # the first failing one), so always keep them as an artifact, also for successful runs + - name: Upload per-CPU-target logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: missing-installations-logs-${{ matrix.EESSI_VERSION }}-${{ matrix.runs_on }} + path: log_*.txt + if-no-files-found: ignore + retention-days: 14 + - name: Test check_missing_installations.sh with missing package (GCC/8.3.0) run: | # This is a general check, doesn't need to be run for every CPU, just the host is fine @@ -458,16 +318,14 @@ jobs: echo "::group::EESSI ${EESSI_VERSION} / ${EESSI_CPU_FAMILY}" # Get all CPU targets belonging to this architecture family. - CPU_TARGETS=$(echo "${EESSI_CPU_TARGETS}" \ - | yq -r ".\"${EESSI_VERSION}\".\"${EESSI_CPU_FAMILY}\"[]") + CPU_TARGETS=$(yq -p toml -o yaml ".cpu_targets.\"${EESSI_VERSION}\".\"${EESSI_CPU_FAMILY}\" | .[]" "${EESSI_TARGETS_FILE}") for COMPARISON_ARCH in ${CPU_TARGETS}; do echo "::group::Compare ${EESSI_VERSION} / ${COMPARISON_ARCH}" # Use architecture-specific accelerator targets where defined, # otherwise fall back to the default set. - ALL_ACCELERATOR_TARGETS=$(echo "${EESSI_ACCELERATOR_TARGETS}" \ - | yq ".\"${{ matrix.EESSI_VERSION }}\".\"${COMPARISON_ARCH}\" // .\"${{ matrix.EESSI_VERSION }}\".default | .[]" \ + ALL_ACCELERATOR_TARGETS=$(yq -p toml -o yaml ".accelerator_targets.\"${{ matrix.EESSI_VERSION }}\".\"${COMPARISON_ARCH}\" // .accelerator_targets.\"${{ matrix.EESSI_VERSION }}\".default | .[]" "${EESSI_TARGETS_FILE}" \ | tr '\n' ' ') echo "COMPARISON_ARCH=${COMPARISON_ARCH}"