Writing a GitHub Actions Workflow for DCAT Validation
Writing a GitHub Actions workflow for DCAT validation means adding a pull_request-triggered job that parses each RDF catalog file with rdflib, validates it against a repository-stored DCAT-AP SHACL shapes graph using pyshacl, and blocks the merge whenever conforms is false. The result is a deterministic, offline gate that rejects non-compliant catalog metadata before it is published.
DCAT-AP is the application profile that governs how datasets are described in open data portals across the EU and, increasingly, North America; its spatial extension adds constraints on bounding geometry, reference systems, and licensing. Validating those constraints is a natural CI check, and it pairs with the Pre-commit Hooks for Spatial Metadata guide — the pre-commit hooks catch structural problems in the data files, while this workflow catches semantic problems in the catalog records that describe them. Both sit inside the CI/CD Validation & Policy Enforcement for Spatial Data framework. For how the DCAT-AP fields themselves are produced and mapped, see DCAT-AP spatial profile mapping.
Automated Python Implementation
The workflow triggers on pull requests that touch RDF catalog files or the SHACL shapes, installs pinned versions of rdflib and pyshacl, and runs a single validation script. Storing the shapes graph in the repository keeps validation deterministic and free of network calls at run time.
# .github/workflows/dcat-validate.yml
name: DCAT-AP SHACL Validation
on:
pull_request:
paths:
- 'catalog/**/*.ttl'
- 'catalog/**/*.rdf'
- 'catalog/**/*.jsonld'
- 'shapes/dcat-ap-shacl.ttl'
- 'scripts/validate_dcat.py'
jobs:
dcat-validate:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install RDF validation dependencies
run: pip install "rdflib==7.0.0" "pyshacl==0.26.0"
- name: Run DCAT-AP SHACL validation
run: |
python scripts/validate_dcat.py \
--shapes shapes/dcat-ap-shacl.ttl \
--catalog-dir catalog \
--report reports/dcat_validation.txt
- name: Upload validation report
if: always()
uses: actions/upload-artifact@v4
with:
name: dcat-validation-report
path: reports/dcat_validation.txt
retention-days: 14
The runner walks the catalog directory, parses each graph, and validates it against the shapes graph. pyshacl.validate returns a (conforms, results_graph, results_text) tuple; the script aggregates the text reports and exits non-zero if any file fails, which GitHub Actions interprets as a failed required check.
#!/usr/bin/env python3
"""scripts/validate_dcat.py — validate DCAT-AP catalog RDF against SHACL shapes.
Usage:
python scripts/validate_dcat.py \
--shapes shapes/dcat-ap-shacl.ttl \
--catalog-dir catalog \
--report reports/dcat_validation.txt
Exits 1 if any catalog graph fails SHACL validation.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from pyshacl import validate
from rdflib import Graph
# Map file suffix to the rdflib parser format
FORMAT_BY_SUFFIX = {
".ttl": "turtle",
".rdf": "xml",
".xml": "xml",
".jsonld": "json-ld",
".nt": "nt",
}
def parse_format(path: Path) -> str:
fmt = FORMAT_BY_SUFFIX.get(path.suffix.lower())
if fmt is None:
raise ValueError(f"{path}: unsupported RDF serialisation '{path.suffix}'")
return fmt
def validate_catalog(data_path: Path, shapes_graph: Graph) -> tuple[bool, str]:
"""Validate a single catalog file. Returns (conforms, human-readable report)."""
data_graph = Graph()
try:
data_graph.parse(str(data_path), format=parse_format(data_path))
except Exception as exc: # noqa: BLE001
return False, f"{data_path}: RDF parse error — {exc}"
conforms, _results_graph, results_text = validate(
data_graph,
shacl_graph=shapes_graph,
inference="rdfs",
abort_on_first=False,
meta_shacl=False,
advanced=True,
)
header = f"{data_path}: {'CONFORMS' if conforms else 'VIOLATIONS'}"
return conforms, f"{header}\n{results_text.strip()}"
def main() -> int:
parser = argparse.ArgumentParser(description="DCAT-AP SHACL validator")
parser.add_argument("--shapes", required=True, type=Path)
parser.add_argument("--catalog-dir", required=True, type=Path)
parser.add_argument("--report", type=Path, default=Path("reports/dcat_validation.txt"))
args = parser.parse_args()
if not args.shapes.exists():
print(f"ERROR: shapes graph not found: {args.shapes}", file=sys.stderr)
return 1
shapes_graph = Graph()
shapes_graph.parse(str(args.shapes), format="turtle")
catalog_files = sorted(
p for p in args.catalog_dir.rglob("*")
if p.suffix.lower() in FORMAT_BY_SUFFIX
)
if not catalog_files:
print("No catalog RDF files found — skipping validation.")
return 0
reports: list[str] = []
failed = 0
for path in catalog_files:
conforms, report = validate_catalog(path, shapes_graph)
reports.append(report)
if not conforms:
failed += 1
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text("\n\n".join(reports))
print("\n\n".join(reports))
summary = f"\n{failed} of {len(catalog_files)} catalog file(s) failed DCAT-AP validation."
print(summary)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
The inference="rdfs" setting lets pyshacl apply RDFS entailment before validation, so shapes that target dcat:Dataset still match a node typed only as a subclass. advanced=True enables SHACL-AF features such as sh:SPARQLConstraint, which the DCAT-AP shapes use for cross-property rules like requiring dct:spatial when a dct:Location bounding box is present. Setting abort_on_first=False means one run reports every violation, not just the first.
Validation & pipeline integration
Run the validator locally against the same pinned versions the workflow uses before opening a pull request:
# Match the workflow's pinned dependency versions
pip install "rdflib==7.0.0" "pyshacl==0.26.0"
# Validate the whole catalog directory
python scripts/validate_dcat.py \
--shapes shapes/dcat-ap-shacl.ttl \
--catalog-dir catalog \
--report reports/dcat_validation.txt
# Validate a single record while debugging a specific failure
python -c "from rdflib import Graph; g = Graph(); g.parse('catalog/roads.ttl', format='turtle'); print(len(g))"
Lock the behaviour behind a pytest regression so a shapes edit that silently stops catching a violation is itself caught:
# tests/test_dcat_validation.py
from pathlib import Path
from rdflib import Graph
from scripts.validate_dcat import validate_catalog
SHAPES = Graph().parse("shapes/dcat-ap-shacl.ttl", format="turtle")
def test_missing_license_fails(tmp_path: Path):
ttl = """
@prefix dcat: <http://www.w3.org/ns/dcat#> .
@prefix dct: <http://purl.org/dc/terms/> .
<http://example.org/ds/1> a dcat:Dataset ;
dct:title "Roads" .
"""
path = tmp_path / "no_license.ttl"
path.write_text(ttl)
conforms, report = validate_catalog(path, SHAPES)
assert conforms is False
assert "VIOLATIONS" in report
def test_complete_record_conforms(tmp_path: Path):
ttl = """
@prefix dcat: <http://www.w3.org/ns/dcat#> .
@prefix dct: <http://purl.org/dc/terms/> .
<http://example.org/ds/2> a dcat:Dataset ;
dct:title "Parcels" ;
dct:description "Cadastral parcels for the district." ;
dct:license <http://publications.europa.eu/resource/authority/licence/CC_BY_4_0> .
"""
path = tmp_path / "ok.ttl"
path.write_text(ttl)
conforms, _report = validate_catalog(path, SHAPES)
assert conforms is True
Once the job is green, promote dcat-validate to a required status check in Settings → Branches → Branch protection rules so it wires into the policy enforcement gates for data PRs and blocks non-conforming catalog records at the SCM layer.
Long-term compliance best practices
- Vendor the SHACL shapes into the repository. Fetching shapes from a remote URL at run time makes validation non-deterministic and network-dependent; a committed
shapes/dcat-ap-shacl.ttlguarantees every run validates against the exact profile revision you reviewed. - Pin
rdflibandpyshaclto exact versions. Both libraries evolve their SHACL support between releases; an unpinned upgrade can change which violations are reported, turning a passing branch red for no data reason. - Separate parse errors from conformance failures. A malformed Turtle file and a well-formed but non-conforming record are different defects with different fixes — the runner reports them distinctly so contributors know whether to fix syntax or content.
- Keep RDFS inference on only when your shapes need it. Inference costs time and can mask missing explicit types; enable it deliberately and document why, rather than leaving it on by habit.
- Publish the validation report as a build artifact. Uploading
reports/dcat_validation.txton every run — including passes — gives auditors a durable record of which catalog records conformed at merge time. - Align the shapes with your field mapping. When the DCAT-AP spatial profile mapping that generates the records changes, update the shapes in the same pull request so producer and validator never drift apart.
Related
- Pre-commit Hooks for Spatial Metadata — the parent guide covering local and CI hooks for spatial metadata files
- DCAT-AP Spatial Profile Mapping — how the DCAT-AP catalog records validated here are generated and field-mapped
- Policy Enforcement Gates for Data PRs — promote this workflow into a branch-protection required check
- Spatial Data Schema Linting in CI — the structural linting gate that runs alongside this semantic catalog check