Most FHIR teams start validation in the same place: someone adds the HL7 validator to a CI pipeline, points it at a folder of example resources, and calls the build red when the validator reports errors.
That is a good first step. It is also easy to make it too heavy, too noisy, or too detached from the way developers actually work.
A useful FHIR validation gate should have four properties:
- It runs on every pull request.
- It validates the files developers are already changing.
- It prints failures in a format CI systems can consume.
- It leaves a path to deeper profile, terminology, baseline, and evidence workflows later.
The public @records-fhir/cli package is built for that first layer. It gives FHIR teams a free local validation gate that runs from npm, with JSON and JUnit output for CI. Teams that need configured server validation, baselines, drift detection, or release evidence can use the same CLI against a Records API.
The minimal GitHub Actions gate
Create .github/workflows/fhir-validation.yml:
name: FHIR validationon: pull_request: paths: - "fhir/**" - "examples/**" - ".github/workflows/fhir-validation.yml"jobs: validate-fhir: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Validate local FHIR resources run: | npx --yes @records-fhir/cli \ validate-file ./fhir \ --engine \ --format junit > fhir-validation.xml - name: Upload validation report if: always() uses: actions/upload-artifact@v4 with: name: fhir-validation-report path: fhir-validation.xml
This workflow does not require a Records account. It runs the CLI from npm, validates the local ./fhir folder, uses the local engine mode, and writes JUnit output so the result can be collected by CI tooling.
If your resources live somewhere else, change ./fhir to the directory that contains your test resources, sample bundles, or generated fixtures.
Why validate-file belongs in CI
FHIR validation is often treated as a certification or integration task: run a validator when a release is almost done, then fix whatever breaks. That is late. By the time CI finds a profile violation, the resource shape may already be baked into mapping code, test fixtures, API contracts, and UI assumptions.
A pull-request gate moves the feedback earlier. It does not prove that production data is valid, but it catches the obvious mistakes before they ship:
- missing required elements
- wrong resource types
- invalid references
- malformed bundles
- profile violations in examples or generated fixtures
- regressions introduced by mapping changes
That is the right scope for CI. Keep the gate narrow, deterministic, and cheap to run.
Make failures readable
For local debugging, plain terminal output is usually enough:
npx --yes @records-fhir/cli validate-file ./fhir --engine
For CI systems, machine-readable output is more useful:
npx --yes @records-fhir/cli validate-file ./fhir --engine --format jsonnpx --yes @records-fhir/cli validate-file ./fhir --engine --format junit
Use JSON when another script will parse the result. Use JUnit when your CI system or reporting layer expects test-style XML artifacts.
The important point is that validation output should survive the failed job. A red build with no artifact is a weak signal. A red build with a structured report is something the team can inspect, archive, and compare.
Pin the package when the gate becomes critical
For early experiments, npx --yes @records-fhir/cli is fine. Once the gate becomes part of release quality, pin the package:
- name: Validate local FHIR resources run: | npx --yes @records-fhir/cli@0.1.1 \ validate-file ./fhir \ --engine \ --format junit > fhir-validation.xml
Pinning avoids surprise behavior changes when a new CLI version is published. The same principle applies to profiles, terminology snapshots, and any validator dependency in your quality pipeline: if you want reproducible results, lock the inputs.
Add a Records API gate when local checks are not enough
Local validation is the first layer. It is good for pull requests and fast feedback. It does not replace configured validation against the same profiles, terminology assumptions, environments, and baselines your team uses for release decisions.
For that, run the same CLI against a Records API:
- name: Records validation gate run: | npx --yes @records-fhir/cli \ --api-url="${{ secrets.RECORDS_API_URL }}" \ --auth-token="${{ secrets.RECORDS_AUTH_TOKEN }}" \ validate-file ./fhir \ --format junit > records-validation.xml
That moves the workflow from a local file check to a configured Records validation run. The distinction matters:
- local CLI gate: fast, free, developer-facing
- Records API gate: configured, team-facing, connected to baselines and evidence
Use the local gate to stop obvious regressions. Use Records when the question becomes: did this release change validation status, drift from baseline, or produce evidence we can show later?
What CI still cannot answer
A GitHub Actions gate answers a narrow question: did this pull request introduce a validation problem in the resources we checked?
It does not answer whether production data is valid right now. It does not catch terminology drift that happens without a commit. It does not inspect legacy resources already stored in a FHIR server. It does not produce a longitudinal evidence trail by itself.
That is not a failure of CI. It is a boundary.
FHIR quality needs layers:
- local validation while developers build resources
- pull-request validation before changes merge
- release validation against configured profiles and baselines
- continuous validation against real environments
- evidence that records what changed, when, and under which validation inputs
The GitHub Actions gate is the easiest layer to add. Start there, keep it boring, and make the next layer explicit.
