The steps to cut a tagged release. The signed/notarized pkg, the mobileconfig profiles, the SBOMs, and the cosign attestations are produced automatically by .github/workflows/release.yml on a v* tag push; this checklist covers the human-ordered work that has to happen first, plus the validation gates that a release candidate must clear before it is promoted to a stable tag.
The flow is built around a release-candidate (RC) loop: cut vX.Y.Z-rc.N, run the candidate-only validation against it, and only then promote to the stable vX.Y.Z tag. An RC tag is signed like a stable tag but does NOT advance the :latest server image (docs/README.md), so it is safe to validate against real infrastructure. If validation surfaces a blocker, fix it on main and cut rc.N+1; repeat until an RC is clean.
OpenSpec deltas are NOT archived per-merge (see CLAUDE.md). They accumulate in openspec/changes/<name>/ across the release cycle and are archived in one batch here, so the canonical openspec/specs/** tree moves only at release boundaries and is reviewed as a single diff.
On a release-prep branch off main:
- List the pending changes:
ls -1 openspec/changes/ | grep -v '^archive$'. - For each completed change, run
openspec archive <name> -y(NO--skip-specs). This merges the delta intoopenspec/specs/**and moves the folder toopenspec/changes/archive/<date>-<name>/. Use--skip-specsONLY for a tooling/doc-only change that shipped no spec delta. - If a merged change is genuinely deferred to a later release (incomplete, intentionally held), it must not ship its delta into the canonical specs yet. Decide explicitly: either finish + archive it, or back its delta out of this release. The release gate (
openspec-archivedinrelease.yml) does not let an un-archived change ride silently into a release. - Verify the canonical tree is well-formed and fully traced after archiving:
openspec validate --all --strictgo run ./tools/spectrace check --strict
- Confirm nothing un-archived remains:
ls -1 openspec/changes/ | grep -v '^archive$'prints nothing.
Note on removed requirements: a change that retires a requirement (a
## REMOVED Requirementsdelta) does not need to be archived early to keep CI green.spectrace check --strictexempts canonical scenarios whose requirement an in-flight delta marks## REMOVED, so the requirement's tests can be deleted on the merging PR and the gate stays honest until this archive step finalizes the removal.
On the same release-prep branch, alongside the archive from step 1:
- Draft the changelog: move the
CHANGELOG.md[Unreleased]items into a new versioned section (## [X.Y.Z] (YYYY-MM-DD)), grouped under Added / Changed / Fixed / Removed, and write the release-notes highlights. Write for the security and IT professionals who read release notes, not for contributors: keep them brief and to the point (one tight claim per bullet, "action required" upgrade notes first, engineer-facing internals cut in favor of the user-visible effect, intra-release refinements folded into the feature they refine). The## [0.3.0]section is the reference for density (~1,300 words for a feature release). This is the same rule as in CLAUDE.md's Code style section. The release workflow publishes the GitHub Release with auto-generated notes (gh release create --generate-notes), which do not readCHANGELOG.md, so after the tag is published replace the release body with this section to match:gh release edit <tag> --notes-file <file>. - Bump the pinned release tag in the operator deploy docs so the copy-paste deploy snippets stay current. Run
tools/bump-doc-versions.sh vX.Y.Z: it rewrites every pinned version token indocs/quickstart-vm.md,docs/install-server.md,docs/install-agent-manual.md,docs/mdm-deployment.md,docs/fleet-deployment.md, andbootstrap.sh, printing every change for review. These literals are intentionally pinned (a pilot must deploy a known signed tag), so they do not float.README.mdis deliberately NOT in that set: it is the evergreen landing page and stays version-free (it showsEDR_VERSION=latestand routes to the quickstart for the production pin). The demo (docker-compose.demo.yml) defaults tolatestand is NOT bumped. The full model is indoc-versioning.md; thedocs-version-pinnedjob inrelease.ymlre-checks this on the stable tag and fails the release if any snippet is stale. - Pass the automated gates locally (they mirror CI; a local run avoids a failed release build):
task lint:go,task lint:nilaway,task lint:dashesclean.task test:go:serverandtask test:go:agentgreen.- The cross-context integration and browser-with-fake-agent suites green.
- Open the release-prep PR and get it reviewed. The archive is where editing
openspec/specs/**is expected and legitimate, unlike on a feature branch, so this is the diff a reviewer scrutinizes. - Merge to
main. Everything downstream tags off the merged commit.
- Create the annotated RC tag on the merged commit:
git tag -a vX.Y.Z-rc.N -m "vX.Y.Z-rc.N"and push it. - The
v*push triggersrelease.yml. Itsopenspec-archivedjob runs first; every publishing job (macos-pkg,docker-server,docker-demo-seed) depends on it vianeeds:, so the release fails before any signing ifopenspec/changes/still holds a non-archive folder. That is the automated backstop for the "nothing un-archived remains" check (step 1, item 5). - The RC is signed and published but does not advance
:latest. Use it for the validation in steps 4 through 7.
These layers do not run per-PR (docs/testing-strategy.md); the RC is where they gate.
- The macOS VM end-to-end run: real Swift extensions + real agent + real server on a SIP-enabled, Gatekeeper-enabled VM. Any agent/extension change touching ESF, XPC, or the event wire format MUST be exercised here since the last release.
- The detection-efficacy run: the MITRE-aligned attack corpus, asserting each shipped rule fires within its SLA (detection rate gate) and the noise corpus stays clean (false-positive gate).
- The single-replica scale gate (#203):
scale.ymlruns automatically on thev*-rc.*tag push and asserts theevent_queueprocessing backlog stays bounded under a 200-host lane with the production processor fan-out. Confirm that check is green on the RC before promoting (or runtask uat:scale:gatelocally). The full 500-host reference is the committed baseline (test/scale/baselines/post-535-500host.json); re-capture it on a dev box withtask uat:scale -- --hosts=500 ...if the throughput path changed materially.
Drive the built UI from the RC server image and walk the core operator journeys to catch rendering and interaction regressions the automated suites do not assert: sign-in, host list, process tree, alert detail, and policy / app-control editing.
The Mac-free demo (docker-compose.demo.yml) floats to the released :latest server + seeder images, so promoting a stable tag makes the public demo run this release. v0.4.0+ also hard-requires ClickHouse (ADR-0015), which the demo compose provisions, so a boot or seeding regression on the new image would otherwise surface only after :latest moves. Eyeball the demo against the RC first, pinned to the RC tag:
docker compose -f docker-compose.demo.yml down -v # start clean (discards any prior demo data)
EDR_VERSION=vX.Y.Z-rc.N docker compose -f docker-compose.demo.yml upOpen https://localhost:8088/ui/, sign in through the bundled dex IdP (demo@fleet-edr.local / demo), and confirm the seeded corpus renders end to end: the process graph, the fired ATT&CK alerts, and the application-control block. The EDR_VERSION pin selects the same tag for both the server and the one-shot seeder. Start clean with down -v when switching image versions: the seeder skips replay when demo data is already present, and v0.4.0+ reads events from ClickHouse, so stale MySQL-only demo data from a prior latest run would render an empty event view.
Roll the candidate server image onto the live dogfood deployment, enroll an actual Mac, and confirm on real hardware: enrollment succeeds, telemetry flows, and at least one real detection fires end to end. This is the last gate that exercises the full product the way a pilot customer would.
If any of steps 4 through 7 surfaces a blocker, fix it on main and return to step 3 with rc.N+1. Only a clean RC is promoted.
- Create the annotated stable tag on the same commit the clean RC was built from:
git tag -a vX.Y.Z -m "vX.Y.Z"and push it. release.ymlre-runs and, because this is a non--rctag, advances:latestand produces the final signed pkg, the two mobileconfig profiles, the SBOMs, theSHA256SUMS, and the cosign bundles.
Run the verify-release skill against the stable tag. It confirms the GitHub Release carries every expected artifact and that each one verifies: asset completeness, checksums, per-artifact Sigstore bundles, the server image signature (plus the :latest digest match for a stable tag), the build-provenance attestations, and the macOS Gatekeeper checks on the pkg. Any failure means the release is not safe to announce.
To rehearse the build/sign path from a topic branch without cutting a tag, trigger release.yml via workflow_dispatch; the run sets --dry-run and skips signing/notarization. The openspec-archived gate is advisory on a dry-run (it reports but does not fail), since a topic branch legitimately carries in-flight changes.