rules_ci
Bazel rules + Rust translator + Lean 4 IR for provably correct translations between GitLab CI, GitHub Actions, and Bazel rules.
| Latest | 0.3.0 |
|---|---|
| Versions | 4 |
| Category | Bazel rules |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_ci/ |
| Source | github.com/tomato-bazel/rules_ci |
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
.bzldocstrings 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.bzldocstrings orREADME.md.tmpl, then runbazel 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
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
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Top-level targets whose discovered products are checked against expected. | List of labels | optional | [] |
| expected | Declared products as normalized “kind:name” entries (e.g. “npm:@aion/foo”). | List of strings | optional | [] |
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
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | Top-level targets to discover products under. | List of labels | optional | [] |
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
ReleaseArtifactsInfo
load("@rules_ci//release:defs.bzl", "ReleaseArtifactsInfo")
ReleaseArtifactsInfo(products)
Aggregate of every ReleaseArtifactInfo reachable from a target.
FIELDS
| Name | Description |
|---|---|
| products | depset 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
| Name | Type |
|---|---|
| deps | String |
| srcs | String |
| data | String |
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
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| gates | The bazel test target the hook runs (the ci_gates suite label, as text). | String | required | |
| hooks_dir | Working-tree dir for the hook; core.hooksPath is pointed here. | String | optional | ".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:
Nparsers (one per source format)Nemitters (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:
| Level | Claim | Status |
|---|---|---|
| A. Structural | Parser 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 semantics | Define 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 equivalence | Model 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:
-
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.
-
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.
extendsresolved 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:
| Crate | What | Status |
|---|---|---|
ci-ir | Pure-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-emit | IR → .gitlab-ci.yml. | roadmap |
github-emit | IR → GitHub Actions workflow. | roadmap |
bazel-emit | IR → Bazel rule invocations. Uses starlark crate’s AST to emit .bzl. | roadmap |
aggregator | N members → fleet IR + Markdown similarity report. Subsumes the savvi-side Python aggregator. | scaffold (smoke) |
ci-translate-cli | ci-translate --from gitlab --to github < input.yml > out.yml. | roadmap |
Bazel rule surface
User-facing Bazel rules in rules/defs.bzl:
| Rule | Status | What |
|---|---|---|
ci_yaml_translate(name, src, from, to) | stub | Translate a single CI YAML file. from/to ∈ {gitlab, github, bazel}. |
ci_yaml_aggregate(name, members) | stub | Fleet-wide aggregation + Markdown report (Rust reimplementation of the savvi ci_analysis rule). |
ci_yaml_diff(name, a, b) | roadmap | Structural 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:
- Wrap the script as
sh_test:sh_test(name = "<job>", srcs = ["<job>.sh"], deps = [...])where thedepsare the predecessor jobs’ outputs. Lossy in the other direction (Bazeldepsare at content-level granularity, but a CIneeds:is at job-completion-level granularity — close enough for the structural translation). - 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
| Version | Adds |
|---|---|
| 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.0 | Working GitLab parser → IR; aggregator Rust binary that replaces the savvi ci_analysis Python aggregator (same Markdown report); ci_yaml_aggregate rule. |
| 0.2.0 | github-parse. GitLab ↔ IR round-trip property tests. |
| 0.3.0 | gitlab-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.0 | bazel-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.0 | Lean 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.tsentry via the TS compiler API: a sorted, comment/whitespace-NORMALIZED list of exported declarations ({name, kind, signature}) + a stablesurface_hash. Cosmetic edits (doc comments, reformatting) are NOT surface changes. The.d.tsinputs come from the build graph (ReleaseArtifactInfo.surface, captured by therelease_artifactsaspect).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 markedambiguous(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 → conservativeFASTVERK_VERSION_AMBIGUOUS_DEFAULT(defaultmajor) so it never under-bumps. PRECISE WHEN WIRED:FASTVERK_VERSION_ESCALATE_CMD(the headlessclaude -pentrypoint) gets the ambiguous changes on stdin, returns{bump, rationale}.version.mjs— orchestrator:bump = max(deterministic, escalation), thenapplyBumpwith 0.x SemVer degradation (every @aion/* package is 0.x: breaking→minor, additive→patch; normal mapping once a package hits 1.0.0). Emits aVersionDecision.
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 emitsrelease_proto-descriptor-set.proto.bin, the single cross-language source of truth. - The Node
version/tools serialize via that descriptor —version/proto.mjsloads the bazel-emitted descriptor set with protobufjs (no.protore-parse, no codegen step) and does proto-wireencode/decode+verifyat the I/O boundary. The tools keep ergonomic JS objects internally (lowercase enum strings);proto.mjsmaps them to the proto messages (camelCase fields = the proto3-JSON canonical form;Bump/Openum-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 --binaryemits aSurfaceon the wire; Surface/SurfaceDiff/ VersionDecision all round-trip encode→decode, andverify()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 -pgiven 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_hashpair. - 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, writespackage.jsonversion, and pushespkg@x.y.z. The proven tag-push pattern (from platform/studiorelease.yml):git push origin <tag>under a token; on tag-collision append a timestamp suffix. The existing idempotentpublish.mjsfires 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.tsfor--previous(no rebuild-at-tag needed). Alternative: store the surface JSON as a release artifact per tag.
Rollout (gated)
- Bake
version/*.mjs+publish.mjsinto the CI image (stop per-repo vendoring). - Add the release lane to the shared aion CI templates (
include:-ed by every repo). - Stand up the escalation backend; set
FASTVERK_VERSION_ESCALATE_CMD. - Create the release-bot identity + tag protection.
- 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.
| 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 (1 in the registry)
Versions#
4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (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_PROPERTIESin//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>.testssuite; the outer suite keepsjob_tagsfor introspection / the IR round-trip. NB the generated<name>.testsname 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: atest_suitewith 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 inbazel testoutput and inpipeline.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 withvacuity_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>).artifactis annpm pack-layout tarball (apackage/root);destinationis 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 temporaryNPM_CONFIG_USERCONFIGcarries the_authTokenline 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@versionalready exists rather than failing — re-running a pipeline on an unbumped commit is a no-op. This preserves thepublish.mjsconvention the GitLab jobs used.name/versionare read out of the tarball’spackage/package.jsonwithnode(which ships withnpm, 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;testaliases an existing test /test_suite(for Bazel-native repos, e.g.//ci:pr_gates).needsbecomedata(the structural approximation of a GitLabneeds:edge).ci_publish(name, artifact, kind, destination|repo/tag/asset, needs)— a side-effecting publish job → ansh_binaryyoubazel run(creds from the runner env).kind∈static_cdn|site|oci|github_release.ci_pipeline(name, jobs)— test jobs → atest_suite(name)sobazel test //ci:<name>is the gate; publish jobs →<name>.pipeline.json, the machine- readableBuild.publish[]contract the fastverk build-runner replays.//ci:publish_runner.sh— the reference dispatcher per publishkind(aws s3 / oras / gh release); a dry-run-safe no-op (FASTVERK_PUBLISH_DRYRUN=1or missing creds) that logs the intended action.rules_shellpromoted to a non-dev dependency (the runtime expands tosh_test/sh_binary).
0.0.1 — initial scaffold
Ships:
- Design doc at
docs/DESIGN.mdcovering 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_schemasmodule 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.