Convert DevOps tool reports between equivalent formats. One small static
binary, udc.
CI/CD platforms accept one format per report type. The ecosystem produces dozens. So the tool you want and the platform you use routinely disagree:
| You run | It emits | Your platform wants |
|---|---|---|
cargo llvm-cov, nyc, gcov, pytest-cov |
LCOV | Cobertura |
| JaCoCo, Gradle, Maven | JaCoCo XML | Cobertura |
| ESLint, golangci-lint, PHP_CodeSniffer, ktlint | Checkstyle XML | Code Climate JSON |
| semgrep, CodeQL, gosec, bandit, Checkov | SARIF | Code Climate JSON / GitLab Security Report |
dotnet test, Cypress, Cucumber |
TRX, JSON, … | JUnit XML |
The usual workarounds are a fragile sed pipeline, a Python script nobody
maintains, or giving up on the report. udc is the boring alternative:
$ cargo llvm-cov --lcov --output-path lcov.info
$ udc -i lcov.info -t cobertura -o coverage.xml --source-root "$PWD"- Single static binary. No runtime, no interpreter, no dependency.
muslon Linux, so it runs inscratch,alpineand every minimal runner image. Under 2 MB. - Honest about loss. Formats do not carry the same information. Whatever
the target cannot express is reported on stderr —
lossy:when information is dropped,degraded:when a value had to be rewritten to stay schema-valid — and--strictturns either into a failed job. - Never guesses. Format detection refuses ambiguous input rather than silently producing a wrong report.
- Deterministic. The same input always produces byte-identical output, so reports diff cleanly and caches stay warm.
- Fixes paths. The single most common reason a converted coverage report shows 0% — see Troubleshooting.
# Linux / macOS
curl -fsSL https://raw.githubusercontent.com/pismy/universal-devops-converter/main/install.sh | sh# Windows
irm https://raw.githubusercontent.com/pismy/universal-devops-converter/main/install.ps1 | iexBoth scripts detect the OS and architecture, verify the published SHA-256 checksum and install the binary. Options:
curl -fsSL .../install.sh | sh -s -- --version v1.2.3 --install-dir ~/.local/bin| Option | Environment variable | Default |
|---|---|---|
--version <TAG> |
UDC_VERSION |
latest |
--install-dir <DIR> |
UDC_INSTALL_DIR |
/usr/local/bin if writable, else ~/.local/bin |
--no-verify |
— | checksum is verified |
Resolving latest reads GitHub's redirect rather than its API, so it does not
spend the unauthenticated rate limit. GITHUB_TOKEN is only consulted if that
lookup has to fall back to the API — which the PowerShell script always does.
Archives are attached to every release. From source, all you need is a Rust toolchain:
cargo install --git https://github.com/pismy/universal-devops-converterudc [OPTIONS] # convert (the default mode)
udc formats [--category <CAT>] # list formats and what each one loses
| Option | Default | Description |
|---|---|---|
-i, --input-file <PATH> |
- (stdin) |
Input file. Repeatable — see Merging. |
-o, --output-file <PATH> |
- (stdout) |
Output file. |
-f, --input-format <FMT>[@<VER>] |
auto |
Input format, or auto to detect it. |
-t, --output-format <FMT>[@<VER>] |
required | Output format. auto is not accepted. |
--source-root <PATH> |
— | Make absolute paths under this root repository-relative. |
--strip-prefix <PREFIX> |
— | Strip a literal path prefix. Repeatable. |
--strict |
off | Fail instead of warning when the conversion loses information. |
-v, --verbose |
off | Debug logging. RUST_LOG overrides it. |
--no-color |
off | Disable colors. NO_COLOR is honoured too. |
Every option can also be set through its UDC_* environment variable
(UDC_INPUT_FILE, UDC_OUTPUT_FORMAT, UDC_SOURCE_ROOT, UDC_STRICT, …),
which is often the tidier way to configure a CI job.
| Exit code | Meaning |
|---|---|
0 |
Conversion succeeded |
1 |
Runtime error: parse failure, impossible conversion, loss under --strict |
2 |
Usage error: unknown option, bad argument |
# LCOV from any language → the Cobertura report GitLab ingests
udc -i lcov.info -t cobertura -o coverage.xml --source-root "$PWD"
# JaCoCo → Cobertura, straight from a pipe
cat build/reports/jacoco.xml | udc -f jacoco -t cobertura -o coverage.xml
# Go: note the module path, which `go test` writes into every path
go test -coverprofile=coverage.out ./...
udc -i coverage.out -t cobertura -o coverage.xml --strip-prefix github.com/acme/proj
# nyc / Jest coverage-final.json → Cobertura
udc -i coverage/coverage-final.json -t cobertura -o coverage.xml --source-root "$PWD"
# PHPUnit's Clover report → Cobertura
udc -i build/logs/clover.xml -t cobertura -o coverage.xml --source-root "$PWD"
# Any linter with a Checkstyle reporter → GitLab Code Quality
eslint -f checkstyle . | udc -f checkstyle -t codeclimate-gitlab -o gl-code-quality.json
# ESLint's native output keeps more than its checkstyle reporter does
eslint -f json . | udc -t codeclimate-gitlab -o gl-code-quality.json
# SARIF (semgrep, CodeQL, gosec…) → GitLab Code Quality
semgrep --sarif | udc -f sarif -t codeclimate-gitlab -o gl-code-quality.json
# the mirror image: any linter → SARIF, for GitHub code scanning
eslint -f checkstyle . | udc -f checkstyle -t sarif -o results.sarif
# any SAST tool → GitLab's security dashboard
semgrep --sarif | udc -f sarif -t gitlab-sast -o gl-sast-report.json
# CycloneDX XML → JSON, keeping the spec version it came in as
udc -i sbom.xml -t cyclonedx-json -o sbom.json
# SPDX 2 → SPDX 3: `spdx3-json` is its own format, not `spdx-json@3.0`
udc -i sbom.spdx.json -t spdx3-json -o sbom.spdx3.json
# CycloneDX ↔ SPDX, in one binary
udc -i sbom.cdx.json -t spdx-json -o sbom.spdx.json
# downgrade a bill of materials for a consumer stuck on an older schema
udc -i sbom.json -t cyclonedx-json@1.4 -o sbom-1.4.json
# Trivy covers dependencies, images, IaC and secrets in one report — and one
# image scan yields two GitLab reports, each holding the half it can express
trivy image --format json acme/api:1.2.3 > trivy.json
udc -i trivy.json -t gitlab-container-scanning -o gl-container-scanning-report.json
udc -i trivy.json -t gitlab-dependency-scanning -o gl-dependency-scanning-report.json
# TAP, from node-tap, prove, or anything else that speaks it
tap --reporter=tap | udc -f tap -t junit -o junit.xml
# Go's test runner speaks NDJSON; a build failure becomes a failing case, not silence
go test -json ./... | udc -t junit -o junit.xml
# dotnet test writes TRX, which nothing outside the Microsoft toolchain reads
dotnet test --logger trx --results-directory .
udc -i TestResults/*.trx -t junit -o junit.xml
# Merge sharded test runs into a single JUnit report
udc -i results/shard1.xml -i results/shard2.xml -t junit -o junit.xml
# Fail the job rather than hand the platform a degraded report
udc -i lcov.info -t jacoco --strict--input-file is repeatable, and the merge follows each category's semantics:
- Coverage folds by file: hit counts are summed and line sets unioned, so a line uncovered in one shard and covered in another ends up covered. This is what you want for parallelised or matrix builds.
- Tests concatenates suites without folding them: two suites with the same name are two executions (shards, retries), and collapsing them would lose results.
- Quality concatenates findings and drops exact duplicates, which overlapping linter runs produce routinely.
Inputs may even be in different formats — detection runs per file, so they only have to belong to the same category:
# a JaCoCo backend report and an LCOV frontend report, merged into one Cobertura file
udc -i backend/jacoco.xml -i frontend/lcov.info -t cobertura -o coverage.xmlThat only works with --input-format auto (the default): an explicit -f
applies to every input.
Formats that exist in several specification versions take an optional
@<version> suffix. Today SARIF is the only one; the syntax is in place for
CycloneDX and SPDX when SBOM support lands:
semgrep --sarif | udc -f sarif@2.1.0 -t codeclimate-gitlab -o gl-code-quality.jsonWithout a suffix the format's default version is used — deliberately the most widely ingested one rather than the newest, because a report the platform rejects is worse than one missing a recent field. An unversioned format rejects the suffix instead of ignoring it:
$ udc -t junit@1.0
error: format 'junit' is not versioned: drop the '@1.0' suffix
$ udc -t sarif:2.1.0
error: 'sarif:2.1.0': use 'sarif@2.1.0' to pin a spec version ('@', not ':').Version breaks that are really model breaks — SPDX 3.0, SARIF 1.0 — get their own format id rather than a version suffix. SPECS.md §3.4 explains which is which and why.
udc formats is the authoritative list, and it tells you what each writer
gives up:
$ udc formats --category coverage
Any format readable in a category can be converted to any writable format of the same
category. `r` = can be read (input), `w` = can be written (output).
A versioned format accepts a `@<version>` suffix, e.g. `-t sarif@2.1.0`.
COVERAGE
lcov rw LCOV tracefile (gcov, nyc/istanbul, cargo-llvm-cov, tarpaulin)
aliases: lcov-info
degraded: branch block/index identity is synthesized
lossy: JaCoCo instruction/complexity/method counters are dropped
cobertura rw Cobertura XML — the coverage format GitLab CI ingests
aliases: coverage.py
lossy: JaCoCo instruction/complexity/method counters are dropped
jacoco rw JaCoCo XML report
degraded: hit counts are flattened to covered/not-covered (JaCoCo has no hit counter)
degraded: instruction counters are approximated when the source has noneToday:
| Category | Read | Write |
|---|---|---|
| Coverage | LCOV, Clover, Cobertura, Go, Istanbul, JaCoCo | LCOV, Clover, Cobertura, JaCoCo |
| Tests | JUnit XML, TRX, go test -json, TAP |
JUnit XML |
| Quality | Checkstyle, ESLint, SARIF, Code Climate | SARIF, Code Climate, Code Climate (GitLab) |
| Security | SARIF, Trivy | SARIF, GitLab SAST / dependency / container |
| SBOM | CycloneDX JSON/XML, SPDX JSON, SPDX 3 JSON-LD | CycloneDX JSON/XML (1.4–1.6), SPDX JSON (2.2 / 2.3), SPDX 3 JSON-LD (3.0.1) |
Any readable format converts to any writable format sharing a category. Converting a Checkstyle report into Cobertura is an error, not a best-effort guess. A format may belong to several categories — SARIF is both a quality and a security format, which is why it reaches the writers of both. Security, SBOM, accessibility and performance are on the roadmap — see SPECS.md §5.
Convert in after_script, not in script. after_script runs whether the
job succeeded or failed, so the analysis tool keeps its normal exit code — a
failing lint or a failing test suite fails the job, as it should — while the
report still gets converted and published. artifacts:when: always is what
makes the report survive the failure.
coverage:
script:
- cargo llvm-cov --lcov --output-path lcov.info
after_script:
- curl -fsSL https://raw.githubusercontent.com/pismy/universal-devops-converter/main/install.sh | sh -s -- --version v1.0.0
- udc -i lcov.info -t cobertura -o coverage.xml --source-root "$CI_PROJECT_DIR"
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
code_quality:
script:
# No `|| true`: ESLint fails the job on lint errors…
- eslint -f checkstyle . > checkstyle.xml
after_script:
# …and the report is published either way, because the redirect above wrote
# checkstyle.xml before ESLint exited non-zero.
- curl -fsSL https://raw.githubusercontent.com/pismy/universal-devops-converter/main/install.sh | sh -s -- --version v1.0.0
- udc -i checkstyle.xml -t codeclimate-gitlab -o gl-code-quality.json
artifacts:
when: always
reports:
codequality: gl-code-quality.jsonThree things worth knowing about this pattern:
after_scriptruns in a separate shell. Anythingscriptexported is gone; predefined variables such as$CI_PROJECT_DIRare still there, which is why the--source-rootabove works.- GitLab ignores the
after_scriptexit code. A failed install or a broken conversion leaves you with no report rather than a red job, so keep an eye on the job log — or move both back intoscriptwhen you would rather they be blocking. - Pin the version. It keeps the job reproducible:
latestmoves under you, and a pipeline that changes behaviour because a release happened is a pipeline you cannot bisect. Either the--versionoption or theUDC_VERSIONenvironment variable. It also skips the release lookup entirely, which is one fewer network call that can fail.
- name: Install udc
run: curl -fsSL https://raw.githubusercontent.com/pismy/universal-devops-converter/main/install.sh | sh
env:
GITHUB_TOKEN: ${{ github.token }}
UDC_VERSION: v1.0.0
- name: Test with coverage
run: cargo llvm-cov --lcov --output-path lcov.info
# Publish the report even when the previous step failed.
- name: Normalize the coverage report
if: always()
run: udc -i lcov.info -t cobertura -o coverage.xml --source-root "$GITHUB_WORKSPACE"Almost always a path mismatch, not a conversion bug. Platforms match a
report's file paths against the repository tree; LCOV records the build
machine's absolute paths (/builds/acme/proj/src/main.rs) and JaCoCo only
stores package + sourcefile. Neither is repository-relative.
Look at what came out:
$ grep filename coverage.xml | head -1
<class name=".builds.acme.proj.src.main" filename="/builds/acme/proj/src/main.rs" …>If the path is not what the platform would see from the repository root, fix it:
udc -i lcov.info -t cobertura -o coverage.xml --source-root "$CI_PROJECT_DIR"Go is the sharpest case. go test -coverprofile writes import paths —
github.com/acme/proj/main.go — which match nothing in the repository tree.
Strip the module path:
udc -i coverage.out -t cobertura --strip-prefix github.com/acme/projudc says so on stderr when it reads a Go profile and no rewriting was asked
for, and stays quiet once you have dealt with it.
--strip-prefix handles the cases --source-root cannot — a monorepo
sub-project, a container path that does not match the checkout:
udc -i lcov.info -t cobertura --strip-prefix /app --strip-prefix packages/apiPrefixes are tried in order and the first match wins; stripping only happens on
a path-segment boundary, so --strip-prefix src/app never mangles
src/apple/main.rs.
Detection found nothing conclusive, or the input is ambiguous — a bare JSON
array could be Code Climate or pa11y. Pass -f explicitly. This is by design:
guessing wrong produces a plausible-looking but incorrect report.
Conversion only exists within a category. Check udc formats for the category
each format belongs to.
udc -i lcov.info -t jacoco --strictBoth lossy: and degraded: notices count. Run without --strict first to see
what you would be enforcing.
Formats never see each other. Each category has a canonical pivot model; a format contributes a reader (format → pivot), a writer (pivot → format), or both.
lcov ─┐ ┌─→ cobertura
jacoco ─┼─→ [ CoverageDoc ] ───────────────┼─→ jacoco
cobertura┘ └─→ lcov
That keeps the implementation at 2N instead of N², makes cross-category conversion impossible by construction, and gives losses a single place to be detected and reported.
Reading is done with a parser that does not resolve external DTDs or
entities. That is deliberate: Cobertura reports carry a SYSTEM doctype, and a
resolving parser would turn every conversion into a network call and every
untrusted report into an XXE vector.
SPECS.md is the design document: category taxonomy, format roadmap with per-format status, the CLI contract, and the reasoning behind each choice.
cargo test # unit + end-to-end tests
cargo clippy --all-targets -- -D warnings
cargo fmt --checkAdding a format means writing one module under src/formats/<category>/ and
adding one entry to FORMATS in src/registry.rs. Nothing else in the codebase
enumerates formats. See CLAUDE.md for the conventions.
Commits follow Conventional Commits;
the release, changelog and binaries are produced by semantic-release on merge
to main.
MIT — see LICENSE.
