Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Unreleased

.. vendor-insert-here

- Support multi-document GitLab CI configuration files. (:issue:`561`)
- Update vendored schemas: bitbucket-pipelines, mergify, renovate (2026-08-16)

0.38.0
Expand Down
3 changes: 3 additions & 0 deletions docs/precommit_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ Validate GitHub Workflows against the schema provided by SchemaStore

Validate GitLab CI config against the schema provided by SchemaStore

The hook supports multi-document configuration files, including a ``spec:inputs``
header separated from the pipeline configuration by ``---``.

.. code-block:: yaml
:caption: example config

Expand Down
5 changes: 4 additions & 1 deletion src/check_jsonschema/instance_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ def __init__(
)

self._parsers = ParserSet(
modify_yaml_implementation=self._data_transform.modify_yaml_implementation
modify_yaml_implementation=self._data_transform.modify_yaml_implementation,
load_multiple_yaml_documents=(
self._data_transform.load_multiple_yaml_documents
),
)

def iter_files(self) -> t.Iterator[tuple[str, ParseError | t.Any]]:
Expand Down
7 changes: 6 additions & 1 deletion src/check_jsonschema/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def __init__(
self,
*,
modify_yaml_implementation: t.Callable[[ruamel.yaml.YAML], None] | None = None,
load_multiple_yaml_documents: bool = False,
supported_formats: t.Sequence[str] | None = None,
) -> None:
yaml_impl = yaml.construct_yaml_implementation()
Expand All @@ -54,7 +55,11 @@ def __init__(
modify_yaml_implementation(yaml_impl)
modify_yaml_implementation(failover_yaml_impl)
base_by_tag = {
"yaml": yaml.impl2loader(yaml_impl, failover_yaml_impl),
"yaml": yaml.impl2loader(
yaml_impl,
failover_yaml_impl,
load_multiple_documents=load_multiple_yaml_documents,
),
**DEFAULT_LOAD_FUNC_BY_TAG,
}
if supported_formats is None:
Expand Down
15 changes: 13 additions & 2 deletions src/check_jsonschema/parsers/yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
ParseError = ruamel.yaml.YAMLError


class YAMLDocuments(list[t.Any]):
"""Distinct collection returned when all YAML documents are requested."""


def construct_yaml_implementation(
typ: str = "safe", pure: bool = False
) -> ruamel.yaml.YAML:
Expand Down Expand Up @@ -42,6 +46,8 @@ def _normalize(data: t.Any) -> t.Any:
"""
if isinstance(data, dict):
return {str(k): _normalize(v) for k, v in data.items()}
elif isinstance(data, YAMLDocuments):
return YAMLDocuments(_normalize(x) for x in data)
elif isinstance(data, list):
return [_normalize(x) for x in data]
else:
Expand All @@ -52,7 +58,9 @@ def _normalize(data: t.Any) -> t.Any:


def impl2loader(
primary: ruamel.yaml.YAML, *fallbacks: ruamel.yaml.YAML
primary: ruamel.yaml.YAML,
*fallbacks: ruamel.yaml.YAML,
load_multiple_documents: bool = False,
) -> t.Callable[[t.IO[bytes]], t.Any]:
def load(stream: t.IO[bytes]) -> t.Any:
stream_bytes = stream.read()
Expand All @@ -62,7 +70,10 @@ def load(stream: t.IO[bytes]) -> t.Any:
warnings.simplefilter("ignore", ruamel.yaml.error.ReusedAnchorWarning)
for impl in [primary] + list(fallbacks):
try:
data = impl.load(stream_bytes)
if load_multiple_documents:
data = YAMLDocuments(impl.load_all(stream_bytes))
else:
data = impl.load(stream_bytes)
except ruamel.yaml.YAMLError as e:
lasterr = e
else:
Expand Down
4 changes: 3 additions & 1 deletion src/check_jsonschema/transforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@


class Transform:
load_multiple_yaml_documents = False

def __init__(
self,
*,
Expand All @@ -16,7 +18,7 @@ def __init__(
def modify_yaml_implementation(self, implementation: ruamel.yaml.YAML) -> None:
pass

def __call__(self, data: list | dict) -> list | dict:
def __call__(self, data: t.Any) -> t.Any:
if self.on_data is not None:
return self.on_data(data)
return data
24 changes: 22 additions & 2 deletions src/check_jsonschema/transforms/gitlab.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from __future__ import annotations

import typing as t

import ruamel.yaml

from ..parsers.yaml import YAMLDocuments
from .base import Transform


Expand All @@ -24,11 +27,28 @@ def from_yaml(
return [item.value for item in node.value]


# this "transform" is actually a no-op on the data, but it registers the GitLab !reference
# tag with the instance YAML loader
# Register GitLab's !reference tag and combine multi-document CI configuration.
class GitLabDataTransform(Transform):
load_multiple_yaml_documents = True

def modify_yaml_implementation(self, implementation: ruamel.yaml.YAML) -> None:
implementation.register_class(GitLabReference)

def __call__(self, data: t.Any) -> t.Any:
if not isinstance(data, YAMLDocuments):
return data
documents = [document for document in data if document is not None]
if not documents:
return None
if len(documents) == 1:
return documents[0]
if not all(isinstance(document, dict) for document in documents):
return documents

merged = {}
for document in documents:
merged.update(document)
return merged


GITLAB_TRANSFORM = GitLabDataTransform()
8 changes: 8 additions & 0 deletions tests/example-files/hooks/positive/gitlab-ci/spec-inputs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
spec:
inputs:
job-stage:
default: test
---
scan-website:
stage: $[[ inputs.job-stage ]]
script: echo scan
6 changes: 6 additions & 0 deletions tests/unit/test_gitlab_data_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ def test_can_parse_yaml_with_transform():
assert data == {"a": "b", "c": "d"}


def test_transform_preserves_non_yaml_data():
data = {"job": {"script": "echo ok"}}

assert GITLAB_TRANSFORM(data) is data


def test_can_parse_ok_gitlab_yaml_with_transform():
rawdata = """\
foo:
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_instance_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from check_jsonschema.instance_loader import InstanceLoader
from check_jsonschema.parsers import BadFileTypeError, FailedFileLoadError
from check_jsonschema.parsers.json5 import ENABLED as JSON5_ENABLED
from check_jsonschema.transforms.gitlab import GITLAB_TRANSFORM


# handy helper for opening multiple files for InstanceLoader
Expand Down Expand Up @@ -67,6 +68,42 @@ def test_instanceloader_yaml_data(tmp_path, filename, default_filetype, open_wid
assert data == [(str(f), {"a": {"b": [1, 2], "c": "d"}})]


def test_instanceloader_gitlab_multidocument_yaml(tmp_path, open_wide):
f = tmp_path / ".gitlab-ci.yml"
f.write_text("""\
spec:
inputs:
job-stage:
default: test
---
scan-website:
stage: $[[ inputs.job-stage ]]
script: echo scan
""")

regular_loader = InstanceLoader(open_wide(f), default_filetype="yaml")
regular_result = list(regular_loader.iter_files())
assert len(regular_result) == 1
assert isinstance(regular_result[0][1], FailedFileLoadError)

loader = InstanceLoader(
open_wide(f), default_filetype="yaml", data_transform=GITLAB_TRANSFORM
)

assert list(loader.iter_files()) == [
(
str(f),
{
"spec": {"inputs": {"job-stage": {"default": "test"}}},
"scan-website": {
"stage": "$[[ inputs.job-stage ]]",
"script": "echo scan",
},
},
)
]


@pytest.mark.parametrize(
"filename, default_filetype",
[
Expand Down