How a release's notes are assembled¶
cut-release.yml and cut-hotfix.yml build the GitHub release body in five
steps. Knowing the order matters, because two of the steps can silently produce
nothing and two of them are deliberately placed where a model cannot touch them.
1. Pick the baseline .github/scripts/release_prev_tag.sh
2. Collect the changelog changes/*.md fragments, or a CHANGES.md slice
3. Polish the wording Claude, via claude-code-action (best-effort)
4. Append the dependencies tools/release-notes/dependency-delta.py
5. Append the download guide tools/release-notes/portable-downloads.md
cut-beta.yml runs steps 2, 3 and 5 (its baseline is the latest tag of any
kind, and it has no dependency section).
Steps 1–3 describe what people changed. Step 4 describes what the image changed, which is a different question and has a different source of truth.
1. The baseline¶
Everything else is relative to "the release this one follows", produced by
.github/scripts/release_prev_tag.sh:
The highest stable tag strictly below the version being cut.
Pre-release tags (beta, rc, alpha) are never a baseline — a beta is a
snapshot on the way to the stable it precedes, so diffing against one would
report a subset of the work.
| Cutting | Tags that exist | Baseline | Why |
|---|---|---|---|
v5.10.1 |
v5.9.1 v5.10.0 v5.11.0 |
v5.10.0 |
A patch on a maintained line follows its own line, not whatever main has since tagged |
v5.10.0 |
v5.9.0 v5.9.1 |
v5.9.1 |
First of its series — falls back to the previous series |
v5.10.0 |
v5.9.1 v5.10.0-beta.2 |
v5.9.1 |
The beta it supersedes is not a baseline |
v5.0.0 |
(none) | (empty) | First release ever; notes ship without a comparison link |
Both cut workflows call the one script. They each used to compute this inline,
and disagreed — cut-release took the globally latest tag, which describes a
5.10.1 patch as removing everything 5.11 added; cut-hotfix scoped to the
v5.10.* series, which finds nothing when the tag is first of its series and
silently drops the change list. Both failures are covered by
test/ci-scripts/test-release-prev-tag.sh, including wiring assertions that
fail if either workflow goes back to computing it inline.
2. The changelog¶
cut-hotfix reads the unmerged changes/*.md fragments on the branch, because
bump-version.yml has not folded them into CHANGES.md yet. cut-release
slices CHANGES.md down to the lines prepended since the baseline.
This is why a changelog fragment is mandatory on every branch: a change with no fragment is invisible here.
3. Polishing¶
claude-code-action rewrites the bullets into user-facing language. The step is
continue-on-error: true on purpose — a rejected or unentitled model call must
never block a release, and the raw bullets are already valid notes. When it
fails the job logs a warning and ships the unpolished version.
The prompt is told to drop CI, tooling and pure-implementation bullets with no user-visible effect. Keep that in mind when writing a fragment you want to survive: describe the effect, not the mechanism.
4. Dependency updates¶
tools/release-notes/dependency-delta.py diffs the baseline against the tag
being cut and appends a Dependency updates section.
This exists because dependency changes cannot reach the notes any other way:
- The PR Hygiene gate's
CODEpattern does not coverpackage-lock.json, so Dependabot PRs merge without achanges/*.mdfragment — every one of them. - Even a hand-written fragment about a library bump reads exactly like the "tooling with no user-visible effect" that step 3 is told to discard.
So a release made up only of library upgrades used to ship notes with nothing
under ## Changes — the one release where an operator most needs to see which
version moved and which advisory it closes.
The section is appended after polishing. A model cannot drop or reword a section it never sees, so the version list is exact.
What it reports, and why the two apps differ¶
Only what reaches the published image. The API and the UI ship differently, so they are filtered differently:
| Source | Scope | Reason |
|---|---|---|
app/api/package-lock.json |
Full transitive production closure | npm ci --omit=dev installs all of it into the image, and a container scanner sees every package |
app/ui/package-lock.json |
Only packages declared in app/ui/package.json dependencies |
The UI ships compiled dist/; its node_modules never reaches the image |
app/api/Dockerfile, setup/docker/Dockerfile.powershell |
Pinned base-image digests | The base image is most of what a scanner reports |
The UI rule is not cosmetic. @tailwindcss/vite sits in dependencies, so npm
marks the whole @rolldown/binding-* family production — around forty
per-platform bundler binaries that cannot appear in a browser bundle. Reporting
the raw closure buried react and @azure/msal-browser under them.
Each section caps at 30 bullets (--max-per-section, 0 disables) and defers
the tail to the SBOM. When nothing shipped changed the script prints nothing at
all, so an empty heading cannot appear.
Running it by hand¶
python3 tools/release-notes/dependency-delta.py v5.9.1 main
python3 tools/release-notes/dependency-delta.py v5.9.1 main --max-per-section 0
Sample output:
## Dependency updates
### API runtime
- `express-rate-limit` 8.5.2 → 8.7.0
- `multer` 2.2.0 → 2.4.0
- `pg` 8.22.0 → 8.23.0
- …and 19 more — see the SBOM attached to this release
### Frontend
- `@azure/msal-browser` 5.17.1 → 5.22.0
- `react` 19.2.7 → 19.3.0
### Container images
- `node:24-slim` (app/api) `unpinned` → `0e0ff40`
unpinned is not an error — it means that end of the range used a bare tag,
which is true of every base image before digest pinning landed.
5. The download guide¶
Every release attaches two portable Windows ZIPs, built by
.github/scripts/build_portable_zips.sh:
| Asset | What it is |
|---|---|
IdentityAtlas-portable.zip |
PGlite only; node.exe is its only executable and is code-signed |
IdentityAtlas-portable-postgres.zip |
Embeds PostgreSQL 16 for large data sets; run with -Database Postgres; the PostgreSQL binaries are not code-signed and need VCRUNTIME140.dll |
tools/release-notes/portable-downloads.md explains that choice to whoever
opens the release page. It is a static file appended after polishing, for
the same reason as the dependency section: the model is told to drop
packaging and tooling text, and this is the one piece of packaging text a
downloader needs. See
Portable Windows Launcher.
Where releases can be cut from¶
Both cut-release and cut-beta take a ref input (branch, tag or commit;
default main), and cut-hotfix takes a branch name. Nothing in the
tooling requires a release to come from main HEAD — a release can be cut from
a branch whose content was fixed at an earlier point.
That capability is unused today. Whether to adopt it — a maintained
release/5.N line with its own dependency updates and patch releases, versus
releasing from main on a cadence — is an open policy question, not something
this tooling decides. The notes machinery is correct either way: the baseline
rule above was written specifically so a patch cut from a maintained line is
described against its own line.
Testing¶
| Suite | Covers |
|---|---|
test/ci-scripts/test-release-prev-tag.sh |
Baseline selection, version ordering, pre-release exclusion, workflow wiring |
tools/release-notes/test_dependency_delta.py |
Lockfile parsing, dev/production filtering, the UI manifest restriction, Dockerfile digests, rendering, truncation |
app/desktop/portableReleaseAssets.guard.test.js |
All three cut workflows attach both portable ZIPs, the build order that keeps both, and the download guide's placement after polishing |
The first two run in the ci-scripts job of pr.yml; the guard runs in the
API Vitest suite. They are worth having precisely
because nothing else exercises this code until a release is already being cut,
and a release is a bad place to discover that the notes generator throws.