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.yamlsaysstatus: 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:leavesdraftthe 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.