Skip to content

Release Process

This is what an IG owner does to turn merged FSH into a published version. Contributors do none of it — see Contributing and feedback.

It is published rather than kept internal for two reasons: if you have contributed FSH, this is what happens to it next; and if you are implementing against a released version, this is what "released" guarantees about the artifacts you consume.

Five commands

Command When What it does
npm run build every owner-track PR The gate. Compiles the IG with SUSHI, regenerates every consumer (TypeScript, Python, C#, and the docs/fhir/ pages of this site), then runs 18 read-only checks over the result -- example conformance, version agreement, canonicals, the export boundary, published URLs, invariants, and a check that regenerating changed nothing. No IG Publisher, no site build.
npm run build <x.y.z> cutting a version The same gate, with the version written into sushi-config.yaml and the changelog heading rewritten first. Use <x.y.z>-candidate to mark a branch as unreleased. Cutting lives here, in the cheap local command, so a failed cut leaves an ordinary working tree.
npm run test before a release, and after touching the library The library's own suite: the Python, .NET and TypeScript ports, patient-synth, and the cross-verifier. Deliberately NOT part of publish -- nothing in it can make a published profile, example or page wrong.
npm run site previewing Builds the site into .build/site/ and publishes nothing.
npm run publish publishing Release readiness, the gate, package.tgz, the site build, the push, and a check that the live site really serves every artifact. Add --dry-run to do everything except the push, --fast to verify with TypedFhir in process rather than the HL7 validator, --with-publisher to also run the IG Publisher as a cross-check.

Run the target, not the steps. The chain has an order that matters and every way of getting it wrong is silent -- it publishes something subtly wrong rather than failing. bin/publish.ts has the specifics if you need to debug it; nobody should have to hold them in their head.

There is no way to publish without the gate. The verification runs inside publish rather than beside it, so there is no flag that ships unchecked content.

If you changed a ^copyright, publish with --with-publisher. The IP-statements page is rendered from a fragment the IG Publisher emits (.build/temp/pages/_includes/ip-statements.xhtml), and gen-about keeps the committed page when no fresh fragment is present -- so without the publisher a licence change does not reach the rendered page. bin/check-export-boundary.ts fails the gate when a licence statement denies what the rendered page does.

package.tgz is built by bin/pack.ts rather than by the IG Publisher, and the QA half of what the publisher used to provide is now bin/check-canonicals.ts in the gate. The publisher remains available as a cross-check; it is not on the critical path.

Publishing happens before the PR is opened. That is deliberate: readers see the current spec without waiting on review. The published site is therefore allowed to be ahead of main.

What the target cannot do

Four things need judgement. They are the release.

1. Review the scope

Work a release ticket (FHIR-*) that names it. Collect the FSH merged or queued since the last release and review each change against conventions.md: one profile per file, file-size caps, portable FHIRPath invariants over unevaluable slices, canonical URLs are forever. Fix or bounce anything that does not conform, and clean up contributor FSH — naming, must-support policy, duplicated modelling, stray experimental content.

Breaking profile changes, canonical-URL changes and edits to src/ig/ig.ini or src/ig/sushi-config.yaml are T3 review tier: a second owner reviews.

2. Check the change reached every surface

A spec change is not done until its surroundings catch up. Most of this is checked for you — the gate tells you what is missing; writing it is yours:

  • Examples — every profile ships at least one conforming example. Enforced: the gate fails if a profile has no instance declaring it in meta.profile.
  • Seed corpus — new must-support elements need generated data. Enforced: the coverage guarantee fails, in both directions (IG surface the corpus misses, corpus surface the IG does not model).
  • Narrative — the profile's src/pages/StructureDefinition-<id>-intro.md, and the handbook page for the feature. Enforced: the gate fails if a profile has no intro page. Every profile has one as of 1.15.0, so a missing page means the change in front of you added a profile without writing about it.
  • Validator — add an explicit rule only where regeneration cannot infer the constraint. This one is genuinely judgement: no check can tell you that a constraint needs a hand-written rule.

3. Bump the version

src/ig/sushi-config.yaml is the AUTHORITY for the version, and as of 1.25.0 it is the only file that carries it by hand. Everything else derives: the facade ports hold it in IG_META / IgMeta and are rewritten by the regenerate step, ts/codegen/lib/ig-specs.ts reads it from the compiled IG, and worked CQL examples declare bare using Nexus, which resolves whichever model is vendored. (manifest.json is fork provenance and never carries the version.)

npm run build <x.y.z> cuts it -- writing the authority, rewriting the changelog heading, and regenerating, in that order and in one command:

npm run build 1.21.0-candidate   # start work on 1.21.0
npm run build 1.21.0             # cut it, changelog heading dated today

check-version-agreement.ts is the gate. It asserts the generated stamps against the authority, so a tree that was stamped but not regenerated fails naming the file, both versions, and the command that fixes it. Generated file banners no longer carry the version (as of 1.23.0), and the rendered reference pages under docs/fhir/ are not tracked, so neither is a place a bump has to reach.

Breaking change = major, additive = minor, fix or docs only = patch — except while this IG is status: draft, which it currently is.

The draft carve-out. While sushi-config.yaml says status: draft, a breaking change bumps MINOR, not major. The spec is still stabilizing and no external party depends on it; applying the strict rule now would spend major versions on the ordinary work of getting the model right. 1.12.0 alone renamed two invariant keys and added two required bindings, each technically breaking — a 3.0.0 inside one month would assert something false about the spec's maturity. Version numbers communicate; while nothing depends on us, the changelog does the communicating.

Three obligations come with it, none optional:

  • The changelog entry names the break and states the consumer action ("consumers validating against the old invariant key must update it"). A silent breaking change inside a minor is the failure mode this rule risks; the entry is the mitigation.
  • Breaking changes stay T3 review tier regardless of the digits. The carve-out is about numbering, not scrutiny.
  • When status: leaves draft the carve-out ends, and whoever flips it updates this section in the same PR.

Version numbers ending in 13 are not used — see the 1.13.0 section between 1.14.0 and 1.12.0 in the Changelog. This applies to TypedFhir's version line as well, which went 0.12.0 → 0.14.0 alongside 1.19.0. Skipping in only one of the two lines would leave a reader hunting for an artifact that was never cut.

The -candidate marker

A branch that is not being released carries <x.y.z>-candidate, and pre-pr step 17 fails every branch that does not. That is the normal state of work in progress, not a defect: before the marker existed, the 1.20.0 branch read 1.19.0 everywhere, and anything handed to someone from it was misnamed as a release that had not happened.

Step 17 is LAST on purpose. Everything above it -- conformance, all four ports, the coverage guarantee, the cross-verifier -- has already reported by the time it speaks, so a branch under development gets the whole gate and one line saying the version is not releasable. Cutting the release is npm run build <x.y.z> without the -candidate suffix, and it is the last thing you do, not the first.

4. Write the changelog entry

You do not move the heading by hand. npm run build <x.y.z>, from step 3, rewrites the in-flight ## [x.y.z-candidate] - unreleased heading in CHANGELOG.md to ## [x.y.z] - <today> as part of the same command that writes the version into every site. This step is about the CONTENT under that heading, not the heading. Record the TypedFhir version that generated the release in the heading comment.

set-version.ts renames the in-flight heading; it does not create one, and it accepts two spellings -- ## [Unreleased] and ## [x.y.z-candidate]. That matters twice. Starting the NEXT release from a freshly cut one means the top heading is dated and there is nothing to rename, so the cut reports no ... heading to rename and you add one by hand before re-running. And an outside contributor branching from a released state has the same problem with no way to know the version, so the contributor guide tells them to open ## [Unreleased] and leave the number to you -- expect that spelling in their PRs and rename it here rather than asking them to change it.

The entries themselves are written as the work lands, not assembled here -- every owner-track PR adds its own. What this step is for is reading them together, once, as the release note a consumer will actually get: are the breaking ones marked breaking, in bold, with the consumer action spelled out? Is anything consumer-visible missing? The changelog carries this specification's compatibility contract in place of SemVer, so completeness is a correctness question and the preamble says so.

Write for the reader of the release — converter authors, bot developers, integrators. It records what changed in the model, what changed in the artifacts they consume, and any migration warning. It is not a build log: internal tooling gets one line, or none.

Then

Merge and tag the version, and announce it — the new version, the headline changes, any consumer-facing notes (breaking changes, new must-support elements, deprecations), and a link to the site — to the project FHIR channel and to any partner integrating against the IG. That is how contributors learn their FSH landed and how implementers learn something they depend on moved.

The site is published at https://fhir.apps.health/ from the Well-Polaris/nexus-ig repository, which hosts the built output and nothing else.