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:

GitHub Actions ISO 19115 validation flow Flow from developer push through checkout, cache lookup, schema download, lxml XSD validation, pass/fail result, and branch protection gate. Developer push / PR Checkout repository XSD cached? Download ISO XSD files Use cached XSD files lxml XSD validation FAIL: block merge PASS: allow merge no yes

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.2 in 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, and gmx.xsd via relative xs:import. Missing any sibling file causes a XMLSchemaParseError at 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:referenceSystemInfo contains an approved EPSG code, or that gmd:CI_ResponsibleParty holds 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 used http://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: matrix block to run the job with two separate --schema-dir values. 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.