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.

What the workflow needs on the runner, and where each comes fromThe runner needs a Python environment, the ISO schema set, the validation script and a place to put the report.Outputsretainedvalidation reportannotationsexit statusCodevalidation scriptpinned lxmlrule configurationSchemasvendored, not fetchedISO 19115-3 XSD setschematron rulescode listsRunnerPython version pinnedcache keycheckout depth
Vendoring the schema set is the difference between a workflow that runs offline and one that depends on somebody else's uptime.

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.

The workflow, step by stepCheckout, environment setup, cache restore, validation, and report upload with annotations.Checkoutshallow, changed pathssetupPython + depspinned, from lock filerestoreSchema cachevendored XSD setvalidateValidateXSD then schematronpublishAnnotate + uploaddiff comments, artifact
Annotations before upload: a report nobody opens is worth less than five lines on the diff.
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:

Why an ISO validation workflow is slow, and what to do about itFour causes of slow ISO validation runs in CI with the mitigation for each.Typical costMitigationFetching schemas over thenetwork20-60 s, flakyvendor the schema setCompiling the XSD set5-15 s per runcompile once, validate manyValidating unchanged recordslinear in catalogue sizerestrict to changed pathsInstalling GDAL1-3 minnot needed for XML validation
Schema compilation dominates; everything else is noise by comparison.
# 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.