GitHub CI
Run dx in GitHub Actions with the reusable workflows. Copy the starters into
your repo and pin a reviewed commit.
cp examples/consumer-ci/caller.yml .github/workflows/ci.yml
cp examples/docs-ci/caller.yml .github/workflows/docs.yml
bazel_dep(name = "rules_dx", version = "0.0.0")
Pin Check
Every job runs bazel run @rules_dx//tools/ci:module_pin_bin before dx.
It checks the rules_dx declaration in MODULE.bazel. Pass that same version as rules_dx_version. A repository that
calls the workflow from its own tree declares itself instead:
module(
name = "rules_dx",
version = "0.0.0",
)
Bazel resolves rules_dx first. It stops with its own error before the
check runs if MODULE.bazel declares no rules_dx, declares it twice, or
pins a release without //tools/ci:module_pin_bin.
- A multiline call, reordered arguments and comments are fine.
- A commented-out pin never counts.
- Only the
rules_dxdeclaration counts. - A mismatched version, or one that is not a quoted string, fails with the line, the declared version and the expected one.
Consumer Workflow
examples/consumer-ci/ calls reusable-consumer.yml. Nine checks run on
every platform in platforms:
lint:dx lint --check //...typecheck:dx typecheck --check //...format:dx format --check //...generate:dx generate --check //...security-audit:dx security //...license-audit:dx license //...test:dx test //...build:dx build //...coverage:dx coverage //..., plus--min-coverage <percent>whenmin_coverageis set. Withmin_changed_line_coverageit also writes the LCOV report and gates the lines a pull request adds.
--check applies to the four quality checks. dx security, dx license,
dx test, dx build, and dx coverage reject it.
The validate (inputs) job runs first. It reads every input, rejects a bad one with the
accepted values, and starts no check.
Inputs:
rules_dx_version: the version yourrules_dxdeclaration carries. The pin check compares it withMODULE.bazel.disabled_checks: job IDs above to skip, comma-separated. Empty runs all nine. Surrounding spaces are ignored. An unknown ID, a repeated ID and an empty ID fail.platforms: JSON array of platform labels, such as["linux_x86_64"]. Omit it to run["linux_x86_64"]. Valid labels:linux_x86_64,linux_arm64,macos_arm64,windows_x86_64,windows_arm64. Malformed JSON, a JSON value that is not an array, an empty array, a repeated label, an unknown label and a label carrying spaces all fail.scheduling_mode:parallelonly.sequentialfails; checks always run in parallel.code_scanning_opt_in:falseonly.truefails; no SARIF upload step ships in this workflow.min_coverage: fails below that percent, a whole number from 0 to 100 such as80. Empty collects without enforcing a threshold.80%,80.5,-1,101,eightyand a leading space fail. Coverage comes fromdx coverage.min_changed_line_coverage: fails when fewer than that percent of the executable lines a pull request adds are covered, a whole number from 0 to 100. Empty skips the check. It accepts and rejects the same values asmin_coverage, and it never changesmin_coverage.invocation_id: 1 to 40 letters, digits, underscores or hyphens. Empty uploadsadvisory-snapshots, which suits one call per workflow run. Two calls in one workflow run, including matrix cells, each need their own ID, such asapi-linux; a repeated ID makes the second upload fail. Anything else fails. The ID also keeps the calls' concurrency groups apart, so one call never cancels another. A rerun reuses the same ID.
Changed-Line Coverage
Set min_changed_line_coverage to require coverage on new code without raising
the whole-repo threshold. The coverage job fetches full history, runs
dx coverage --report lcov=<path>, then runs diff-cover 10.6.0 over that
report. It runs on pull_request events only. Other events print a notice and
skip it.
The base is the pull request base commit. The head is the checked out HEAD.
Lines come from the diff between their merge-base and HEAD. Renames follow
the file, so a pure rename adds no lines. Deleted lines are not counted. Report
paths are mapped to workspace-relative paths and must match a changed file
exactly. An absolute path maps to the longest tracked path it ends with.
The check prints the base, merge-base and head commits, the threshold, the
covered and eligible line counts, whether the evidence is complete, and each
uncovered path:line. Lines the report does not list, such as comments and
docs, are not eligible. With no eligible line the result is not-applicable,
not 100 percent.
It fails when:
- fewer than the threshold percent of eligible lines are covered. The compare is exact, with no rounding.
- the base commit is not available, or the history is too shallow to reach a merge-base.
- the report is missing, empty, or not valid LCOV.
- the
diff-coveroutput lacks its line totals or per-file stats. - a changed file has the extension of files in the report but is not in it.
The result is
incomplete.
with:
rules_dx_version: "0.0.0"
min_changed_line_coverage: "80"
A rejected input fails in the validate job before any check starts. The nine
checks need it, so none of them run. dx-ci (aggregate) names the validation
failure and fails with them.
dx-ci (aggregate) passes only when every check succeeds. It fails when a check
fails or is cancelled, when a declared check never reports a result, and when a
check is skipped without being listed in disabled_checks. A check you disabled
stays distinguishable from a check that never ran.
When a pull request or push run is cancelled, the gate is skipped and uses no runner. In a merge queue the gate still runs and fails.
The workflow needs contents: read plus checks: write for check runs.
coverage also needs pull-requests: write for its pull-request comment.
The comment shows PASS or FAIL for the coverage threshold and the changed-line
gate, and every pull-request run updates it, including failed runs.
Pull requests use the read-only cache config automatically.
A called workflow can only narrow the calling job's token, so grant those
scopes on the calling job. examples/consumer-ci/caller.yml does.
Evidence Workflow
evidence.yml runs Bazel target patterns on hosted runners for native evidence
this repository cannot produce locally. Dispatch it per run; it never runs on
push:
gh workflow run evidence.yml --ref <branch> -f scope=//cli/cli/...
gh workflow run evidence.yml --ref <branch> -f scope="//cli/process/... //tools/testing/process_probe/..."
scope: space-separated target patterns such as//pkg/...or//pkg:target.task:test(default) orbuild.platforms: JSON array of platform labels. Accepted:linux_x86_64,linux_arm64,macos_arm64,windows_x86_64,windows_arm64. List each platform once.test_timeout: whole seconds each test may run. Default600.displays: Linux only.xcb,waylandorxcb,wayland. Starts Xvfb and a headless Weston and setsDX_QT_DISPLAYS. Default empty: no displays.
A validate job rejects a bad input naming the accepted values. Each platform
cell runs the task, uploads its log as the evidence-<platform> artifact, and
writes the tail to the job summary.
Apple Workflow
apple.yml runs the Apple toolchain tests on a hosted macOS runner with Xcode
preinstalled. Dispatch it per run:
gh workflow run apple.yml --ref <branch> -f command=test \
-f targets="//apple/... //apple/tests:macos_consumer_test //apple/tests:macos_app_consumer_test //apple/tests:macos_framework_consumer_test"
command:test(default) orbuild.targets: space-separated Bazel targets. Each must start with//.runner:macos-15(arm64, default).
Each run uploads the
apple-evidence-<runner> artifact with the executor's macOS, Xcode, and SDK
identities, the Bazel log, and the test logs and outputs.
Advisory Snapshots
security-audit reads .dx/advisory/. One
advisory snapshots (one run, every platform) job fills that directory once
per run, uploads it as the advisory-snapshots artifact
(advisory-snapshots-<invocation_id> when set), and every platform cell
downloads the same
bytes. No cell downloads a database of its own.
bazel run @rules_dx//cli/advisory_prep --
The command converts one archive per advisory family the dependency locks need,
so a repository with only Cargo.lock prepares the cargo snapshot alone. It
writes <out>/<family>.json and <out>/<family>.meta.json. The sidecar holds
the set, the source URL, the sha256 of the payload, the stamped date and the
payload path. The payload is the same for the same advisories whatever order
the archive lists them in.
Relative paths anchor at the workspace root.
--workspace <dir>: dependency set locks to read. Default the workspace root.--out <dir>: where the snapshots are written. Default.dx/advisoryunder it.--date YYYY-MM-DD: date every sidecar carries. Default today, UTC.--max-time <seconds>: wall clock limit for one download. Default 600.--connect-timeout <seconds>: connect limit for one download. Default 30.--retries <count>: attempts one download may spend. Default 3.--retry-delay <seconds>: pause between two attempts. Default 5.--archive FAMILY=PATH: convert a local archive instead of fetching it. Repeatable.--status-file <file>: appendstatus,ecosystemsandfamiliesaskey=valuelines. The workflow passes$GITHUB_OUTPUT.
A repository whose locks need no advisory ecosystem prepares nothing. The
command prints no advisory ecosystems needed, exits 0 and reports
status=empty and ecosystems=0. The job then uploads no artifact, the
security-audit cells download none, and secret scanning still runs. Dependency
sets without a lock are not audited. A missing artifact for a needed ecosystem
still fails the audit and dx-ci (aggregate).
Only https and file URLs are fetched, a redirect stays on https, and an
archive entry is refused when its length or checksum disagrees with the
archive. Exit codes: 0 success, 2 usage error, 1 preparation failure. A
failure prints one line starting with advisory_prepare_failed: naming the
family and the archive entry, path or limit it could not use, and writes no
snapshot for that family.
The artifact is kept for one day. Each security-audit cell judges freshness
against the date the job stamped, passed to dx security as DX_AUDIT_TODAY,
so every cell accepts or rejects the same snapshot. security-audit needs the
job, so a failed preparation skips every cell and dx-ci (aggregate) fails. A
missing, stale or corrupt snapshot fails the audit with advisory_refresh_failed
naming the set and the file. The job needs contents: read and no secret, so
fork pull requests run it.
Hermetic Tool Resolution
security-audit scans secrets with the pinned Gitleaks artifact, and dx
refuses ambient PATH lookup. One Resolve hermetic Gitleaks step resolves
the artifact path with a native command instead of shell parsing, so every
platform cell exports the same variable without host utilities.
bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:gitleaks --var DX_GITLEAKS_BIN
The command runs bazel cquery --output=files for the label, takes the last
output line as the artifact path, joins it with bazel info output_base, and
refuses a missing file. It appends VAR=path to the file GITHUB_ENV names.
--label <label>: pinned tool label to resolve. Required.--var <name>: environment variable receiving the absolute path. Required.--bazel <path>: Bazel binary the resolution runs through. Defaultbazel.--workspace <dir>: directory the inner Bazel commands run in. DefaultBUILD_WORKSPACE_DIRECTORY, thebazel runinvocation root, else the current directory.--env-file <path>: environment file receivingVAR=path. DefaultGITHUB_ENV.--print: writeVAR=pathto standard output instead of a file.- Arguments after
--reach the inner cquery unchanged.
Exit codes: 0 success, 2 usage error, 1 resolution failure. A failure
prints one line starting with tool_resolve_failed: naming the label and the
cquery, output-base, missing-file or environment write it could not use, and
exports nothing.
In a local shell, export the same variable with --print:
export "$(bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:gitleaks --var DX_GITLEAKS_BIN --print)"
Docs Workflow
examples/docs-ci/ calls reusable-docs.yml. It runs dx lint --check over
docs_scope (default //...), runs dx docs --check on site_check when
you set it, then renders your site label with dx docs and checks the
rendered tree for missing pages, styles, and search index, so broken links
fail before deploy.
Inputs:
rules_dx_version: the version yourrules_dxdeclaration carries. The pin check compares it withMODULE.bazel.docs_scope: target patterns covered by the docs check, separated by spaces, such as//... //docs:all. Default//.... Each pattern reachesdxas one argument. A value with quotes, shell characters, wildcards, a leading-or anything but a target pattern fails with one error naming it.publish: build the rendered site and deploy to Pages. Default false.environment: Pages environment name. Defaultgithub-pages.site: required. One target label in your repository whose output is the rendered mdBook tree, withindex.htmlandsearchindex.jsat its root.site_check: target label thatdx docs --checkruns. Default empty, which skips the source check.forbid: text the rendered site must not contain, one entry per line. Default empty.landing: also require a landing page in the rendered site. Default false.smoke_test: test label run withDX_PAGES_URLset to the deployed page URL. Default empty, which runs only the built-in page fetch.
A site, site_check or smoke_test value that is not a single target label
fails the docs check with one dx-ci: line naming the input.
Publishing needs pages: write plus id-token: write. The publish job stages
and validates the whole site tree, deploys it, fetches index.html and
searchindex.js from the reported page URL, then runs smoke_test if you set
one, all before the clean-checkout proof. Enable Pages with source GitHub
Actions before the first publishing run.
Bump Workflow
.github/workflows/bump.yml opens one dependency PR per run.
Create a GitHub App with contents: write and pull-requests: write.
Install it on the repository. Set two secrets:
BUMP_APP_ID: the App ID.BUMP_APP_PRIVATE_KEY: the App private key.
Dispatch the workflow with selector and version:
gh workflow run bump.yml -f selector=cargo:anyhow -f version=1.2.3
A run without both inputs lists outdated dependencies and changes nothing.
A dispatched run fails before changing files when the secrets are missing.
It pushes the branch and opens the PR as the App, so the PR runs the
required checks. Only the manifest, lock and pin files for the selected set
are staged. Other changed files fail the run. One run never batches two
dependencies. Set automerge to true to merge on the full required-check
set.
Repository Settings
Set these once in the repository. Each caller already grants its job permissions, so a read-only repository default is fine.
- Approval policy:
all_external_contributors, so fork pull requests run. - Branch protection for the consumer workflow: require
dx-ci (aggregate). One green gate covers the whole matrix. GitHub counts a skipped required check as passing, so rerun a cancelled pull request run before merging, or use a merge queue. - Branch protection for the docs workflow: require
docs-check. Adddocs-publish (GitHub Pages)oncepublishis true. - Merge gate: require branches to be up to date, or use a merge queue. Both
callers run on
merge_group(checks_requested), so the queue gets the required checks for its own SHA. Merge-group runs post no PR comment and never publish Pages. - Pages source: GitHub Actions, before the first publishing run.
- Workflow permissions: read-only by default. Each calling job then grants
what its reusable jobs request: the consumer caller
checks: writepluspull-requests: write, the docs callerpages: writeplusid-token: write.
Caching
Both callers forward repository secrets with secrets: inherit. Set
BUILDBUDDY_API_KEY to share the BuildBuddy remote cache. Codecov is
optional.
Every job restores the Bazel repository cache that Bazel and dx use. Each
platform shares one copy, keyed on MODULE.bazel and MODULE.bazel.lock.
On the default branch, coverage saves it. If coverage is disabled, test
saves it. If both are disabled, build saves it. With all three disabled,
nothing saves it. The saved copy holds only the downloads the saving job
used. Pull requests, forks included, merge queues and other branches only
restore it. Without BUILDBUDDY_API_KEY, build and test outputs
are not cached between runs.
With that secret set, every Bazel and dx call in both workflows runs with
--config=ci, plus --config=ci-pr on pull requests. Define both configs in
the repository .bazelrc before setting the secret. An undefined config fails
the run: Config value 'ci' is not defined in any .rc file.
common:ci --remote_cache=grpcs://remote.buildbuddy.io
common:ci --remote_cache_compression
common:ci --remote_timeout=10m
common:ci --remote_download_outputs=all
common:ci-pr --noremote_upload_local_results
Without the secret, no config flag is passed and no stanza is needed.