diff --git a/.github/scripts/csv_to_excel_dataset.py b/.github/scripts/csv_to_excel_dataset.py new file mode 100644 index 000000000..355bb40f3 --- /dev/null +++ b/.github/scripts/csv_to_excel_dataset.py @@ -0,0 +1,202 @@ +""" +csv_to_excel_dataset.py + +Converts one CDISC Open Rules test-case `data/` folder (.env, _datasets.csv, +_variables.csv, and one CSV per dataset) into the single-workbook Excel +format required by Verisian's ExcelDataService +(cdisc_rules_engine/services/data_services/excel_data_service.py): + + - Exactly one .xlsx file. + - A "Datasets" sheet with columns Filename, Label. + - One sheet per dataset, sheet name == the Filename value (with .xpt + appended if not already present, since Verisian's engine expects + dataset filenames to carry the .xpt extension). + - Each dataset sheet's first 4 rows (no header row skipped) are: + row 1: variable names + row 2: variable labels + row 3: variable types -- MUST stay exactly "Char"/"Num" as written + in _variables.csv. ExcelDataService reads + these case-sensitively + ({"Char": str, "Num": float, ...}) and + silently falls back to str for anything + that doesn't match, so do NOT lowercase. + row 4: variable lengths + followed by the actual data from row 5 onward. + +Also returns the parsed .env values (PRODUCT, VERSION, SUBSTANDARD, ...) so +the caller can build the `core.py validate` CLI arguments. +""" + +import csv +import os +import re +from collections import defaultdict +from typing import Dict, List, Tuple + +from openpyxl import Workbook + +REQUIRED_FILES = ("_datasets.csv", "_variables.csv") + + +class ConversionError(Exception): + pass + + +def find_env_file(data_dir: str) -> str: + """ + Locate the .env file in a test case's data/ folder. Matches an exact + '.env' filename, but also tolerates a file merely ending in '.env' in + case a differently-named variant ever shows up. + """ + for name in os.listdir(data_dir): + if name == ".env" or name.endswith(".env"): + return os.path.join(data_dir, name) + raise ConversionError(f"No .env file found in {data_dir}") + + +def read_env(path: str) -> Dict[str, str]: + env = {} + with open(path, "r", encoding="utf-8-sig") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip().upper()] = value.strip() + return env + + +def read_csv_rows(path: str) -> List[dict]: + with open(path, "r", encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def read_csv_raw(path: str) -> Tuple[List[str], List[List[str]]]: + with open(path, "r", encoding="utf-8-sig", newline="") as f: + rows = list(csv.reader(f)) + if not rows: + return [], [] + return rows[0], rows[1:] + + +def base_name(filename: str) -> str: + return re.sub(r"\.[A-Za-z0-9]+$", "", filename.strip()).lower() + + +def ensure_xpt_extension(filename: str) -> str: + name = filename.strip() + if not name.lower().endswith(".xpt"): + name = f"{name}.xpt" + return name + + +def to_number_if_possible(value): + if value is None: + return None + v = str(value).strip() + if v == "": + return None + try: + if re.fullmatch(r"[+-]?\d+", v): + return int(v) + return float(v) + except ValueError: + return value + + +def check_required_files(data_dir: str) -> List[str]: + missing = [name for name in REQUIRED_FILES if not os.path.isfile(os.path.join(data_dir, name))] + try: + find_env_file(data_dir) + except ConversionError: + missing.append(".env") + return missing + + +def convert_test_case_to_excel(data_dir: str, output_xlsx_path: str) -> Dict[str, str]: + """ + Converts a single test case's data/ folder into one .xlsx workbook at + output_xlsx_path, matching Verisian's ExcelDataService expectations. + + Returns the parsed .env dict (e.g. {"PRODUCT": "SDTMIG", "VERSION": "3-3"}). + Raises ConversionError on any missing/invalid required input. + """ + missing = check_required_files(data_dir) + if missing: + raise ConversionError(f"missing required file(s) in {data_dir}: {', '.join(missing)}") + + env = read_env(find_env_file(data_dir)) + if "PRODUCT" not in env or "VERSION" not in env: + raise ConversionError(f".env in {data_dir} must define PRODUCT and VERSION") + + dataset_rows = read_csv_rows(os.path.join(data_dir, "_datasets.csv")) + if not dataset_rows: + raise ConversionError(f"_datasets.csv in {data_dir} has no rows") + for row in dataset_rows: + row["Filename"] = ensure_xpt_extension(row["Filename"]) + + variable_rows = read_csv_rows(os.path.join(data_dir, "_variables.csv")) + variables_by_dataset = defaultdict(list) + for row in variable_rows: + variables_by_dataset[row["dataset"].strip().lower()].append(row) + + wb = Workbook() + # Remove the default sheet; we'll add "Datasets" explicitly so it's first. + default_sheet = wb.active + wb.remove(default_sheet) + + ws_ds = wb.create_sheet("Datasets") + ws_ds.append(["Filename", "Label"]) + for row in dataset_rows: + ws_ds.append([row["Filename"], row["Label"]]) + + for row in dataset_rows: + filename = row["Filename"] + base = base_name(filename) + + var_rows = variables_by_dataset.get(base, []) + if not var_rows: + # Fallback: longest dataset-name prefix match (handles split + # datasets, e.g. variables listed under "ec" but files "ecaa"/"ecbb") + candidates = [k for k in variables_by_dataset if base.startswith(k)] + if candidates: + var_rows = variables_by_dataset[max(candidates, key=len)] + if not var_rows: + raise ConversionError(f"No variable metadata in _variables.csv for dataset '{filename}' in {data_dir}") + + sheet_name = filename[:31] + ws = wb.create_sheet(sheet_name) + + var_names = [v["variable"] for v in var_rows] + var_labels = [v["label"] for v in var_rows] + # IMPORTANT: keep type exactly as written (e.g. "Char"/"Num") — + # ExcelDataService matches these case-sensitively. + var_types = [v["type"].strip() for v in var_rows] + var_lengths = [to_number_if_possible(v["length"]) for v in var_rows] + + ws.append(var_names) + ws.append(var_labels) + ws.append(var_types) + ws.append(var_lengths) + + src_path = os.path.join(data_dir, f"{base}.csv") + if not os.path.isfile(src_path): + raise ConversionError(f"No source data CSV found for '{filename}' (expected '{base}.csv') in {data_dir}") + + header, data_rows = read_csv_raw(src_path) + header_index = {name: i for i, name in enumerate(header)} + type_by_var = {v["variable"]: v["type"].strip() for v in var_rows} + + for data_row in data_rows: + out_row = [] + for col in var_names: + idx = header_index.get(col) + raw_val = data_row[idx] if idx is not None and idx < len(data_row) else "" + if type_by_var.get(col) == "Num": + out_row.append(to_number_if_possible(raw_val)) + else: + out_row.append(raw_val if raw_val != "" else None) + ws.append(out_row) + + wb.save(output_xlsx_path) + return env diff --git a/.github/scripts/rule_filter.py b/.github/scripts/rule_filter.py new file mode 100644 index 000000000..050cec50f --- /dev/null +++ b/.github/scripts/rule_filter.py @@ -0,0 +1,70 @@ +""" +rule_filter.py + +Determines whether a rule.yml qualifies for the Verisian validation run, +based on its `Authorities` block. + +We only want to run rules that apply to at least one of: + Authorities: + - Organization: CDISC + Standards: + - Name: SDTMIG + - Organization: FDA + Standards: + - Name: SDTMIG + +Any other combination (other organizations, other standard names only) is +excluded. +""" + +from pathlib import Path +from typing import Iterable + +import yaml + +ALLOWED_ORG_STANDARD_PAIRS = { + ("CDISC", "SDTMIG"), + ("FDA", "SDTMIG"), +} + + +def load_rule(rule_yml_path: Path) -> dict: + with open(rule_yml_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def rule_matches_authorities(rule: dict) -> bool: + """ + True if any (Organization, Standard Name) pair in the rule's Authorities + block is in ALLOWED_ORG_STANDARD_PAIRS. + """ + authorities = rule.get("Authorities") or [] + for authority in authorities: + org = authority.get("Organization") + standards = authority.get("Standards") or [] + for standard in standards: + name = standard.get("Name") + if (org, name) in ALLOWED_ORG_STANDARD_PAIRS: + return True + return False + + +def rule_file_matches(rule_yml_path: Path) -> bool: + try: + rule = load_rule(rule_yml_path) + except Exception: + # Unparseable rule.yml — treat as non-matching rather than crashing + # the whole run; the caller can log this separately if desired. + return False + return rule_matches_authorities(rule) + + +def find_matching_rule_dirs(published_root: Path) -> Iterable[Path]: + """ + Yields the directory of every rule under `published_root` (e.g. + open-rules/Published) whose rule.yml matches the Authorities filter. + """ + for rule_dir in sorted(p for p in published_root.iterdir() if p.is_dir()): + rule_yml = rule_dir / "rule.yml" + if rule_yml.is_file() and rule_file_matches(rule_yml): + yield rule_dir diff --git a/.github/scripts/validate_verisian_rules.py b/.github/scripts/validate_verisian_rules.py new file mode 100644 index 000000000..0ebf53763 --- /dev/null +++ b/.github/scripts/validate_verisian_rules.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +validate_verisian_rules.py + +Runs the Verisian fork of the CDISC Rules Engine against every applicable +rule + test case in cdisc-open-rules' Published/ folder, and compares the +actual output against the committed results.csv baselines. + +Only rules whose `Authorities` block includes at least one of: + Organization: CDISC, Standard: SDTMIG + Organization: FDA, Standard: SDTMIG +are run (see lib/rule_filter.py). + +For each qualifying rule's positive/negative test cases: + 1. Convert the CSV+.env fixture into the single-xlsx dataset format + Verisian's engine expects (lib/csv_to_excel_dataset.py). + 2. Run `core.py validate` against that Excel file with the rule loaded + via -lr, output format JSON. + 3. Convert the JSON output's Issue_Details into the same + Dataset,Record,Variable,Value CSV schema as the committed + results.csv (lib/verisian_report.py), and diff the two. + +Writes: + /summary_table.md - one row per rule + /detail_report.md - full diff detail for every non-passing case + /actual_results/... - the converted actual.csv for every case run (artifact) + +Exits non-zero if any case FAILed or ERRORed. +""" + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + +from tabulate import tabulate + + +from rule_filter import find_matching_rule_dirs # noqa: E402 +from csv_to_excel_dataset import convert_test_case_to_excel, ConversionError # noqa: E402 +from verisian_report import ( # noqa: E402 + load_actual_json, + issue_details_to_rows, + write_rows_csv, + read_rows_csv, + diff_rows, +) + +TEST_TYPES = ("positive", "negative") + + +def get_test_cases(rule_dir: Path): + """Yields (test_type, case_dir) for every case that has a data/ folder.""" + for test_type in TEST_TYPES: + type_dir = rule_dir / test_type + if not type_dir.is_dir(): + continue + for case_dir in sorted(p for p in type_dir.iterdir() if p.is_dir()): + if (case_dir / "data").is_dir(): + yield test_type, case_dir + + +def run_engine_validate( + python_cmd: str, + engine_dir: Path, + rule_yml: Path, + dataset_xlsx: Path, + output_path: Path, + env: dict, +) -> tuple[bool, str]: + if "PRODUCT" not in env or "VERSION" not in env: + return False, ".env missing PRODUCT and/or VERSION" + + cmd = [ + python_cmd, + "core.py", + "validate", + "-s", + env["PRODUCT"].lower(), + "-v", + env["VERSION"], + "-dp", + str(dataset_xlsx.resolve()), + "-lr", + str(rule_yml.resolve()), + "-of", + "JSON", + "-o", + str(output_path.resolve()), + "-p", + "disabled", + "-l", + "disabled", + ] + if env.get("SUBSTANDARD"): + cmd += ["-ss", env["SUBSTANDARD"]] + + try: + result = subprocess.run( + cmd, + cwd=str(engine_dir), + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + return False, "engine validate call timed out after 300s" + except Exception as e: + return False, f"failed to invoke engine: {e}" + + json_path = Path(str(output_path) + ".json") + if result.returncode != 0 or not json_path.is_file(): + tail = (result.stdout or "") + "\n" + (result.stderr or "") + return False, tail.strip()[-4000:] + return True, "" + + +def run_case(python_cmd: str, engine_dir: Path, rule_yml: Path, case_dir: Path, actual_out_dir: Path) -> dict: + """ + Runs one test case end-to-end. Returns a result dict with keys: + status: PASS | FAIL | ERROR | SKIPPED + message: human-readable detail (empty for PASS) + diff: dict from diff_rows(), only present for FAIL + """ + data_dir = case_dir / "data" + expected_csv = case_dir / "results" / "results.csv" + + if not expected_csv.is_file(): + return {"status": "SKIPPED", "message": f"no expected results.csv at {expected_csv}"} + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + dataset_xlsx = tmp_path / "dataset.xlsx" + try: + env = convert_test_case_to_excel(str(data_dir), str(dataset_xlsx)) + except ConversionError as e: + return {"status": "ERROR", "message": f"preprocessing failed: {e}"} + + output_path = tmp_path / "actual" + ok, message = run_engine_validate(python_cmd, engine_dir, rule_yml, dataset_xlsx, output_path, env) + if not ok: + return {"status": "ERROR", "message": f"engine run failed: {message}"} + + try: + report = load_actual_json(str(output_path) + ".json") + actual_rows = issue_details_to_rows(report) + except Exception as e: + return {"status": "ERROR", "message": f"could not parse engine JSON output: {e}"} + + actual_out_dir.mkdir(parents=True, exist_ok=True) + write_rows_csv(actual_rows, str(actual_out_dir / "actual.csv")) + + expected_rows = read_rows_csv(str(expected_csv)) + diff = diff_rows(expected_rows, actual_rows) + if diff["match"]: + return {"status": "PASS", "message": ""} + return {"status": "FAIL", "message": "actual output does not match expected results.csv", "diff": diff} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rules-root", required=True, help="Path to cdisc-open-rules checkout") + parser.add_argument("--engine-dir", required=True, help="Path to verisianHQ/cdisc-rules-engine checkout") + parser.add_argument("--python-cmd", required=True, help="Python executable to run core.py with") + parser.add_argument("--output-dir", required=True, help="Where to write reports and actual results") + parser.add_argument( + "--core-ids", + nargs="*", + default=None, + help="Restrict to these rule IDs (space-separated). Still subject to the Authorities filter.", + ) + args = parser.parse_args() + + rules_root = Path(args.rules_root) + engine_dir = Path(args.engine_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + published_root = rules_root / "Published" + matching_dirs = list(find_matching_rule_dirs(published_root)) + if args.core_ids: + wanted = set(args.core_ids) + matching_dirs = [d for d in matching_dirs if d.name in wanted] + + summary_rows = [] + detail_sections = [] + any_failure = False + + for rule_dir in matching_dirs: + rule_id = rule_dir.name + rule_yml = rule_dir / "rule.yml" + + case_results = [] + for test_type, case_dir in get_test_cases(rule_dir): + case_label = f"{test_type}/{case_dir.name}" + actual_out_dir = output_dir / "actual_results" / rule_id / test_type / case_dir.name + result = run_case(args.python_cmd, engine_dir, rule_yml, case_dir, actual_out_dir) + case_results.append((case_label, result)) + + if result["status"] in ("FAIL", "ERROR"): + any_failure = True + detail = [f"### {rule_id} — {case_label} — {result['status']}", "", result["message"]] + if "diff" in result: + diff = result["diff"] + if diff["missing_from_actual"]: + detail.append("\n**Expected but missing from actual output:**") + detail += [f"- {row}" for row in diff["missing_from_actual"]] + if diff["unexpected_in_actual"]: + detail.append("\n**Present in actual output but not expected:**") + detail += [f"- {row}" for row in diff["unexpected_in_actual"]] + detail_sections.append("\n".join(detail)) + + if not case_results: + status = "NO TEST CASES" + elif all(r["status"] == "PASS" for _, r in case_results): + status = "PASS" + elif any(r["status"] == "ERROR" for _, r in case_results): + status = "ERROR" + else: + status = "FAIL" + + passed = sum(1 for _, r in case_results if r["status"] == "PASS") + summary_rows.append([rule_id, status, f"{passed}/{len(case_results)}"]) + + summary_table = tabulate(summary_rows, headers=["Core ID", "Status", "Cases Passed"], tablefmt="github") + (output_dir / "summary_table.md").write_text( + f"# Verisian Engine Validation Summary\n\n" + f"Rules evaluated (matching CDISC/FDA SDTMIG Authorities filter): {len(matching_dirs)}\n\n" + f"{summary_table}\n", + encoding="utf-8", + ) + + detail_report = "\n\n---\n\n".join(detail_sections) if detail_sections else "All cases passed — no details to show." + (output_dir / "detail_report.md").write_text( + f"# Verisian Engine Validation — Detail Report\n\n{detail_report}\n", + encoding="utf-8", + ) + + print(summary_table) + sys.exit(1 if any_failure else 0) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/verisian_report.py b/.github/scripts/verisian_report.py new file mode 100644 index 000000000..9d4b73819 --- /dev/null +++ b/.github/scripts/verisian_report.py @@ -0,0 +1,76 @@ +""" +verisian_report.py + +Converts Verisian engine JSON output (-of JSON) into the same +`Dataset,Record,Variable,Value` CSV schema used by cdisc-open-rules' +committed results.csv baselines, and compares the two. + +Verisian's JSON "Issue_Details" entries look like (from +cdisc_rules_engine/services/reporting/base_report.py): + + { + "core_id": "CORE-000001", + "message": "...", + "executability": "...", + "dataset": "IE", + "USUBJID": "...", + "row": 1, + "SEQ": "...", + "variables": ["IECAT", "IEORRES"], + "values": ["INCLUSION", "Y"], + } + +Each (variable, value) pair inside one Issue_Details entry becomes one row +of Dataset,Record,Variable,Value — "row" maps to "Record". +""" + +import csv +import json +import re +from typing import List, Tuple + +Row = Tuple[str, str, str, str] # (Dataset, Record, Variable, Value) + + +def load_actual_json(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def issue_details_to_rows(report: dict) -> List[Row]: + rows: List[Row] = [] + for item in report.get("Issue_Details", []) or []: + dataset = re.sub(r"\.[A-Za-z0-9]+$", "", str(item.get("dataset", ""))).upper() + record = item.get("row", "") + variables = item.get("variables", []) or [] + values = item.get("values", []) or [] + for variable, value in zip(variables, values): + value_str = "" if str(value) == "null" else str(value) + rows.append((dataset, str(record), str(variable), value_str)) + return sorted(rows) + + +def write_rows_csv(rows: List[Row], path: str) -> None: + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["Dataset", "Record", "Variable", "Value"]) + writer.writerows(rows) + + +def read_rows_csv(path: str) -> List[Row]: + with open(path, "r", newline="", encoding="utf-8-sig") as f: + reader = csv.reader(f) + rows = list(reader) + if not rows: + return [] + return sorted(tuple(r) for r in rows[1:] if r) + + +def diff_rows(expected: List[Row], actual: List[Row]) -> dict: + expected_set = set(expected) + actual_set = set(actual) + return { + "match": expected_set == actual_set, + "missing_from_actual": sorted(expected_set - actual_set), + "unexpected_in_actual": sorted(actual_set - expected_set), + } diff --git a/.github/workflows/validate-verisian-rules.yml b/.github/workflows/validate-verisian-rules.yml new file mode 100644 index 000000000..e8b748cb3 --- /dev/null +++ b/.github/workflows/validate-verisian-rules.yml @@ -0,0 +1,151 @@ +# ============================================================================== +# This workflow: +# 1. Checks out this repo (cdisc-open-rules) — provides Published/ rules + test data +# 2. Checks out verisianHQ/cdisc-rules-engine (the Verisian engine fork) +# 3. Installs the Verisian engine's Python dependencies +# 4. Filters Published/ rules down to those whose Authorities include +# CDISC/SDTMIG or FDA/SDTMIG +# 5. For each qualifying rule's test cases: +# - converts the CSV/.env fixture data into the single-xlsx dataset +# format the Verisian engine's ExcelDataService expects +# - runs the Verisian engine's `core.py validate` against it +# - converts the JSON output into the same Dataset,Record,Variable,Value +# schema as the committed results.csv, and diffs the two +# 6. Publishes a Markdown report to the Job Summary and as an artifact +# ============================================================================== +name: Validate Verisian Engine Against Published Rules + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + inputs: + core_ids: + description: >- + Space-separated list of rule IDs to validate + (e.g. CORE-000001 CORE-000002). Leave blank to validate all + rules matching the CDISC/FDA SDTMIG Authorities filter. + required: false + default: "" + +jobs: + validate-verisian-rules: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # ----------------------------------------------------------------------- + # 1. Checkout this repo (cdisc-open-rules) — Published/ rules + test data + # and .github/scripts/ (the preprocessing/orchestration scripts below) + # ----------------------------------------------------------------------- + - name: Checkout cdisc-open-rules + uses: actions/checkout@v6 + with: + path: open-rules + + # ----------------------------------------------------------------------- + # 2. Checkout the Verisian engine fork + # ----------------------------------------------------------------------- + - name: Checkout verisianHQ/cdisc-rules-engine + uses: actions/checkout@v6 + with: + repository: verisianHQ/cdisc-rules-engine + path: engine + token: ${{ secrets.GITHUB_TOKEN }} + + # ----------------------------------------------------------------------- + # 2b. Debug — verify directory layout + # ----------------------------------------------------------------------- + - name: Debug — list workspace layout + run: | + echo "=== Workspace root ===" + ls -la + echo "=== open-rules/Published/ (first 10) ===" + ls open-rules/Published/ 2>/dev/null | head -10 || echo "Published/ NOT FOUND" + echo "=== engine/ ===" + ls engine/ | head -10 || echo "engine/ NOT FOUND" + + # ----------------------------------------------------------------------- + # 3. Set up Python + # ----------------------------------------------------------------------- + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + # ----------------------------------------------------------------------- + # 4. Install engine dependencies + # + # NOTE: the engine's pyproject.toml pins numpy==1.23.2, which predates + # Python 3.12 and has no prebuilt wheel for it — pip will try to build + # it from source, which can fail on newer setuptools + # (pkgutil.ImpImporter was removed in Python 3.12's own stdlib, which + # numpy's legacy build backend still expects). If this step fails here, + # that dependency pin needs attention upstream in the Verisian repo; + # pinning an older setuptools locally will NOT fix it, since the + # missing attribute is gone from Python 3.12 itself, not from + # setuptools. + # ----------------------------------------------------------------------- + - name: Install engine dependencies + run: | + python -m venv venv + venv/bin/pip install --upgrade pip + cd engine + ../venv/bin/pip install -r requirements.txt + cd .. + venv/bin/pip install tabulate pyyaml openpyxl + + # ----------------------------------------------------------------------- + # 5. Run validation for every qualifying Published rule + # ----------------------------------------------------------------------- + - name: Run validation against Verisian engine + id: validate + continue-on-error: true + run: | + CORE_IDS_ARG="" + if [ -n "${{ inputs.core_ids }}" ]; then + CORE_IDS_ARG="--core-ids ${{ inputs.core_ids }}" + fi + + venv/bin/python open-rules/.github/scripts/validate_verisian_rules.py \ + --rules-root "$(pwd)/open-rules" \ + --engine-dir "$(pwd)/engine" \ + --python-cmd "$(pwd)/venv/bin/python" \ + --output-dir "$(pwd)/output" \ + $CORE_IDS_ARG + + # ----------------------------------------------------------------------- + # 6. Upload reports + actual results as artifacts + # ----------------------------------------------------------------------- + - name: Upload validation artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: verisian-rules-validation-${{ github.run_id }} + path: | + output/summary_table.md + output/detail_report.md + output/actual_results/** + if-no-files-found: warn + + # ----------------------------------------------------------------------- + # 7. Write ONLY the summary table to GitHub Actions Job Summary + # ----------------------------------------------------------------------- + - name: Write summary table to workflow summary + if: always() + run: | + [ -f output/summary_table.md ] && cat output/summary_table.md >> $GITHUB_STEP_SUMMARY || true + + # ----------------------------------------------------------------------- + # 8. Fail the job if any rule failed + # ----------------------------------------------------------------------- + - name: Check overall status + if: steps.validate.outcome == 'failure' + run: | + echo "One or more rules failed validation against the Verisian engine — see the artifacts for detail_report.md." + exit 1