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

rules_ci

Bazel rules + Rust translator + Lean 4 IR for provably correct translations between GitLab CI, GitHub Actions, and Bazel rules.

Latest0.3.0
Versions4
CategoryBazel rules
MaintainersMatt Marshall
Registryhttps://registry.tbzl.dev/modules/rules_ci/
Sourcegithub.com/tomato-bazel/rules_ci
MODULE.bazelstarlark
bazel_dep(name = "rules_ci", version = "0.3.0")

View source & releases on GitHub ↗

The fastverk CI / release / versioning foundation: generate a repo’s CI pipeline by convention, make its shippable products first-class in the build graph, drift-gate its generated files, drive automated versioning from the public surface — plus a Rust CI-IR translator (GitLab ↔ GitHub ↔ Bazel, mediated by a neutral IR).

This README is GENERATED — do not edit it directly. The API reference below is rendered from the .bzl docstrings by stardoc; the whole file is composed by rules_readme and drift-gated by //:readme.write_test (run in CI + the pre-commit hook). To change it, edit the .bzl docstrings or README.md.tmpl, then run bazel run //:readme.write.

The three-layer spine

fastverk_project (this module) ← aion_framework_library (aion/rules) ← aion_app (studio). A repo calls the one macro for its layer; it composes the CI pipeline (rules_gitlab gitlab_ci), the README badges (rules_readme), the release_artifacts products aspect, the git_hooks pre-commit installer, and the version/ tooling — all from convention.

Quick start

# MODULE.bazel
bazel_dep(name = "rules_ci", version = "0.0.1")
# BUILD.bazel — generate + drift-gate this repo's .gitlab-ci.yml, README badges, and hooks
load("@rules_ci//project:defs.bzl", "fastverk_project")

fastverk_project(name = "project", repo = "your/repo", badges = True, hooks = True)
bazel test //:project.ci_gates       # every drift/validation gate (CI + the pre-commit hook)
bazel run  //:project.hooks_install  # install the pre-commit hook (core.hooksPath)

API reference

fastverk_project — the generic per-repo convention macro (the fastverk layer).

Composes the project-level wiring a repo needs — CI, README/badges, git hooks, versioning — from the underlying rulesets (rules_gitlab, rules_readme), driven by the products the //release:release_artifacts aspect discovers in the build graph. The upper layers wrap this: aion/rules’ aion_framework_library / aion_framework_project, and studio’s aion_app(features = [...]).

Milestone 1: the .gitlab-ci.yml generator — emit + schema-validate + drift-gate a thin pipeline from a caller-supplied include: + CI variables.

Milestone 4 (this revision): ci = selects the CI BACKEND, so the pipeline file stops being mandatory. The macro previously called gitlab_ci() unconditionally, which put it in direct contradiction with readiness criterion C2 (ci-as-rules requires the forge-native CI file to be ABSENT) — C2 was unsatisfiable for every repo using this macro. ci = "native" composes //ci’s ci_job/ci_publish targets instead and writes no such file; ci = "none" wires no CI at all.

Milestone 3 (this revision): features + the release-products gate. A feature is a named bundle of (CI lane include(s), CI variables, expected shippable products). The upper layers own the feature CATALOG — studio’s aion_app(features = ["web", "tui"]) resolves names → the resolved features dict this macro consumes — so the generic layer stays catalog-agnostic. When the repo points products at its top-level product targets, the macro materializes the discovered-products manifest and, against the declared set, a declared-vs-discovered DRIFT GATE (products_drift_test) — the connection between the features a repo turns on and the artifacts it’s allowed to ship.

Badges, git hooks, and the versioning workflow land in subsequent milestones.

fastverk_project

load("@rules_ci//project:defs.bzl", "fastverk_project")

fastverk_project(name, repo, ci_include, ci_variables, ci_stages, ci_jobs, ci_extra, ci,
                 ci_jobs_native, features, products, expected_products, gate_tests, hooks, hooks_dir,
                 badges, host, badge_branch, write_to, validate, visibility)

Generic project-level wiring: the CI backend + the release-products gate.

With ci = "gitlab" (the default), generates <write_to> and its gates:

bazel run  //:<name>.ci.update           # (re)generate the pipeline into the tree
bazel test //:<name>.ci.update_test      # CI-drift gate (CI + the pre-commit hook)
bazel build //:<name>.ci_validate        # schema gate

With ci = "native", no forge-native file is written — CI is Bazel targets:

bazel test //:<name>.ci                  # the test gate (a test_suite)
# <name>.ci.pipeline.json                # publish jobs, read by the build-runner

With ci = "none", neither.

And, when products is set, the release-products seam:

bazel build //:<name>.products           # the discovered-products JSON manifest
bazel test  //:<name>.products_drift_test # declared-vs-discovered drift gate

Plus the unified gate suite (and, with hooks = True, the installer):

bazel test //:<name>.ci_gates            # every drift/validation gate (CI + the hook)
bazel run  //:<name>.hooks_install       # install the pre-commit hook (core.hooksPath)

PARAMETERS

NameDescriptionDefault Value
namebase name; generates <name>.ci, <name>.ci.update, <name>.ci_validate, and (when products is set) <name>.products + <name>.products_drift_test."project"
repoOWNER/REPO on the git host (e.g. “aion/db”). Used for the README badges.None
ci_includebase GitLab include: entries (list of dicts) — shared lane(s) to pull in. ci = "gitlab" only.[]
ci_variablesbase global CI/CD variables (dict). ci = "gitlab" only.{}
ci_stagesexplicit pipeline stages (usually empty — the included lane owns them). ci = "gitlab" only.[]
ci_jobsrepo-local jobs (usually empty — jobs live in the shared lane). ci = "gitlab" only.{}
ci_extraescape-hatch raw top-level keys merged into the generated pipeline. ci = "gitlab" only.{}
ciwhich CI backend to generate — one of CI_BACKENDS:

* "gitlab" (default) — a generated .gitlab-ci.yml. Unchanged behavior. * "native" — CI as ci_job/ci_publish Bazel targets, NO forge-native file. This is what makes readiness criterion C2 (ci-as-rules, which requires .gitlab-ci.yml to be absent) satisfiable — it was previously unreachable for any repo using this macro, because the macro always emitted the very file C2 forbids. bazel test //:<name>.ci is the gate. * "none" — no CI wiring, for a repo built entirely off the BuildRun rail.

The GitLab-shaped args (ci_include, ci_variables, ci_stages, ci_jobs, ci_extra) configure a generated pipeline FILE; passing them with a non-gitlab backend is an error rather than a silent no-op, so a repo flipping to "native" cannot quietly drop its lanes.
"gitlab"
ci_jobs_nativeci_job() / ci_publish() results composed into the pipeline when ci = "native". Test jobs become the <name>.ci test_suite; publish jobs land in <name>.ci.pipeline.json for the build-runner.[]
featuresresolved feature bundles, {name: {"include": [...], "variables": {...}, "jobs": {...}, "expects": [{"kind","name"}, ...]}}. Enabling a feature = its presence here; its include/variables/jobs union onto the base CI, and its expects join the declared product set for the drift gate. The upper layers turn ["web", "tui"] into this dict; the generic layer never hardcodes which features exist. A sibling product ruleset can EXPORT a helper returning such a bundle (e.g. rules_tap’s affected-test lane as inline jobs) — so fastverk_project composes it WITHOUT a hard dep on that ruleset. Example:

load(“@rules_tap//ci:defs.bzl”, “tap_ci_feature”) fastverk_project(name = “project”, repo = “aion/db”, features = {“tap”: tap_ci_feature()}) # adds the affected-test lane
{}
productstop-level product targets (labels) to run the release_artifacts aspect over — the inputs to the manifest + drift gate. Empty ⇒ no release-products targets.[]
expected_productsproducts the repo declares it ships, as [{"kind","name"}, ...] (merged with every enabled feature’s expects). Drives the drift gate; empty (and no feature expects) ⇒ the manifest is still emitted but no gate is generated.[]
gate_testsEXTRA gate test labels to fold into <name>.ci_gates (e.g. the repo’s :readme.write_test, a lockfile drift test, a manifest objects.json gate). The macro’s own gates (.ci.update_test, .products_drift_test) are added automatically.[]
hooksalso generate <name>.hooks_installbazel run it to install a pre-commit hook that runs <name>.ci_gates (hard block, no auto-fix) + sets core.hooksPath. Local/CI parity: the hook runs the SAME suite as CI. Default off.False
hooks_dirworking-tree dir the pre-commit hook is installed into (default “.githooks”).".githooks"
badgesemit a <name>.badges rules_readme markdown_fragment (slot “badges”) with the GitLab-native pipeline + coverage badges for repo. The repo’s README template composes it: <!-- FRAGMENTS:badges --> + readme(fragments=[":<name>.badges"]). Requires repo. Default off (opt-in until a repo wires the slot).False
hostthe git host for the badge URLs (default “gitlab.savvifi.com”)."gitlab.savvifi.com"
badge_branchthe branch the badges reflect (default “main”)."main"
write_tosource-relative output path (default .gitlab-ci.yml).".gitlab-ci.yml"
validateschema-validate the generated file (default True).True
visibilitytarget visibility for the generated targets.None

release_artifacts — make the build graph’s “versioned products” first-class.

A ReleaseArtifactInfo is the normalized, cross-kind view of a shippable product (an npm package, an OCI image, a helm chart, a static site, an aion module bundle). The release_artifacts aspect SYNTHESIZES it from the producing rules rather than requiring producers to hand-emit it — so any existing npm_package / oci_image / … is discoverable as a product automatically.

fastverk_project consumes the aspect to derive a repo’s products → which CI lanes to include and what the versioning workflow operates on. kind is thus DERIVED from what a repo produces, not declared.

Detection keys off ctx.rule.kind — a stable signal that avoids depending on aspect_rules_js/ts internal provider symbols (whose fields shift across versions). The public-surface extraction for versioning (npm → the .d.ts via rules_ts DeclarationInfo; the package name/version via the package.json) is pinned when the aspect is first run against a real package — see the M3 TODOs below.

products_drift_test

load("@rules_ci//release:defs.bzl", "products_drift_test")

products_drift_test(name, deps, expected)

Fail if the products discovered under deps differ from the declared expected set.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
depsTop-level targets whose discovered products are checked against expected.List of labelsoptional[]
expectedDeclared products as normalized “kind:name” entries (e.g. “npm:@aion/foo”).List of stringsoptional[]

release_manifest

load("@rules_ci//release:defs.bzl", "release_manifest")

release_manifest(name, deps)

Emit the fastverk.release.v1.ReleaseManifest (proto3-JSON) of every product under deps.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
depsTop-level targets to discover products under.List of labelsoptional[]

ReleaseArtifactInfo

load("@rules_ci//release:defs.bzl", "ReleaseArtifactInfo")

ReleaseArtifactInfo(kind, name, label, version_source, surface)

Normalized view of one shippable, versioned product discovered in the graph.

FIELDS

NameDescription
kindProduct kind: npm | oci | helm | site | module_bundle.
nameThe product’s published name (npm package name, image repo, chart name).
labelThe Bazel label that produces it (string).
version_sourceWhere the version is read (e.g. the package.json File), or None.
surfaceTuple of Files describing the public surface for versioning (npm: the .d.ts* closure); () if none.

ReleaseArtifactsInfo

load("@rules_ci//release:defs.bzl", "ReleaseArtifactsInfo")

ReleaseArtifactsInfo(products)

Aggregate of every ReleaseArtifactInfo reachable from a target.

FIELDS

NameDescription
productsdepset of ReleaseArtifactInfo.

release_artifacts

load("@rules_ci//release:defs.bzl", "release_artifacts")

release_artifacts()

Walk a target’s graph and collect its shippable products as ReleaseArtifactInfo.

ASPECT ATTRIBUTES

NameType
depsString
srcsString
dataString

ATTRIBUTES

git_hooks — install a pre-commit hook that runs the SAME gates as CI.

The local/CI parity rule: a developer’s pre-commit runs exactly the ci_gates test_suite that CI runs — so a commit that would fail the pipeline’s drift/validation gates (stale generated .gitlab-ci.yml, README, products manifest, lockfiles, …) is blocked locally FIRST. Hard block, NO auto-fix: the dev runs the named .update/.write target and re-commits. fastverk_project(hooks = True) wires this over the gate suite it assembles.

bazel run //<pkg>:<name> installs: it writes <hooks_dir>/pre-commit into the working tree and points git at it via core.hooksPath. The devcontainer/bootstrap calls it once.

Implemented as a tiny executable rule (not sh_binary) so consumers don’t need rules_shell, and the install script EMBEDS the hook inline (heredoc) so there are no runfiles to resolve.

git_hooks

load("@rules_ci//hooks:defs.bzl", "git_hooks")

git_hooks(name, gates, hooks_dir)

bazel run installs a pre-commit hook (runs gates, hard-block) + sets core.hooksPath.

ATTRIBUTES

NameDescriptionTypeMandatoryDefault
nameA unique name for this target.Namerequired
gatesThe bazel test target the hook runs (the ci_gates suite label, as text).Stringrequired
hooks_dirWorking-tree dir for the hook; core.hooksPath is pointed here.Stringoptional".githooks"

Versioning + the Rust translator

Automated, surface-driven versioning (proto DTOs in proto/fastverk/release/v1/, the version/ tools, the escalation seam) is documented in docs/VERSIONING.md. The Rust CI-IR translator (//translator, dev-scoped) and its proof strategy are in docs/DESIGN.md.

Usage#

Real usage, taken from the module’s examples/.

examples/native_project/BUILD.bazel

load("@rules_ci//ci:defs.bzl", "ci_job", "ci_publish")
load("@rules_ci//project:defs.bzl", "fastverk_project")
load("@rules_shell//shell:sh_test.bzl", "sh_test")

# A repo whose CI is Bazel targets, not a generated pipeline file.
#
# This is the shape readiness criterion C2 (`ci-as-rules`) asks for: no
# `.gitlab-ci.yml`, no `.github/workflows`, `MODULE.bazel` present. Before the
# `ci` backend existed, fastverk_project could not produce it — the macro always
# emitted the very file C2 forbids, so the criterion was unsatisfiable for every
# repo that used it.
#
#   bazel test  //examples/native_project:project.ci        # the test gate
#   bazel test  //examples/native_project:project.ci_gates  # the same, via the suite
#
# And, load-bearing: NO .gitlab-ci.yml is written anywhere by this target.
package(default_visibility = ["//visibility:public"])

fastverk_project(
    name = "project",
    ci = "native",
    ci_jobs_native = [
        ci_job(
            name = "unit",
            script = ["echo ok"],
            stage = "test",
        ),
        ci_publish(
            name = "pub",
            artifact = ":unit.sh",
            destination = "s3://example/native/",
            kind = "static_cdn",
        ),
    ],
    repo = "example/native",
)

# A repo that ships nothing and is built entirely off the BuildRun rail: no CI
# wiring at all. `project_none.ci_gates` must still ANALYZE — it was the dangling
# `.ci.update_test` reference in the gates list that made this shape impossible.
fastverk_project(
    name = "project_none",
    ci = "none",
    repo = "example/none",
)

# Turn the load-bearing claim above ("NO .gitlab-ci.yml is written") into a gate.
# A comment cannot fail CI, and this one guards readiness criterion C2 — see
# no_pipeline_file_test.sh for why absence needs a query to assert it.
genquery(
    name = "backend.targets",
    expression = "deps(//examples/native_project:project.ci_gates) + deps(//examples/native_project:project_none.ci_gates)",
    scope = [
        ":project.ci_gates",
        ":project_none.ci_gates",
    ],
)

sh_test(
    name = "no_pipeline_file_test",
    srcs = ["no_pipeline_file_test.sh"],
    data = [":backend.targets"],
    env = {"TARGETS": "$(rootpath :backend.targets)"},
)

examples/pipeline/BUILD.bazel

load("@rules_ci//ci:defs.bzl", "ci_job", "ci_pipeline", "ci_publish")
load("@rules_shell//shell:sh_test.bzl", "sh_test")

# Smoke test of the //ci re-export: express a pipeline through @rules_ci//ci and confirm the
# runtime (from rules_ci_ir) resolves through rules_ci.
#   bazel test  //examples/pipeline:demo
#   bazel build //examples/pipeline:demo.manifest
package(default_visibility = ["//visibility:public"])

# A test carrying its OWN tags — the shape that exposed the vacuous-suite bug.
# Every real test has tags (`savvi_ts_package` stamps `unit`, sizes imply others),
# and none of them carry `ci-job` / `ci-stage=…`, so a tagged job suite filtered
# them all out. Kept deliberately tagged: an untagged test would not reproduce it.
sh_test(
    name = "tagged_unit_test",
    srcs = ["tagged_unit_test.sh"],
    tags = ["unit"],
)

ci_pipeline(
    name = "demo",
    jobs = [
        ci_job(
            name = "unit",
            script = ["echo ok"],
            stage = "test",
        ),
        # `test =` (aliasing an existing test) rather than `script =`. Until this
        # example existed, every example used `script =`, so the alias path — the
        # one every migrating repo actually uses — was never exercised.
        ci_job(
            name = "aliased",
            stage = "test",
            test = ":tagged_unit_test",
        ),
        ci_publish(
            name = "pub",
            artifact = ":unit.sh",
            destination = "s3://example/demo/",
            kind = "static_cdn",
        ),
        # `npm` kind: `artifact` is an `npm pack`-layout tarball and `destination`
        # is the registry URL. Not a real tarball here — this only pins that the
        # kind resolves and lands in `demo.pipeline.json`; the runner's own
        # dry-run keeps `bazel run` harmless without credentials.
        ci_publish(
            name = "pub_npm",
            artifact = ":unit.sh",
            destination = "https://registry.example.com/api/v4/projects/1/packages/npm/",
            kind = "npm",
        ),
    ],
)

# The vacuity gate is now GENERATED by ci_job — `//examples/pipeline:aliased.not_vacuous_test`
# — so the hand-written genquery + sh_test that used to live here is gone. This
# package still exercises the shape that broke: `ci_job(test = ...)` over a test
# carrying its own unrelated tags.

Rules & providers#

Generated with Stardoc from the module's .bzl sources.

from docs/DESIGN.md

rules_ci — design

A neutral intermediate representation (IR) for CI pipeline configuration, with proved-correct translations between

  • GitLab CI YAML (.gitlab-ci.yml)
  • GitHub Actions YAML (.github/workflows/*.yml)
  • Bazel rule invocations (sh_test / genrule / py_test / custom)

The repo is in scaffold state — pinned schemas, Rust workspace skeleton, Bazel rule stubs, and this design doc are live; the Lean 4 IR + correctness theorems are placeholders pinned by the roadmap below.

Why an IR at all

Direct translations between three pairs of CI/build systems would require six translators (and grows quadratically as more targets are added). With a single IR in the middle, we instead need:

  • N parsers (one per source format)
  • N emitters (one per target format)
  • Theorems about parser/emitter pairs, not about every direction.

A neutral IR also forces a sharp question: what is a CI pipeline, structurally? The translation work depends entirely on that abstraction holding up across three very different surface representations.

What “provably correct” means here

Three increasingly ambitious levels of correctness:

LevelClaimStatus
A. StructuralParser is total over valid-schema inputs. Round-trip identity holds modulo normalization: parse · emit · parse ≡ parse. DAG invariants preserved: no cycles in needs / dependencies, every artifact consumer has a producer, every job’s stage exists in the stages list.Targeted for v0.1.
B. Bisimulation under abstract semanticsDefine an opaque execution model: a job consumes inputs, runs an unspecified action, produces outputs. Prove gitlab.exec ≃ ir.exec ≃ github.exec under that model. Captures scheduling, dependency ordering, artifact flow — but not what script: [pytest] actually executes.v0.3+ goal.
C. Runtime equivalenceModel bash, the runner environment, container state, network. “Translated YAML exits with the same code as the original.”Explicit non-goal. A research project, not a release.

The repo commits to level A as a hard guarantee, layers level B as work progresses, and treats level C as out of scope.

Lean-as-verifier, not Lean-as-runtime

Two natural ways to integrate Lean 4:

  1. Runtime: compile the Lean translator binary, run it inside Bazel actions at build time. Pro: the proofs cover the actual running code. Con: Lean → C compile is slow, deployment story is heavy, and rewriting parsers in Lean is high-friction.

  2. Verifier: Rust is the language of the actual translator; Lean formalizes the IR + the spec of each parser / emitter; a reference implementation in Lean is extracted (or hand-mirrored) and proven correct; property-based tests fuzz-generate schema-valid inputs and check that the Rust translator matches the Lean spec on every input. This is the Verus / Creusot model.

We pick verifier. The Rust translator is the production artifact. Lean provides:

  • The IR’s algebraic types (canonical reference).
  • Theorems on parser totality, round-trip identity, and invariant preservation.
  • Reference implementations for each parser/emitter that the Rust code is fuzz-tested against.

The Lean code lives under ir/; the Rust workspace lives under translator/. The two are kept in sync by convention + a TODO-tracked roadmap of “Lean theorem proved → matching Rust property test landed.”

The IR

The IR is a single algebraic type, designed to be the least-upper-bound of what GitLab CI and GitHub Actions express. Sketch (Lean syntax, illustrative):

structure Job where
  name      : String
  stage     : String                  -- "build", "test", ...
  needs     : List String             -- predecessor job names
  rules     : List Rule               -- when this job is selected
  env       : Map String String       -- key=value
  image     : Option String           -- container reference
  script    : List String             -- shell lines, opaque
  artifacts : List ArtifactSpec
  cache     : List CachePath
  outputs   : Map String String       -- exported step outputs
  publish   : Option Publish          -- ship the build to a hosting surface
  deriving Repr, BEq

inductive Publish where               -- structured publish targets
  | pages (path : String)             -- publish `path` as the repo's Pages site
  deriving Repr, BEq

structure Pipeline where
  stages    : List String             -- ordered phase names
  variables : Map String String
  defaults  : Job                     -- inherited shape
  triggers  : List Trigger            -- which events run the pipeline
  jobs      : List Job
  includes  : List Include            -- cross-pipeline composition
  deriving Repr

The IR is not the union of every GitLab and GitHub feature — it’s the intersection of what can be translated faithfully. Source features that fall outside (e.g. GitLab’s nested extends, GitHub’s reusable workflows with secrets-inheritance) are either:

  • Lowered to the IR at parse time when an equivalence exists (e.g. extends resolved before IR emission).
  • Emitted as a translation diagnostic (unsupported feature X at path Y) when not.

Diagnostics are first-class outputs; a translation that emits diagnostics is still a translation, but downstream consumers (the Bazel rule, the registry) can treat them as warnings or hard errors.

Pages — the first publish node

publish is where the IR earns its keep most visibly: “publish this directory as the repo’s static site” is one intent the two backends realize with almost nothing in common. A job carrying publish := some (.pages "public") lowers as:

gitlab-emit — GitLab Pages is a magic job named pages whose public/ artifact is served (the publish dir is public/, or set via pages.publish: on 17.x+):

pages:
  stage: pages
  script:
    - <job.script>            # build the site into public/
  artifacts:
    paths: [public]
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

github-emit — GitHub Pages is a workflow that uploads the directory as a Pages artifact and deploys it, with the right permissions and environment:

jobs:
  pages:
    runs-on: ubuntu-latest
    permissions: { pages: write, id-token: write }
    environment: github-pages
    steps:
      - run: <job.script>                       # build the site
      - uses: actions/upload-pages-artifact@v3
        with: { path: public }
      - uses: actions/deploy-pages@v4

The shared core — job.script builds the site, then a directory is published — is exactly one Publish.Pages path. Everything else (the magic job name + public/ artifact vs. the upload/deploy actions + permissions + environment) is emitter-private. rules_gitlab’s gitlab_pages_job macro is the hand-written precursor of the gitlab-emit rendering; promoting it here means a repo authors the publish once, in the neutral IR, and gets both backends.

Structural invariant (level A): at most one job may carry a Publish.Pages, and gitlab-emit names that job pages.

Schemas + pinning

We pin the canonical upstream schemas (sha256), refreshed via a documented workflow:

  • GitLab CI: gitlab.com/gitlab-org/gitlab-foss/.../editor/schema/ci.json (already in use by rules_gitlab).
  • GitHub Actions: json.schemastore.org/github-workflow.json (mirror of SchemaStore’s curated version).

Bazel has no JSON Schema — but each spec-derived rule (in the style of rules_cloudformation) exposes a typed attr set that projects as a JSON Schema. The Bazel-emit side translates IR jobs into rule invocations against a fixed runtime: a small ci_job(name, script, deps, ...) Bazel rule built into this module (see Bazel emit).

Translator surface

Implemented in translator/ as a Cargo workspace with these crates:

CrateWhatStatus
ci-irPure-Rust IR types + invariants + property test helpers.scaffold
gitlab-parse.gitlab-ci.yml → IR. ruamel-yaml-equivalent + custom-tag absorber.scaffold
github-parse.github/workflows/*.yml → IR.roadmap
gitlab-emitIR → .gitlab-ci.yml.roadmap
github-emitIR → GitHub Actions workflow.roadmap
bazel-emitIR → Bazel rule invocations. Uses starlark crate’s AST to emit .bzl.roadmap
aggregatorN members → fleet IR + Markdown similarity report. Subsumes the savvi-side Python aggregator.scaffold (smoke)
ci-translate-clici-translate --from gitlab --to github < input.yml > out.yml.roadmap

Bazel rule surface

User-facing Bazel rules in rules/defs.bzl:

RuleStatusWhat
ci_yaml_translate(name, src, from, to)stubTranslate a single CI YAML file. from/to ∈ {gitlab, github, bazel}.
ci_yaml_aggregate(name, members)stubFleet-wide aggregation + Markdown report (Rust reimplementation of the savvi ci_analysis rule).
ci_yaml_diff(name, a, b)roadmapStructural diff of two CI YAMLs at the IR level — catches reordering-only changes vs. real diffs.

These are stubs that exec the relevant Rust binary from @rules_ci_crates. Once the crates are populated, the stubs become real.

Bazel-emit

bazel-emit is the trickiest part. The IR’s notion of a “job” is a black-box shell script with explicit deps + artifacts; Bazel’s hermeticity requires either:

  1. Wrap the script as sh_test: sh_test(name = "<job>", srcs = ["<job>.sh"], deps = [...]) where the deps are the predecessor jobs’ outputs. Lossy in the other direction (Bazel deps are at content-level granularity, but a CI needs: is at job-completion-level granularity — close enough for the structural translation).
  2. Generate scaffold + leave the heavy lifting to humans: emit the dependency wiring and let the user fill in real Bazel rule types. Less invasive; more useful as a migration tool.

We start with (1) — explicit sh_test + genrule emission. The output is a single <name>.bzl file consumed by a downstream load(...) in a hand-written BUILD.bazel. The Rust starlark crate (https://github.com/facebookexperimental/starlark-rust) provides AST + pretty-printing so the emitted file is canonical (stable across rebuilds, easy to diff).

Concrete shape:

## Emitted: //path:bazel_pipeline.bzl
load(
    "@rules_ci//rules/runtime:defs.bzl",
    "ci_job",
)

def my_pipeline():
    ci_job(
        name = "build",
        stage = "build",
        script = ["uv sync", "uv build"],
        image = "registry/savvi-ops:ci-py3.13",
    )
    ci_job(
        name = "test",
        stage = "test",
        needs = ["build"],
        script = ["uv run pytest"],
    )

ci_job is a thin macro provided by rules_ci’s runtime that expands to a sh_test or genrule depending on artifacts.

Roadmap by version

VersionAdds
0.0.1 (this)Scaffold: design doc, schema pins, Rust workspace skeleton, GitLab parser stub, aggregator stub, Bazel rule stubs. No Lean code yet.
0.1.0Working GitLab parser → IR; aggregator Rust binary that replaces the savvi ci_analysis Python aggregator (same Markdown report); ci_yaml_aggregate rule.
0.2.0github-parse. GitLab ↔ IR round-trip property tests.
0.3.0gitlab-emit + github-emit. End-to-end ci-translate --from gitlab --to github works. Includes the Publish.Pages node (IR type landed in 0.0.1) — both emitters render a pages publish (see Pages — the first publish node).
0.4.0bazel-emit + the ci_job runtime macro. GitLab → Bazel sh_test graphs. The neutral ci_pages(site) authoring macro lands here, superseding rules_gitlab’s gitlab_pages_job.
0.5.0Lean 4 IR formalization + structural correctness theorems (level A above). Property tests are extracted/mirrored.
0.6.0+Bisimulation theorems under abstract semantics (level B).

Refresh procedure

Every quarter, re-fetch the pinned schemas:

curl -fL https://gitlab.com/gitlab-org/gitlab-foss/-/raw/master/app/assets/javascripts/editor/schema/ci.json -o /tmp/gitlab-ci.json
shasum -a 256 /tmp/gitlab-ci.json
## paste into schemas/extensions.bzl

curl -fL https://json.schemastore.org/github-workflow.json -o /tmp/github.json
shasum -a 256 /tmp/github.json
## paste into schemas/extensions.bzl

Each pinning bump should be accompanied by a CHANGELOG entry listing any new schema features and whether they require IR extensions.

from docs/VERSIONING.md

Automated versioning + tag-push (rules_ci)

A WORKFLOW pushes pkg@x.y.z tags — never a dev, never the user. On merge to protected main, per discovered product, the pipeline computes the required SemVer bump from the PUBLIC SURFACE diff and pushes the tag under a bot identity; the existing idempotent publish lane fires on the tag. This replaces hand-maintained version bumps and manual changeset files with a deterministic, surface-driven decision (escalating only the genuinely-ambiguous cases to a judged verdict).

Pipeline

merge to main


release_artifacts aspect  ──►  per npm product: { name, version_source(package.json), surface(.d.ts*) }


version/surface.mjs  ──►  CURRENT surface (normalized exported decls + hash)   [per product]
   │                       fetch PREVIOUS surface (npm pack last tag → .d.ts, or stored artifact)

version/diff.mjs    ──►  changes + DETERMINISTIC bump floor
   │                      • removed export → major   • added export → minor
   │                      • changed shape  → AMBIGUOUS

version/escalate.mjs ──►  resolve ambiguous → bump  (conservative default, OR judged verdict)


version/version.mjs ──►  VersionDecision { from, to, bump, tag, rationale }   [0.x degrade]


tag-push (bot identity, tag-protection-admitted) ──►  publish lane fires on `pkg@x.y.z`

The deterministic core (BUILT + PROVEN — version/)

  • surface.mjs — extracts the public surface from a package’s .d.ts entry via the TS compiler API: a sorted, comment/whitespace-NORMALIZED list of exported declarations ({name, kind, signature}) + a stable surface_hash. Cosmetic edits (doc comments, reformatting) are NOT surface changes. The .d.ts inputs come from the build graph (ReleaseArtifactInfo.surface, captured by the release_artifacts aspect).
  • diff.mjs — diffs two surfaces → changes + a SemVer bump. DETERMINISTIC for add/remove (added→minor, removed→major); a shape CHANGE of an existing export is marked ambiguous (a text diff can’t reliably tell breaking from additive) and routed to escalation.
  • escalate.mjs — the AMBIGUOUS-change seam. SAFE BY DEFAULT (no AI required): absent a backend, ambiguous → conservative FASTVERK_VERSION_AMBIGUOUS_DEFAULT (default major) so it never under-bumps. PRECISE WHEN WIRED: FASTVERK_VERSION_ESCALATE_CMD (the headless claude -p entrypoint) gets the ambiguous changes on stdin, returns {bump, rationale}.
  • version.mjs — orchestrator: bump = max(deterministic, escalation), then applyBump with 0.x SemVer degradation (every @aion/* package is 0.x: breaking→minor, additive→patch; normal mapping once a package hits 1.0.0). Emits a VersionDecision.

Proven on version/fixtures/{base,add,remove,change}.d.ts: add→0.2.6, remove→0.3.0, change(no backend)→0.3.0 conservative, change(wired backend says minor)→0.2.6, no-change@1.4.0→1.4.0.

Schema — the DTOs ARE protos (fastverk.release.v1)

The surface/diff/decision DTOs cross the build→tool→tag boundary, so per “DTOs are protos” they are the canonical proto/fastverk/release/v1/release.proto messages (Surface, TsExport, Product, ReleaseManifest, SurfaceChange, SurfaceDiff, VersionDecision) — the schema of record, not ad-hoc JSON.

  • Compiled HERMETICALLY by bazel//proto/fastverk/release/v1:release_proto (rules_proto 7.1.0 + protobuf 33.4; protoc from the toolchain, never the system one). The build emits release_proto-descriptor-set.proto.bin, the single cross-language source of truth.
  • The Node version/ tools serialize via that descriptorversion/proto.mjs loads the bazel-emitted descriptor set with protobufjs (no .proto re-parse, no codegen step) and does proto-wire encode/decode + verify at the I/O boundary. The tools keep ergonomic JS objects internally (lowercase enum strings); proto.mjs maps them to the proto messages (camelCase fields = the proto3-JSON canonical form; Bump/Op enum-name maps).
  • Cross-language ready: a future Lean emit-boundary surface extractor consumes the same descriptor — add a Lean arm field to Surface (proto3 forward-compat) when it lands.
  • Proven: surface.mjs --binary emits a Surface on the wire; Surface/SurfaceDiff/ VersionDecision all round-trip encode→decode, and verify() rejects malformed messages.

The escalation backend (GREENFIELD — designed, not built)

There is NO fastverk/mcp repo today (confirmed). The seam is ready; the backend is the next infra step:

  • A small headless entrypoint: claude -p given ONLY the ambiguous changes + a strict JSON-schema’d {bump, rationale} output, scoped by an MCP ensemble (fastverk/build’s bazel query/cquery + surface introspection) so it answers from the graph, not guesses.
  • Verdict is a pure function of the ambiguous-change set → cache by surface_hash pair.
  • Baked into the CI image (infra/images → aion/build) and exposed as FASTVERK_VERSION_ESCALATE_CMD. Until then the conservative default keeps releases SAFE.

Tag-push + bot identity (GATED — outward config)

  • The release lane (ci/aion-release.gitlab-ci.yml) runs on main, computes the decision, writes package.json version, and pushes pkg@x.y.z. The proven tag-push pattern (from platform/studio release.yml): git push origin <tag> under a token; on tag-collision append a timestamp suffix. The existing idempotent publish.mjs fires on the tag.
  • Bot identity: a dedicated release-bot user (recommended) or the [[aion-carve-ci-token]] group-195 token, admitted by tag protection (v* / *@* tags pushable only by the bot). Tag protection + the bot user are the gated outward steps (no git tags exist in the carves yet; main is Maintainer-only).
  • Previous-surface source: the lane npm packs the last published version and extracts its .d.ts for --previous (no rebuild-at-tag needed). Alternative: store the surface JSON as a release artifact per tag.

Rollout (gated)

  1. Bake version/*.mjs + publish.mjs into the CI image (stop per-repo vendoring).
  2. Add the release lane to the shared aion CI templates (include:-ed by every repo).
  3. Stand up the escalation backend; set FASTVERK_VERSION_ESCALATE_CMD.
  4. Create the release-bot identity + tag protection.
  5. Pilot on one carve, then fan out via wave/forge.

Conformance#

No gate findings. 15 contested atoms. See how gating works or the full report.

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_ci in the registry graph — what it depends on (left) and what depends on it (right).

Depends on

platforms1.0.0bazel_skylib1.8.2rules_shell0.6.1rules_gitlab0.3.2rules_readme0.0.3rules_python1.7.0rules_proto7.1.0devprotobuf33.4devrules_rust0.70.0devstardoc0.7.2dev

Used by (1 in the registry)

Versions#

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

VersionIntegrity (sha256)Source archive
0.3.0 latest Xae0qeDrOD68R1M9… tag archive ↗
0.2.0 adey0s8yOMiBkP4h… tag archive ↗
0.1.0 +WjXYTOpYFPFtiBr… tag archive ↗
0.0.3 iGVv9IJ8YkVQ7Rx7… tag archive ↗

Changelog#

All notable changes to rules_ci. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.

0.3.0 — @rules_ci//platforms:rbe

The fastverk RBE execution platform, defined once instead of copied per repo.

container-image is the buildbarn scheduler’s ROUTING KEY and part of the RBE action-cache key — it must byte-match the worker pool’s advertised platform or the scheduler answers “No workers exist for … container-image=…”. A value with that property, copied into every consumer, drifts, and it had: aion/lean and aion/e2e both declared the stock ghcr.io/catthehacker/ubuntu:act-22.04 while the RbeCluster worker advertised …/tbzl-rbe-worker:act-22.04-libtinfo5-py3 — two different pools, one of them missing the libtinfo5 that the prebuilt LLVM clang links.

  • @rules_ci//platforms:rbe — use with --extra_execution_platforms=@rules_ci//platforms:rbe.
  • FASTVERK_RBE_CONTAINER_IMAGE / FASTVERK_RBE_EXEC_PROPERTIES in //platforms:defs.bzl, for consumers that set the exec property directly.
  • defs.bzl records why the value is a mutable tag rather than the digest pin the rbe-api contract prefers: the platform string is in every action-cache key, so a digest discards the whole cache on any image change. That is survivable with hermetic toolchains and stops being survivable once a toolchain comes from the image — which makes this constant the thing that must become a digest BEFORE host toolchains are safe.

Ships alongside the 0.2.1 ci_job fixes below, which had not been released.

0.2.1 — ci_job(test = ...) no longer produces an empty gate

ci_job(test = X) built test_suite(tests = [X], tags = ["ci-job", "ci-stage=…"]), and a test_suite’s tags FILTER its direct members. No ordinary test carries ci-job, so the suite resolved to EMPTY and bazel test //ci:<job> passed having run nothing.

That is the worst failure mode a gate has: it does not break, it reports green while verifying nothing — and it did so for exactly the targets people name explicitly as jobs, which tend to be the security-shaped ones. Found in aion/graph (//ci:graph_server_tests → 0 tests, guarding a deprovision blast radius) and aion/idp (//ci:unit → 0 tests).

It hid because a job aliasing something that was ITSELF a test_suite expanded correctly — nested suites are expanded, not filtered — so the breakage looked target-specific. Every in-repo example used script =, so the alias path was never exercised.

  • ci_job(test = ...) now routes the aliased target through an untagged inner <name>.tests suite; the outer suite keeps job_tags for introspection / the IR round-trip. NB the generated <name>.tests name is new and can collide.
  • ci_job(test = ...) now also GENERATES <name>.not_vacuous_test, a per-job gate asserting the job expands to ≥1 test. The fix above removes the cause we know about, not the failure mode: a test_suite with no matching members resolves to nothing rather than erroring, so aliasing an empty suite still yields a job that passes having run zero tests. Vacuity is invisible in bazel test output and in pipeline.json (which records job labels, not the tests behind them), so nothing else here can notice it. Per-job, not per-pipeline: tests(<pipeline>) is non-empty as long as ANY job has tests, so a pipeline-wide check cannot localize — or even detect — one hollow job among several. Opt out with vacuity_gate = False. script = jobs get no gate; they cannot be vacuous.
  • Verified both directions: the gate FAILS (naming the job) against the pre-fix macro and passes after.

0.2.0 — ci_publish(kind = "npm")

Adds npm to PUBLISH_KINDS, so a repo that publishes an npm package can go yaml-free. Until now ci_publish covered static_cdn | site | oci | github_release, which meant any repo whose deliverable is an npm package had to keep a .gitlab-ci.yml purely to run npm publish — the exact thing //ci exists to delete. First consumer: aion/sql, whose @aion/db-migrations-sql (the aion golden-schema migrations.zip, consumed by studio/web) lost its only publish lane when the repo went fastverk-only.

  • ci_publish(name, artifact = <npm tarball>, kind = "npm", destination = <registry url>). artifact is an npm pack-layout tarball (a package/ root); destination is the registry URL, e.g. https://gitlab.example.com/api/v4/projects/<id>/packages/npm/.
  • Auth from the runner env, first match wins: NPM_TOKEN, GITLAB_NPM_TOKEN, CI_JOB_TOKEN, GITLAB_TOKEN. A temporary NPM_CONFIG_USERCONFIG carries the _authToken line keyed by the registry’s scheme-less URI prefix (how npm matches auth).
  • Idempotent: a published version is immutable, so the runner skips when the exact name@version already exists rather than failing — re-running a pipeline on an unbumped commit is a no-op. This preserves the publish.mjs convention the GitLab jobs used. name/version are read out of the tarball’s package/package.json with node (which ships with npm, so no new dep).
  • No credentials, or FASTVERK_PUBLISH_DRYRUN=1 → logs the intended publish and exits 0, like every other kind.

No IR/proto change: pipeline.json already carries kind as an opaque string.

0.1.0 — the yaml-free CI runtime (//ci)

Adds @rules_ci//ci:defs.bzl — the vendor-neutral runtime a repo expresses its pipeline in so it can delete .gitlab-ci.yml (readiness C2), the complement to //project (the GitLab-CI generator):

  • ci_job(name, script | test, stage, needs, image, …) — a hermetic job. script (shell lines) → sh_test; test aliases an existing test / test_suite (for Bazel-native repos, e.g. //ci:pr_gates). needs become data (the structural approximation of a GitLab needs: edge).
  • ci_publish(name, artifact, kind, destination|repo/tag/asset, needs) — a side-effecting publish job → an sh_binary you bazel run (creds from the runner env). kindstatic_cdn | site | oci | github_release.
  • ci_pipeline(name, jobs) — test jobs → a test_suite(name) so bazel test //ci:<name> is the gate; publish jobs → <name>.pipeline.json, the machine- readable Build.publish[] contract the fastverk build-runner replays.
  • //ci:publish_runner.sh — the reference dispatcher per publish kind (aws s3 / oras / gh release); a dry-run-safe no-op (FASTVERK_PUBLISH_DRYRUN=1 or missing creds) that logs the intended action.
  • rules_shell promoted to a non-dev dependency (the runtime expands to sh_test/sh_binary).

0.0.1 — initial scaffold

Ships:

  • Design doc at docs/DESIGN.md covering the IR design, the three semantic levels of “provably correct,” the Lean-as-verifier (not Lean-as-runtime) integration model, and the roadmap through v0.6.0.
  • Pinned upstream JSON Schemas for GitLab CI (gitlab-org/gitlab-foss, sha 1e4a59db…) and GitHub Actions (SchemaStore mirror, sha 30e8f011…) via the ci_schemas module extension.
  • Rust Cargo workspace under translator/:
    • ci-ir — neutral IR types + structural validate (cycles, dangling needs, stage references).
    • gitlab-parse.gitlab-ci.yml → IR with custom-tag tolerance (GitLab’s !reference, !file, !base64).
    • ci-aggregator — fleet IR + Markdown similarity report. Direct Rust replacement for savvi’s Python ci_analysis aggregator.
  • Bazel rule stubs under rules/defs.bzl:
    • ci_yaml_aggregate(name, members) — fully working, backed by the Rust binary.
    • ci_yaml_translate(name, src, from_format, to_format) — public API stub. Lands in v0.3.0.
    • ci_yaml_diff(name, a, b) — public API stub. Lands in v0.4.0+.
  • Lean 4 placeholder under ir/ — formalization + theorems land in v0.5.0.

← All modules