Jsonnet Configuration
Crow supports Jsonnet as an alternative to YAML for defining pipeline configurations. Jsonnet is a data templating language that produces JSON (or YAML) output, enabling dynamic and programmable pipeline definitions that go beyond what static YAML can express.
Why Jsonnet?
Section titled “Why Jsonnet?”While YAML with anchors and aliases covers basic reuse, Jsonnet offers:
- Dynamic values: Compute matrix entries, image tags, or step lists programmatically instead of listing them statically.
- True variables: Define variables once and use them anywhere, including inside strings --- unlike YAML anchors which can only substitute entire values.
- Functions and abstractions: Write reusable functions to generate step definitions, reducing duplication across workflows.
- Conditionals: Branch pipeline logic based on CI metadata (repo, event, branch) at config parse time.
- Imports: Split configuration across multiple files with
importand share libraries across repositories.
Getting started
Section titled “Getting started”Create a .crow.jsonnet file (instead of .crow.yaml) in your repository root, or place .jsonnet files inside a .crow/ directory.
A minimal example:
{ steps: [ { name: "test", image: "golang:1.23", commands: [ "go test ./...", ], }, ],}This Jsonnet evaluates to a JSON object which Crow then parses as a workflow definition, just like YAML.
Full example
Section titled “Full example”The following example demonstrates a realistic CI pipeline for a Go project with linting, testing, and multi-database integration tests. It uses Jsonnet functions to eliminate the repetition that would otherwise require YAML anchors and copy-paste.
// Variables used across the pipeline. Unlike YAML anchors, these can be interpolated inside strings.local golangImage = "golang:1.26-alpine";local apkDeps = "apk add --no-cache -q just git gcc musl-dev";
// A helper function that generates a step with common defaults.// In YAML, every step that needs the Go image, the vendor dependency,// and the apk install line must repeat all of that.local goStep(name, commands, extra={}) = { name: name, image: golangImage, depends_on: ["vendor"], commands: [apkDeps] + commands,} + extra;
// Database test step generator --- three databases, same structure.local dbTestStep(name, driver, datasource, extra={}) = goStep( name, ["just test-server-datastore"], { environment: { CROW_DATABASE_DRIVER: driver, CROW_DATABASE_DATASOURCE: datasource, }, } + extra,);
// The pipeline definition.{ when: [ { event: "pull_request" }, { event: "push", branch: ["${CI_REPO_DEFAULT_BRANCH}"] }, ],
steps: [ // vendor step has no dependencies and no apk install { name: "vendor", image: golangImage, commands: ["go mod vendor"], },
goStep("lint", [ // Install linter from upstream script to always match the current Go version. "wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.9.0", "golangci-lint run", ], { when: [{ event: "pull_request" }] }),
goStep("test", [ "just test-agent", "just test-server", "just test-cli", "just test-lib", ]),
// Database integration tests dbTestStep("sqlite", "sqlite3", "", { // Override commands: sqlite uses a coverage variant commands: [apkDeps, "just test-server-datastore-coverage"], }), dbTestStep( "postgres", "postgres", "host=svc-postgres user=postgres dbname=postgres sslmode=disable", { when: [{ event: "pull_request" }] }, ), dbTestStep( "mysql", "mysql", "root@tcp(svc-mysql:3306)/test?parseTime=true", { when: [{ event: "pull_request" }] }, ), ],
services: [ { name: "svc-postgres", image: "postgres:17", ports: ["5432"], environment: { POSTGRES_USER: "postgres", POSTGRES_HOST_AUTH_METHOD: "trust", }, when: [{ event: "pull_request" }], }, { name: "svc-mysql", image: "mysql:9", ports: ["3306"], environment: { MYSQL_DATABASE: "test", MYSQL_ALLOW_EMPTY_PASSWORD: "yes", }, when: [{ event: "pull_request" }], }, ],}Compare this to the equivalent YAML.
The Jsonnet version avoids repeating the image name, depends_on, and apk add install line in every step, and the three database test steps are generated from a single function call each rather than three near-identical blocks.
Config file detection
Section titled “Config file detection”Crow searches for configuration files in the following order:
.crow/(directory: may contain.yaml,.yml,.jsonnet, and.libsonnetfiles).crow.yaml.crow.yml.crow.jsonnet.woodpecker/(directory).woodpecker.yaml.woodpecker.yml
The first match wins.
When using the .crow/ directory, Jsonnet and YAML files can coexist.
Each file becomes a separate workflow.
Dynamic matrices
Section titled “Dynamic matrices”One of the primary use cases for Jsonnet is generating matrix definitions programmatically:
local goVersions = ["1.21", "1.22", "1.23"];
{ matrix: { GO_VERSION: goVersions, }, steps: [ { name: "test", image: "golang:${GO_VERSION}", commands: [ "go test ./...", ], }, ],}A more advanced example that computes a matrix from structured data:
local platforms = [ { os: "linux", arch: "amd64" }, { os: "linux", arch: "arm64" }, { os: "darwin", arch: "arm64" },];
{ matrix: { include: [ { GOOS: p.os, GOARCH: p.arch, NAME: "%s/%s" % [p.os, p.arch] } for p in platforms ], }, steps: [ { name: "build", image: "golang:1.23", commands: [ "GOOS=${GOOS} GOARCH=${GOARCH} go build -o dist/app-${GOOS}-${GOARCH}", ], }, ],}Using CI metadata
Section titled “Using CI metadata”Crow passes all CI environment variables as Jsonnet external variables, accessible via std.extVar().
These are the same variables available as ${CI_REPO} etc. in YAML variable substitution.
local event = std.extVar("CI_PIPELINE_EVENT");
{ steps: [ { name: "test", image: "golang:1.23", commands: ["go test ./..."], }, ] + ( if event == "tag" then [{ name: "release", image: "goreleaser/goreleaser", commands: ["goreleaser release"], }] else [] ),}See Environment Variables for the full list of available variables.
Imports and libraries
Section titled “Imports and libraries”Jsonnet files can import other files using the import keyword.
Library files use the .libsonnet extension by convention and are not evaluated as standalone workflows.
.crow/├── lib.libsonnet├── build.jsonnet└── test.jsonnet.crow/lib.libsonnet:
{ goImage: "golang:1.23", step(name, commands):: { name: name, image: $.goImage, commands: commands, },}.crow/build.jsonnet:
local lib = import "lib.libsonnet";
{ steps: [ lib.step("build", ["go build ./..."]), ],}.crow/test.jsonnet:
local lib = import "lib.libsonnet";
{ steps: [ lib.step("test", ["go test ./..."]), ],}Multiple workflows from a single file
Section titled “Multiple workflows from a single file”If a Jsonnet file evaluates to a JSON array, each element becomes a separate workflow:
[ { name: "lint", steps: [ { name: "lint", image: "golangci/golangci-lint", commands: ["golangci-lint run"], }, ], }, { name: "test", depends_on: ["lint"], steps: [ { name: "test", image: "golang:1.23", commands: ["go test ./..."], }, ], },]This is equivalent to having two separate YAML files, one for each workflow.
Processing order
Section titled “Processing order”Jsonnet evaluation happens before all other pipeline processing steps:
- Jsonnet evaluation ---
.jsonnetfiles are evaluated to produce YAML/JSON - Matrix expansion ---
matrix:definitions generate workflow variants - Variable substitution ---
${CI_REPO}and similar placeholders are replaced - YAML parsing --- the final YAML is parsed into the internal workflow model
- Linting --- the parsed workflow is validated
- Compilation --- the workflow is compiled into executable steps
This means Jsonnet output can use all standard YAML features like matrix:, when:, depends_on:, ${VAR} substitution, and everything else documented in the workflow syntax reference.
CLI linting
Section titled “CLI linting”Jsonnet files can be linted using the CLI, just like YAML files:
crow-cli lint .crow.jsonnetcrow-cli lint .crow/When linting a directory, .jsonnet files are picked up alongside .yaml and .yml files.
Sibling .libsonnet files in the same directory are available for imports.