Skip to content

Commit e6c2e54

Browse files
WIP: validate rule against schema (#23)
1 parent 7d3e083 commit e6c2e54

2 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""
3+
validate_yaml_schema.py — Validates one or more rule YAML files against the
4+
CDISC CORE JSON Schema (draft/2020-12).
5+
6+
Usage:
7+
python validate_yaml_schema.py <schema_url_or_path> <rule_file> [<rule_file> ...]
8+
9+
Exit codes:
10+
0 — all files are valid
11+
1 — one or more files failed validation or an unexpected error occurred
12+
"""
13+
14+
import json
15+
import sys
16+
import urllib.request
17+
from pathlib import Path
18+
19+
import yaml
20+
21+
try:
22+
import jsonschema
23+
from jsonschema import Draft202012Validator, ValidationError
24+
except ImportError:
25+
print("ERROR: 'jsonschema' package is not installed. Run: pip install 'jsonschema[format-nongpl]'")
26+
sys.exit(1)
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Helpers
31+
# ---------------------------------------------------------------------------
32+
33+
def load_schema(source: str) -> dict:
34+
"""Load JSON schema from a URL or local file path."""
35+
if source.startswith("http://") or source.startswith("https://"):
36+
with urllib.request.urlopen(source, timeout=30) as resp: # noqa: S310
37+
return json.loads(resp.read())
38+
return json.loads(Path(source).read_text(encoding="utf-8"))
39+
40+
41+
def validate_file(path: Path, validator: Draft202012Validator) -> list[str]:
42+
"""
43+
Validate a YAML file against the schema.
44+
Returns a list of human-readable error strings (empty == valid).
45+
"""
46+
try:
47+
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
48+
except yaml.YAMLError as exc:
49+
return [f"YAML parse error: {exc}"]
50+
51+
if doc is None:
52+
return ["File is empty or contains only comments."]
53+
54+
errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path))
55+
return [f" [{' > '.join(str(p) for p in err.path) or '/'}] {err.message}" for err in errors]
56+
57+
58+
def github_annotation(level: str, file: str, msg: str) -> str:
59+
"""Produce a GitHub Actions workflow command annotation."""
60+
# Escape special characters per GHA spec
61+
msg = msg.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
62+
return f"::{level} file={file}::{msg}"
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Main
67+
# ---------------------------------------------------------------------------
68+
69+
def main() -> int:
70+
if len(sys.argv) < 3:
71+
print(f"Usage: {sys.argv[0]} <schema_url_or_path> <rule_file> [<rule_file> ...]")
72+
return 1
73+
74+
schema_source = sys.argv[1]
75+
rule_files = [Path(p) for p in sys.argv[2:]]
76+
77+
# Load schema
78+
print(f"Loading schema from: {schema_source}")
79+
try:
80+
schema = load_schema(schema_source)
81+
except Exception as exc:
82+
print(f"ERROR: Failed to load schema — {exc}")
83+
return 1
84+
85+
validator = Draft202012Validator(schema)
86+
87+
total = 0
88+
failed = 0
89+
90+
report_lines: list[str] = []
91+
92+
for rule_path in rule_files:
93+
if not rule_path.exists():
94+
print(f"WARNING: File not found — {rule_path}")
95+
continue
96+
97+
total += 1
98+
errors = validate_file(rule_path, validator)
99+
100+
if errors:
101+
failed += 1
102+
print(github_annotation("error", str(rule_path), f"Schema validation failed ({len(errors)} error(s))"))
103+
print(f"❌ {rule_path}")
104+
for err in errors:
105+
print(err)
106+
report_lines.append(f"### ❌ `{rule_path}`\n")
107+
report_lines.append("```\n" + "\n".join(errors) + "\n```\n")
108+
else:
109+
print(f"✅ {rule_path}")
110+
report_lines.append(f"### ✅ `{rule_path}`\n")
111+
112+
# Write markdown report (consumed by the workflow)
113+
report_path = Path("schema_validation_report.md")
114+
with report_path.open("w", encoding="utf-8") as fh:
115+
fh.write("# Schema Validation Report\n\n")
116+
fh.write(f"**Schema:** `{schema_source}`\n\n")
117+
fh.write(f"**Files checked:** {total} | **Failed:** {failed}\n\n")
118+
fh.writelines(report_lines)
119+
120+
print(f"\nSummary: {total - failed}/{total} file(s) passed schema validation.")
121+
122+
return 1 if failed else 0
123+
124+
125+
if __name__ == "__main__":
126+
sys.exit(main())
127+
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
name: Validate YAML against Schema
2+
3+
on:
4+
pull_request:
5+
paths:
6+
# - 'Published/**/rule.yml'
7+
- 'Unpublished/**/rule.yml'
8+
types: [opened, synchronize, reopened]
9+
workflow_dispatch:
10+
inputs:
11+
paths_override:
12+
description: >
13+
Space-separated list of rule YAML files to validate
14+
(e.g. "Published/CORE-000001/rule.yml"). Leave blank to validate
15+
all changed rule files detected from the last commit.
16+
required: false
17+
default: ''
18+
19+
# Only one run per PR branch at a time; cancel superseded runs.
20+
concurrency:
21+
group: schema-validate-${{ github.ref }}
22+
cancel-in-progress: true
23+
24+
jobs:
25+
validate-schema:
26+
name: Validate rule YAML against JSON Schema
27+
runs-on: ubuntu-latest
28+
permissions:
29+
contents: read
30+
pull-requests: write
31+
32+
env:
33+
# draft/2020-12 schema — switch to rule-merged if you need $defs inlined
34+
SCHEMA_URL: >-
35+
https://raw.githubusercontent.com/cdisc-org/cdisc-rules-engine/refs/heads/main/resources/schema/rule/CORE-base.json
36+
37+
steps:
38+
# -----------------------------------------------------------------------
39+
# 1. Checkout
40+
# -----------------------------------------------------------------------
41+
- name: Checkout repository
42+
uses: actions/checkout@v6
43+
with:
44+
fetch-depth: 0
45+
46+
# -----------------------------------------------------------------------
47+
# 2. Set up Python
48+
# -----------------------------------------------------------------------
49+
- name: Set up Python 3.12
50+
uses: actions/setup-python@v6
51+
with:
52+
python-version: '3.12'
53+
54+
# -----------------------------------------------------------------------
55+
# 3. Install validation dependencies
56+
# jsonschema 4.x ships full draft/2020-12 support out of the box.
57+
# -----------------------------------------------------------------------
58+
- name: Install Python dependencies
59+
run: |
60+
python -m pip install --upgrade pip
61+
pip install "jsonschema[format-nongpl]>=4.18" "PyYAML>=6.0"
62+
63+
# -----------------------------------------------------------------------
64+
# 4. Detect changed rule YAML files
65+
# -----------------------------------------------------------------------
66+
- name: Detect changed rule files
67+
id: changed
68+
run: |
69+
if [ -n "${{ github.event.inputs.paths_override }}" ]; then
70+
# Manual override
71+
FILES="${{ github.event.inputs.paths_override }}"
72+
elif [ "${{ github.event_name }}" = "pull_request" ]; then
73+
FILES=$(git diff --name-only \
74+
"origin/${{ github.base_ref }}...HEAD" \
75+
-- 'Published/**/rule.yml' 'Unpublished/**/rule.yml' \
76+
| tr '\n' ' ')
77+
else
78+
# workflow_dispatch without override — validate all rule files
79+
FILES=$(find Published Unpublished -name "rule.yml" | tr '\n' ' ')
80+
fi
81+
82+
echo "files=$FILES" >> "$GITHUB_OUTPUT"
83+
echo "Detected rule files: $FILES"
84+
85+
if [ -z "$FILES" ]; then
86+
echo "no_files=true" >> "$GITHUB_OUTPUT"
87+
else
88+
echo "no_files=false" >> "$GITHUB_OUTPUT"
89+
fi
90+
91+
# -----------------------------------------------------------------------
92+
# 5. Run schema validation
93+
# -----------------------------------------------------------------------
94+
- name: Validate YAML against schema
95+
id: validate
96+
if: steps.changed.outputs.no_files == 'false'
97+
continue-on-error: true
98+
run: |
99+
python .github/scripts/validate_yaml_schema.py \
100+
"${{ env.SCHEMA_URL }}" \
101+
${{ steps.changed.outputs.files }}
102+
103+
- name: No rule files changed
104+
if: steps.changed.outputs.no_files == 'true'
105+
run: |
106+
echo "No rule YAML files were changed — nothing to validate."
107+
echo "## Schema Validation" >> "$GITHUB_STEP_SUMMARY"
108+
echo "No \`rule.yml\` files were changed in this PR." >> "$GITHUB_STEP_SUMMARY"
109+
110+
# -----------------------------------------------------------------------
111+
# 6. Upload report artifact
112+
# -----------------------------------------------------------------------
113+
- name: Upload schema validation report
114+
if: always() && steps.changed.outputs.no_files == 'false'
115+
uses: actions/upload-artifact@v7
116+
with:
117+
name: schema-validation-report
118+
path: schema_validation_report.md
119+
if-no-files-found: warn
120+
121+
# -----------------------------------------------------------------------
122+
# 7. Post report as PR comment
123+
# -----------------------------------------------------------------------
124+
- name: Post report to PR
125+
if: >
126+
always() &&
127+
github.event_name == 'pull_request' &&
128+
steps.changed.outputs.no_files == 'false'
129+
uses: actions/github-script@v9
130+
with:
131+
github-token: ${{ secrets.GITHUB_TOKEN }}
132+
script: |
133+
const fs = require('fs');
134+
const reportPath = 'schema_validation_report.md';
135+
const runUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`;
136+
137+
let body = '## Schema Validation Results\n\n';
138+
139+
if (fs.existsSync(reportPath)) {
140+
const content = fs.readFileSync(reportPath, 'utf8');
141+
body += '<details>\n<summary><strong>Click to expand</strong></summary>\n\n';
142+
body += content;
143+
body += '\n</details>\n';
144+
} else {
145+
body += '_No schema validation report was generated._\n';
146+
}
147+
148+
body += `\n[View workflow run](${runUrl})`;
149+
150+
const marker = 'Schema Validation Results';
151+
const { data: comments } = await github.rest.issues.listComments({
152+
owner: context.repo.owner,
153+
repo: context.repo.repo,
154+
issue_number: context.issue.number,
155+
});
156+
157+
const existing = comments.find(
158+
c => c.user.type === 'Bot' && c.body.includes(marker)
159+
);
160+
161+
if (existing) {
162+
await github.rest.issues.updateComment({
163+
owner: context.repo.owner,
164+
repo: context.repo.repo,
165+
comment_id: existing.id,
166+
body,
167+
});
168+
} else {
169+
await github.rest.issues.createComment({
170+
owner: context.repo.owner,
171+
repo: context.repo.repo,
172+
issue_number: context.issue.number,
173+
body,
174+
});
175+
}
176+
177+
# -----------------------------------------------------------------------
178+
# 8. Write report to workflow summary
179+
# -----------------------------------------------------------------------
180+
- name: Write report to workflow summary
181+
if: always() && steps.changed.outputs.no_files == 'false'
182+
run: |
183+
[ -f schema_validation_report.md ] \
184+
&& cat schema_validation_report.md >> "$GITHUB_STEP_SUMMARY" \
185+
|| true
186+
187+
# -----------------------------------------------------------------------
188+
# 9. Fail the job if validation found errors
189+
# -----------------------------------------------------------------------
190+
- name: Check validation outcome
191+
if: steps.validate.outcome == 'failure'
192+
run: |
193+
echo "::error::Schema validation failed — see the report above."
194+
exit 1

0 commit comments

Comments
 (0)