Setting up GitHub Actions for ISO 19115 validation
Setting up GitHub Actions for ISO 19115 validation automates structural and semantic compliance checks for geospatial metadata before it reaches production — every pull request that touches an XML file triggers an XSD parse against the ISO 19115-1:2014 schema and blocks the merge if any document fails.
This page covers the concrete implementation: workflow YAML, Python validation script, XSD caching strategy, and branch protection wiring. It is a practical companion to Spatial Data Schema Linting in CI, which describes the broader linting architecture, and to the CI/CD Validation & Policy Enforcement for Spatial Data framework that governs when these gates block a merge versus emit a warning.
Architecture and dependencies
The implementation uses a Python runner with lxml for fast, standards-compliant XSD validation. Unlike pure-Python XML parsers, lxml leverages the libxml2 C backend to handle complex namespace resolution, schema imports, and large metadata files without memory pressure. XSD files are downloaded once and cached via actions/cache@v4; subsequent runs skip the network entirely, keeping job duration under 90 seconds even for repositories with hundreds of metadata records.
The diagram below shows the end-to-end flow from a developer push through to merge gate:
Step 1 — Configure the GitHub Actions workflow
Create .github/workflows/iso19115-validate.yml. The paths filters prevent the job from running on unrelated changes, keeping CI minutes focused on metadata-touching commits. The actions/cache@v4 step stores the XSD directory keyed to the schema version and runner OS; the download step runs only on a cache miss.
name: ISO 19115 Metadata Validation
on:
push:
paths:
- '**/*.xml'
- 'scripts/validate_iso19115.py'
- '.github/workflows/iso19115-validate.yml'
pull_request:
paths:
- '**/*.xml'
- 'scripts/validate_iso19115.py'
jobs:
validate-metadata:
runs-on: ubuntu-latest
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 dependencies
run: pip install lxml==5.2.2
- name: Cache ISO 19115 XSD schemas
id: cache-xsd
uses: actions/cache@v4
with:
path: .cache/schemas
key: iso19115-xsd-v2-${{ runner.os }}
- name: Download schemas (cache miss only)
if: steps.cache-xsd.outputs.cache-hit != 'true'
run: |
mkdir -p .cache/schemas
# Root schema — ISO 19115-1:2014
curl -fsSL https://standards.iso.org/ittf/PubliclyAvailableStandards/c063242_ISO_19115-1_2014_Schemas/19115-1_2014.xsd \
-o .cache/schemas/iso19115-1.xsd
# Common object types referenced via xs:import
curl -fsSL https://standards.iso.org/ittf/PubliclyAvailableStandards/c063242_ISO_19115-1_2014_Schemas/gco.xsd \
-o .cache/schemas/gco.xsd
curl -fsSL https://standards.iso.org/ittf/PubliclyAvailableStandards/c063242_ISO_19115-1_2014_Schemas/gmd.xsd \
-o .cache/schemas/gmd.xsd
- name: Run ISO 19115 validation
run: |
python scripts/validate_iso19115.py \
--schema-dir .cache/schemas \
--metadata-dir ./metadata
Step 2 — Implement the Python XSD validation script
Save this as scripts/validate_iso19115.py. The script recursively scans a target directory for .xml files, instantiates an etree.XMLSchema object from the cached root XSD, and validates each document. It collects every error from the schema error log before printing, so a single run surfaces all failures rather than aborting on the first. The process exits with status 1 on any failure; GitHub Actions interprets a non-zero exit as a failed required check.
#!/usr/bin/env python3
"""ISO 19115-1:2014 XSD validator for CI pipelines.
Usage:
python scripts/validate_iso19115.py \
--schema-dir .cache/schemas \
--metadata-dir ./metadata
"""
import argparse
import sys
from pathlib import Path
from lxml import etree
def load_schema(schema_dir: Path) -> etree.XMLSchema | None:
"""Parse and compile the root XSD. Returns None and prints on failure."""
schema_file = schema_dir / "iso19115-1.xsd"
if not schema_file.exists():
print(f"ERROR: schema not found at {schema_file}")
return None
try:
schema_doc = etree.parse(str(schema_file))
return etree.XMLSchema(schema_doc)
except etree.XMLSchemaParseError as exc:
print(f"ERROR: failed to compile schema: {exc}")
return None
def validate_file(xml_path: Path, schema: etree.XMLSchema) -> list[str]:
"""Validate a single XML metadata file. Returns a list of error strings."""
errors: list[str] = []
try:
xml_doc = etree.parse(str(xml_path))
if not schema.validate(xml_doc):
for entry in schema.error_log:
errors.append(f" line {entry.line}: {entry.message}")
except etree.XMLSyntaxError as exc:
errors.append(f" malformed XML: {exc}")
except Exception as exc: # noqa: BLE001
errors.append(f" unexpected error: {exc}")
return errors
def main() -> None:
parser = argparse.ArgumentParser(
description="Validate ISO 19115-1:2014 XML metadata files."
)
parser.add_argument("--schema-dir", required=True, type=Path,
help="Directory containing the cached XSD files.")
parser.add_argument("--metadata-dir", required=True, type=Path,
help="Root directory to scan for *.xml files.")
args = parser.parse_args()
if not args.metadata_dir.is_dir():
print(f"ERROR: metadata directory not found: {args.metadata_dir}")
sys.exit(1)
schema = load_schema(args.schema_dir)
if schema is None:
sys.exit(1)
xml_files = sorted(args.metadata_dir.rglob("*.xml"))
if not xml_files:
print("No XML files found — skipping validation.")
sys.exit(0)
failed: list[tuple[Path, list[str]]] = []
for xml_file in xml_files:
file_errors = validate_file(xml_file, schema)
if file_errors:
failed.append((xml_file, file_errors))
if failed:
print(f"\nValidation failed for {len(failed)} of {len(xml_files)} file(s):\n")
for xml_file, file_errors in failed:
print(f" {xml_file.relative_to(args.metadata_dir)}")
for err in file_errors:
print(err)
sys.exit(1)
print(f"All {len(xml_files)} metadata file(s) passed ISO 19115-1:2014 validation.")
if __name__ == "__main__":
main()
Validation and pipeline integration
Run the script locally against a known-good sample before committing the workflow:
# Install the same lxml version the workflow pins
pip install lxml==5.2.2
# Validate against local metadata
python scripts/validate_iso19115.py \
--schema-dir .cache/schemas \
--metadata-dir ./metadata
Add a pytest harness to lock in regression coverage:
# tests/test_iso19115_validator.py (excerpt)
# pytest discovers this automatically
from pathlib import Path
from scripts.validate_iso19115 import load_schema, validate_file
SCHEMA_DIR = Path(".cache/schemas")
FIXTURES = Path("tests/fixtures")
def test_valid_record_passes():
schema = load_schema(SCHEMA_DIR)
assert schema is not None
errors = validate_file(FIXTURES / "valid_iso19115.xml", schema)
assert errors == []
def test_missing_mandatory_field_fails():
schema = load_schema(SCHEMA_DIR)
assert schema is not None
errors = validate_file(FIXTURES / "missing_fileidentifier.xml", schema)
assert len(errors) > 0
Once the job runs cleanly, promote it to a required status check via Settings → Branches → Branch protection rules → Require status checks to pass before merging and add validate-metadata. This converts the technical check into an enforced policy enforcement gate for data PRs that blocks non-compliant merges at the SCM layer rather than in a downstream review step.
Long-term compliance best practices
- Pin both tool and schema versions. Use
lxml==5.2.2in your workflow and name cache keys with a schema-version suffix (e.g.iso19115-xsd-v2-...). When the ISO updates its published XSD, bump the key and re-download deliberately rather than picking up silent changes on the next cache miss. - Cache all XSD dependencies, not just the root. ISO 19115-1:2014 references
gco.xsd,gmd.xsd, andgmx.xsdvia relativexs:import. Missing any sibling file causes aXMLSchemaParseErrorat schema compile time, not at validation time, making the failure look like a tooling problem rather than a missing artifact. - Separate structural from semantic checks. XSD validation confirms that mandatory elements are present and correctly typed. Business rules — verifying that
gmd:referenceSystemInfocontains an approved EPSG code, or thatgmd:CI_ResponsiblePartyholds a live email address — belong in a separate Python function that runs after XSD passes, so failures are categorised by root cause. - Namespace alignment is non-negotiable. ISO 19115-1:2014 uses
http://standards.iso.org/iso/19115/-1/ed-1/uom/while the older ISO 19115:2003 GML implementation usedhttp://www.isotc211.org/2005/gmd. Mixing namespaces across files in the same batch causes subtle failures that look like data errors. Add a namespace assertion to the script’s pre-validation step to catch this early. - Surface errors in SARIF for GitHub Code Scanning. The Python script can emit a SARIF 2.1.0 JSON file that GitHub renders as inline file annotations. This gives reviewers exact line-number context inside the PR diff view rather than forcing them to read raw CI logs.
- Use matrix validation for multi-version schema support. If your organisation holds legacy ISO 19115:2003 records alongside new ISO 19115-1:2014 ones, use a
strategy: matrixblock to run the job with two separate--schema-dirvalues. The script detects the active namespace from the root element and short-circuits the wrong schema path, so both versions are validated in a single workflow run.
Related
- Spatial Data Schema Linting in CI — the broader linting architecture that contextualises this GitHub Actions workflow
- Integrating PyLint with spatial metadata validators — extend schema checks into a unified PyLint message-reporting pass
- Policy Enforcement Gates for Data PRs — how branch protection rules and required checks turn validation into merge governance
- Validating FGDC Metadata Against XML Schemas — the same
lxmlXSD pattern applied to FGDC CSDGM documents