tomato·bazeldocs v0 · latest
Docs/Reference/Modules/rules_gitlab

rules_gitlab

Bazel rules for GitLab CI: schema-pinned validate + glab-backed server-side lint.

Latest0.3.4
Versions10
CategoryBazel rules
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/rules_gitlab/
Sourcegithub.com/tomato-bazel/rules_gitlab
MODULE.bazelstarlark
bazel_dep(name = "rules_gitlab", version = "0.3.4")

View source & releases on GitHub ↗

Bazel rules for working with GitLab CI configuration.

RuleWhatHermetic
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: regex JSON Schema check. GitLab’s actual parser accepts Perl-style /regex/ slash-literals (used in fields like coverage:) while the schema declares those fields with format: 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 own gitlab_ci_validate target. gitlab_ci_lint covers include: 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.yml against the official GitLab JSON Schema pinned by sha via the gitlab_schemas module extension. Hermetic; no network, no auth.
  • gitlab_ci_lint(name, src, host, repo)bazel run-able target. Wraps glab ci lint <src> via the glab toolchain. Hits the GitLab API for full pipeline validation (semantic checks beyond pure schema + include: resolution). Requires glab auth login to the target instance.

Future surface:

  • gitlab_ci_lint_remote(name, src, project) — call /api/v4/projects/:id/ci/lint directly (no glab CLI indirection), bake the project context.
  • Deploy + registry helpers, schema-derived typed Starlark rules for authoring .gitlab-ci.yml from Bazel (mirroring the rules_jsonschema + rules_cloudformation pattern).

Limitations of gitlab_ci_validate:

  • Does not follow include: directives. A .gitlab-ci.yml that imports another project’s snippets is validated only at its own leaf level; chain validations on the included files by registering each as a separate gitlab_ci_validate target. gitlab_ci_lint handles 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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
srcLabel of the .gitlab-ci.yml (or fragment) to lint.Labelrequired
hostGitLab 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).Stringoptional""
repoOWNER/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.Stringoptional""

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

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
srcLabel of the .gitlab-ci.yml (or sibling fragment) to validate.Labelrequired

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>.updatebazel run …:<name>.update writes the file into the source tree; bazel test …:<name>.update checks it is up to date.

PARAMETERS

NameDescriptionDefault Value
nametarget name.none
stageslist of stage names (order preserved).[]
variablesglobal CI variables (dict).{}
defaultthe default: job-config block (dict).None
imagetop-level default image (str or dict).None
includeinclude: entries (list).None
workflowthe workflow: block (dict).None
jobsdict of job-name -> job (a gitlab_job(...) dict or a raw dict).{}
extraescape hatch — raw dict merged at the top level last.{}
outoutput filename; defaults to <name>.gitlab-ci.yml.None
write_tosource-relative path to also create <name>.update.None
validatechain gitlab_ci_validate on the generated file (default True).True
kwargsforwarded to the underlying rule (visibility, tags, …).none

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

NameDescriptionDefault Value
stage

-

None
script

-

None
image

-

None
services

-

None
before_script

-

None
after_script

-

None
rules

-

None
needs

-

None
artifacts

-

None
variables

-

None
cache

-

None
tags

-

None
environment

-

None
when

-

None
allow_failure

-

None
interruptible

-

None
timeout

-

None
retry

-

None
parallel

-

None
coverage

-

None
extends

-

None
dependencies

-

None
extra

-

{}

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

NameDescriptionDefault Value
sitethe Bazel label (string) that builds the static site.none
site_artifactworkspace-relative path to the built site .tar.gz.none
stagethe CI stage for the job (default "pages")."pages"
strip_componentstar --strip-components, for archives with a top-level directory. mdbook’s output is flat, so the default 0 is correct.0
only_default_branchwhen True (default) and rules is unset, restrict the job to the default branch — Pages publishes from a single ref.True
rulesexplicit GitLab rules:; overrides only_default_branch.None
kwargsany other gitlab_job field (needs, variables, image, …).none

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

NameDescriptionDefault Value
parts

-

none

Conformance#

2 findings across 1 invariant. 15 contested atoms. See how gating works or the full report.

D2 a non-dev register_toolchains propagates to every transitive consumer why this matters ↗
versiontoolchain
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.

AtomResolved hereElsewhere
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#

rules_gitlab in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

platforms1.0.0bazel_skylib1.8.2rules_python1.7.0rules_shell0.6.1aspect_bazel_lib2.22.5rules_uv0.7.4stardoc0.7.2dev

Used by (2 in the registry)

Versions#

10 published versions, newest first. Each resolves to an immutable, integrity-checked archive.

VersionIntegrity (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.yml via a new ruamel.yaml emitter (//gitlab/private:emit). Auto-chains gitlab_ci_validate (build-time schema gate) and, when write_to is set, aspect_bazel_lib write_source_files (bazel run :<name>.update writes the file back; bazel test checks it is current).
  • gitlab_job(...) builds one job (drops unset fields); jobs also 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_lib 2.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 file gitlab-org/gitlab-foss/.../editor/schema/ci.json that GitLab’s web editor uses) via the gitlab_schemas module extension and validates .gitlab-ci.yml files against it using check-jsonschema (brought in via the internal @rules_gitlab_tooling pip hub backed by rules_uv). Hermetic; no network or auth at build time. Skips the format: regex check 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 wrapping glab ci lint <src>. Hits the GitLab API for full pipeline validation including include: resolution and semantic checks. Indirected via a glab toolchain (//gitlab/glab:toolchain_type); the default toolchain shells out to system glab on PATH.
  • Smoke test under examples/smoke/ with a minimal valid fixture validated on every CI run.

← All modules