rules_gitlab
Bazel rules for GitLab CI: schema-pinned validate + glab-backed server-side lint.
| Latest | 0.3.4 |
|---|---|
| Versions | 10 |
| Category | Bazel rules |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_gitlab/ |
| Source | github.com/tomato-bazel/rules_gitlab |
bazel_dep(name = "rules_gitlab", version = "0.3.4")
View source & releases on GitHub ↗
Bazel rules for working with GitLab CI configuration.
| Rule | What | Hermetic |
|---|---|---|
gitlab_ci(name, jobs, …, write_to) | Generate a .gitlab-ci.yml from a typed Starlark spec (gitlab_job / gitlab_reference helpers); deterministic YAML via ruamel. Auto-chains gitlab_ci_validate + an optional <name>.update write-back. | ✅ |
gitlab_ci_validate(name, src) | Validate .gitlab-ci.yml against the official GitLab JSON Schema (pinned by sha256 against gitlab-org/gitlab-foss/.../editor/schema/ci.json — the file GitLab’s web editor uses). | ✅ |
gitlab_ci_lint(name, src, repo) | bazel run-able target wrapping glab ci lint <src>. Hits the GitLab API for server-side lint (resolves include: references, applies semantic checks). | Network + auth |
Generate a .gitlab-ci.yml
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci", "gitlab_job", "gitlab_reference")
gitlab_ci(
name = "ci",
stages = ["build", "test"],
variables = {"GREETING": "hello"},
jobs = {
".setup": gitlab_job(before_script = ["echo setting up"]),
"build": gitlab_job(
stage = "build",
before_script = gitlab_reference(".setup", "before_script"),
script = ['echo "$GREETING from build"'],
),
"test": gitlab_job(stage = "test", script = ["pytest"], coverage = "/^TOTAL/"),
},
write_to = ".gitlab-ci.yml", # `bazel run :ci.update` writes it back
)
bazel run :ci.update writes .gitlab-ci.yml; bazel test :ci.update
checks it’s current; the auto-wired :ci_validate schema-checks the
generated file. Unmodeled keys go through gitlab_job(extra={...}) or
gitlab_ci(extra={...}).
Quick start
# MODULE.bazel
bazel_dep(name = "rules_gitlab", version = "0.1.0")
# (rules_gitlab auto-registers a PATH-based default `glab` toolchain
# for gitlab_ci_lint; override by registering your own if you
# want a hermetic glab binary.)
# BUILD.bazel
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci_validate", "gitlab_ci_lint")
gitlab_ci_validate(
name = "ci_validate",
src = ".gitlab-ci.yml",
)
gitlab_ci_lint(
name = "ci_lint",
src = ".gitlab-ci.yml",
repo = "https://gitlab.com/my-group/my-project",
)
bazel build :ci_validate # hermetic schema check
bazel run :ci_lint # network-bound full lint
Architecture
gitlab/
├── defs.bzl # public rules: gitlab_ci_validate, gitlab_ci_lint
├── extensions.bzl # http_file pin for the GitLab JSON Schema
├── glab/ # toolchain abstraction over the `glab` CLI
│ ├── defs.bzl # glab_toolchain rule
│ ├── system_glab.sh # default toolchain: shells out to system `glab`
│ └── toolchain_type.bzl
└── private/
├── BUILD.bazel
└── validate_main.py # check-jsonschema wrapper invoked by the validate rule
tooling/
├── pyproject.toml # check-jsonschema dep
└── uv.lock # resolved by `rules_uv`'s pip.parse
Limitations of gitlab_ci_validate
- Skips the
format: regexJSON Schema check. GitLab’s actual parser accepts Perl-style/regex/slash-literals (used in fields likecoverage:) while the schema declares those fields withformat: regex; the strict regex format validator rejects the wrapped form. Everything else (uri, structural,additionalProperties, etc.) stays enforced. - Does not follow
include:references. Chain validation on included files by registering each as its owngitlab_ci_validatetarget.gitlab_ci_lintcoversinclude:resolution server-side.
Provenance
Lifted from savvi/gitlab/ (the SAVVI Bazel aggregator) where
the rules were first prototyped against selectsmart-engine’s
real .gitlab-ci.yml. Same layout; only the namespace differs.
Schema refresh
The pinned schema sha is at the top of
gitlab/extensions.bzl. To refresh:
curl -fL "https://gitlab.com/gitlab-org/gitlab-foss/-/raw/master/app/assets/javascripts/editor/schema/ci.json" -o /tmp/ci.json
shasum -a 256 /tmp/ci.json
# paste the digest into _CI_SCHEMA_SHA256 Usage#
Real usage, taken from the module’s examples/.
examples/generate/BUILD.bazel
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("//gitlab:defs.bzl", "gitlab_ci", "gitlab_job", "gitlab_pages_job", "gitlab_reference")
package(default_visibility = ["//visibility:public"])
# Smoke: assemble a small pipeline from typed Starlark, emit it as YAML,
# schema-validate the result, and gate the bytes against a committed
# golden. `bazel run //examples/generate:pipeline.update` regenerates the
# golden; `bazel test //examples/generate:...` checks it's current + valid.
gitlab_ci(
name = "pipeline",
stages = ["build", "test", "pages"],
variables = {"GREETING": "hello"},
jobs = {
# Hidden template job + a `!reference` to it (round-trip test).
".setup": gitlab_job(
before_script = ["echo setting up"],
),
"build": gitlab_job(
stage = "build",
before_script = gitlab_reference(".setup", "before_script"),
script = ['echo "$GREETING from build"'],
),
"test": gitlab_job(
stage = "test",
script = ['echo "$GREETING from test"'],
coverage = "/^TOTAL\\s+\\d+/",
),
# The generic static-site → GitLab Pages job.
"pages": gitlab_pages_job(
site = "//docs:site",
site_artifact = "bazel-bin/docs/site.tar.gz",
),
},
write_to = "golden/.gitlab-ci.yml",
)
diff_test(
name = "pipeline_golden_test",
file1 = ":pipeline",
file2 = "golden/.gitlab-ci.yml",
)
examples/smoke/BUILD.bazel
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci_lint", "gitlab_ci_validate")
package(default_visibility = ["//visibility:public"])
# Smoke test: a minimal valid .gitlab-ci.yml fixture must pass
# schema validation. Failure of this target in CI signals either
# a schema-pin drift (upstream broke compatibility) or a bug in
# the rule wiring.
gitlab_ci_validate(
name = "valid_smoke",
src = "valid.gitlab-ci.yml",
)
# Lint variant exercises the launcher-script generation +
# toolchain resolution; `bazel build` is enough to verify the
# launcher is produced. Running it requires `glab auth login`,
# so we don't gate CI on actually running it.
gitlab_ci_lint(
name = "valid_smoke_lint",
src = "valid.gitlab-ci.yml",
# Empty repo — under `bazel run` glab will fall back to the
# cwd's git remote (gitlab.com if nothing matches). Real
# consumers should set this; the fixture leaves it blank
# because we only `bazel build` here.
)
# Surface the validate + lint smoke targets under `bazel test` so
# rules_gitlab's GitHub Actions CI gates them. The lint target's
# output is just the generated launcher script (no network call
# happens during build), so this stays hermetic.
build_test(
name = "smoke_build_test",
targets = [
":valid_smoke",
":valid_smoke_lint",
],
)Rules & providers#
Generated with Stardoc from the module's .bzl sources.
from docs/defs.md
Public Bazel rules for working with GitLab CI configuration.
Today (v0.1.0):
gitlab_ci_validate(name, src)— build-action rule. Validates a.gitlab-ci.ymlagainst the official GitLab JSON Schema pinned by sha via thegitlab_schemasmodule extension. Hermetic; no network, no auth.gitlab_ci_lint(name, src, host, repo)—bazel run-able target. Wrapsglab ci lint <src>via theglabtoolchain. Hits the GitLab API for full pipeline validation (semantic checks beyond pure schema +include:resolution). Requiresglab auth loginto the target instance.
Future surface:
gitlab_ci_lint_remote(name, src, project)— call/api/v4/projects/:id/ci/lintdirectly (no glab CLI indirection), bake the project context.- Deploy + registry helpers, schema-derived typed Starlark rules
for authoring
.gitlab-ci.ymlfrom Bazel (mirroring the rules_jsonschema + rules_cloudformation pattern).
Limitations of gitlab_ci_validate:
- Does not follow
include:directives. A.gitlab-ci.ymlthat imports another project’s snippets is validated only at its own leaf level; chain validations on the included files by registering each as a separategitlab_ci_validatetarget.gitlab_ci_linthandles includes server-side.
gitlab_ci_lint
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci_lint")
gitlab_ci_lint(name, src, host, repo)
bazel run-able target that lints a .gitlab-ci.yml via glab ci lint. Network-bound: hits the GitLab API, requires the user to be glab auth login-ed to the target instance. For hermetic schema-only validation, use gitlab_ci_validate instead.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| src | Label of the .gitlab-ci.yml (or fragment) to lint. | Label | required | |
| host | GitLab host (e.g. gitlab.savvifi.com). Used to anchor glab’s API target when the runfiles cwd doesn’t have a gitlab remote. Ignored if repo is set (which carries host). | String | optional | "" |
| repo | OWNER/REPO or full URL passed as glab -R. Strongly recommended — lets glab pick the right GitLab instance + project context without inspecting the sandbox’s git state. | String | optional | "" |
gitlab_ci_validate
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci_validate")
gitlab_ci_validate(name, src)
Validate a .gitlab-ci.yml against the official GitLab JSON Schema (pinned by sha256 via the gitlab_schemas module extension). Output: a stamp file Bazel checks for caching; on schema violation the build fails with check-jsonschema’s diagnostic on stderr.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| src | Label of the .gitlab-ci.yml (or sibling fragment) to validate. | Label | required |
gitlab_ci
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_ci")
gitlab_ci(name, stages, variables, default, image, include, workflow, jobs, extra, out, write_to,
validate, **kwargs)
Generate a .gitlab-ci.yml from a typed Starlark spec.
Assembles the spec in a fixed top-level order (include, workflow,
default, image, stages, variables, jobs sorted by name, extra),
emits it deterministically as YAML, and (by default) schema-validates
the result. Set write_to (e.g. ".gitlab-ci.yml") to also create
<name>.update — bazel run …:<name>.update writes the file into the
source tree; bazel test …:<name>.update checks it is up to date.
PARAMETERS
gitlab_job
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_job")
gitlab_job(stage, script, image, services, before_script, after_script, rules, needs, artifacts,
variables, cache, tags, environment, when, allow_failure, interruptible, timeout, retry,
parallel, coverage, extends, dependencies, extra)
Build one GitLab CI job as a None-stripped, key-ordered dict.
Returns a plain dict (Starlark structs aren’t json.encode-able), so
pass the result as a value in gitlab_ci(jobs = {...}). Any key not
modeled here can be supplied via extra (a raw dict, merged last).
PARAMETERS
gitlab_pages_job
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_pages_job")
gitlab_pages_job(site, site_artifact, stage, strip_components, only_default_branch, rules, **kwargs)
A GitLab Pages job that publishes a Bazel-built static site.
GitLab Pages serves the public/ artifact of a job named pages. This
builds site (a target whose output site_artifact is a .tar.gz of the
site root — e.g. an mdbook_book or any pkg_tar of HTML) and unpacks it
into public/. The generic “ship this repo’s static site to Pages” job:
gitlab_ci(
name = "ci",
stages = ["pages"],
jobs = {
"pages": gitlab_pages_job(
site = "//docs:site",
site_artifact = "bazel-bin/docs/site.tar.gz",
),
},
)
PARAMETERS
RETURNS
A job dict for use as a gitlab_ci(jobs = {...}) value. The job name
MUST be "pages" for GitLab to publish it.
gitlab_reference
load("@rules_gitlab//gitlab:defs.bzl", "gitlab_reference")
gitlab_reference(*parts)
Emit a GitLab !reference [job, key, ...] tag value.
Usable as a value anywhere in a spec; survives json.encode as a
sentinel the emitter turns back into a real !reference YAML tag.
PARAMETERS
| Name | Description | Default Value |
|---|---|---|
| parts | - | none |
Conformance#
2 findings across 1 invariant. 15 contested atoms. See how gating works or the full report.
| version | toolchain |
|---|---|
0.3.4 | //gitlab/glab:default_glab_toolchain |
0.3.4 | @rules_uv//uv:uv_toolchain_def |
Contested atoms
Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.
| Atom | Resolved here | Elsewhere |
|---|---|---|
apple_support | 1.24.2 | 2.2.0 ×1 |
aspect_bazel_lib | 2.22.5 | 2.8.1 ×1 |
bazel_lib | 3.0.0 | 3.2.2 ×17 |
bazel_skylib | 1.8.2 | 1.9.0 ×2 |
gawk | 5.3.2.bcr.1 | 5.3.2.bcr.3 ×17 |
jq.bzl | 0.1.0 | 0.4.0 ×17 |
nlohmann_json | 3.6.1 | 3.12.0.bcr.1 ×1 |
package_metadata | 0.0.2 | 0.0.5 ×3 |
protobuf | 33.4 | 34.0.bcr.1 ×2 |
rules_jvm_external | 6.7 | 6.8 ×4 |
rules_python | 1.7.0 | 2.0.1 ×1 |
rules_swift | 3.1.2 | 3.6.1 ×1 |
tar.bzl | 0.5.1 | 0.10.4 ×170.6.0 ×1 |
upb | 0.0.0-20220923-a547704 | 0.0.0-20230516-61a97ef ×1 |
yq.bzl | 0.1.1 | 0.3.4 ×17 |
Dependencies#
Depends on
Used by (2 in the registry)
Versions#
10 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (sha256) | Source archive |
|---|---|---|
0.3.4 latest | v6In8FT5qV+kBGWe… | tag archive ↗ |
0.3.3 | J0KZvsqcfajIBPi8… | tag archive ↗ |
0.3.2 | rJ0jZrHFlBZu+z/9… | tag archive ↗ |
0.3.1 | 1vfhKl6+EyBaF+gc… | tag archive ↗ |
0.3.0 | OtSLVoYF1NYJCqgv… | tag archive ↗ |
0.2.0 | /f7drEkJk6jsgvBB… | tag archive ↗ |
0.1.3 | IGwFgFmyR6dO3y8p… | tag archive ↗ |
0.1.2 | 004qHCtvunrety2b… | tag archive ↗ |
0.1.1 | Z62g8LCypu7/1x1w… | tag archive ↗ |
0.1.0 | vzk8Xt7qgoEIMJu7… | tag archive ↗ |
Changelog#
All notable changes to rules_gitlab. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.
0.2.0 — gitlab_ci generation rule
Adds gitlab_ci — generate a .gitlab-ci.yml from a typed Starlark
spec (the D14 CI-generation prereq), mirroring the
rules_jsonschema / rules_vscode “schema-is-the-source, emit
canonically” pattern.
gitlab_ci(name, stages, variables, default, image, include, workflow, jobs, extra, out, write_to, validate)assembles the spec and emits a deterministic.gitlab-ci.ymlvia a new ruamel.yaml emitter (//gitlab/private:emit). Auto-chainsgitlab_ci_validate(build-time schema gate) and, whenwrite_tois set, aspect_bazel_libwrite_source_files(bazel run :<name>.updatewrites the file back;bazel testchecks it is current).gitlab_job(...)builds one job (drops unset fields);jobsalso accepts a raw dict.gitlab_reference("job","key")emits a GitLab!reference [job, key]tag (round-trips through the emitter + validator).- Key order is canonical + deterministic (the emitter applies a fixed priority, then sorts the rest), so generated files diff cleanly.
- New dep:
aspect_bazel_lib2.22.5 (write_source_files).
0.1.3 — ruamel.yaml multi-constructor signature fix
v0.1.2 registered an add_multi_constructor on !-prefixed tags
but used a (self, node) signature when ruamel calls
(loader, tag_suffix, node). Real builds failed at runtime with
_absorb_unknown_tag() takes 2 positional arguments but 3 were given. v0.1.3 fixes the signature + dispatches explicitly on
node type via ruamel.yaml.nodes.
0.1.2 — actually parse GitLab custom YAML tags via ruamel.yaml
v0.1.1 attempted to absorb GitLab’s !reference / !file /
!base64 tags by registering PyYAML constructors. That didn’t
work in practice because check-jsonschema uses ruamel.yaml
(not PyYAML) for YAML loading — the PyYAML monkey-patch never
fired and the parse still failed with ConstructorError.
v0.1.2 restructures the validator: load the YAML ourselves with
ruamel.yaml + a generic constructor that absorbs !-prefixed
tags, dump to a temp JSON file, and pass that JSON to
check-jsonschema — sidestepping its YAML parser entirely.
- Dropped the PyYAML dep added in 0.1.1.
- Direct dep on
ruamel.yaml(already a transitive dep of check-jsonschema; pinned explicitly so rules_python sees it). - Verified end-to-end against selectsmart-employers’
.gitlab-ci.yml(uses!reference [.aws_environment, before_script]).
0.1.1 — GitLab custom YAML tags
Real-world .gitlab-ci.yml files use non-standard YAML tags
(!reference [.aws_environment, before_script], !file,
!base64 …) that GitLab’s server-side parser handles but
PyYAML’s default loader rejects with ConstructorError.
v0.1.1: the validator wrapper now registers a multi-constructor
on !-prefixed tags that absorbs them as their underlying
Python value (scalar / sequence / mapping). The JSON Schema
validator sees the structural shape and validates it as usual —
the trade-off for being able to lint real GitLab configs at all.
- New direct dep on PyYAML (already a transitive dep of check-jsonschema; pinning it explicitly so the py_binary resolves it cleanly).
- Bumped rules_uv pin to 0.7.3 (registry-markers handling surfaced when validating savvi-aggregator member lockfiles).
0.1.0 — initial release
Lifted from savvi/gitlab/ after the rules stabilized against
real-world .gitlab-ci.yml files (selectsmart-engine, savvi-ops).
gitlab_ci_validate(name, src)— build-action rule. Pins the official GitLab CI JSON Schema (the filegitlab-org/gitlab-foss/.../editor/schema/ci.jsonthat GitLab’s web editor uses) via thegitlab_schemasmodule extension and validates.gitlab-ci.ymlfiles against it usingcheck-jsonschema(brought in via the internal@rules_gitlab_toolingpip hub backed byrules_uv). Hermetic; no network or auth at build time. Skips theformat: regexcheck because GitLab accepts slash-delimited regex literals (e.g./^TOTAL.../) the schema doesn’t.gitlab_ci_lint(name, src, host, repo)—bazel run-able target wrappingglab ci lint <src>. Hits the GitLab API for full pipeline validation includinginclude:resolution and semantic checks. Indirected via aglabtoolchain (//gitlab/glab:toolchain_type); the default toolchain shells out to systemglabon PATH.- Smoke test under
examples/smoke/with a minimal valid fixture validated on every CI run.