Releasing¶
The step-by-step release runbook. For the why and the shape of the branch flow, see Branching & releases; this page is the exact steps.
Run pnpm run cut-release -- X.Y.Z. The rest is automatic. You can also cut a release from
GitHub Actions by running Cut Release on the develop branch with the same X.Y.Z
version.
Normal release flow¶
- Make sure the intended changes are merged to
develop. - From a clean checkout at the latest
origin/develop, run:
- Review the generated
release/X.Y.Z->mainPR. - Merge the release PR to
mainwith a merge commit. This starts Deploy - Production, which ships every component in one run and cuts the GitHub release when it finishes. - Let the Back Merge Main to Develop workflow open or update
sync/main-to-develop. - Merge the sync PR to
developwith a merge commit. - Watch the deploy land. The run posts to
#product-teameither way; see Deployments if it fails.
Releases are cut from develop, never from main, so the release branch is grounded in the
same state that was tested on staging. The post-release sync must be a real merge commit so
release commits propagate back to develop and the branches do not silently diverge.
Incident context: PR #976.
GitHub Actions entry point¶
Use Actions -> Cut Release -> Run workflow on develop. Inputs:
version: semver without a build suffix, for example1.1.13.dry_run: validates and prints the planned branch, version bump and PR without pushing anything.
The workflow wraps scripts/cut-release.ts, configures the GitHub Actions bot identity, and
creates the same release PR as the local script.
Versioning¶
Release versions use semver: MAJOR.MINOR.PATCH. The version of record lives in both Flutter
app pubspecs, which the release script writes as <version>+1:
apps/perci-platform-members/pubspec.yamlapps/perci-platform-clinicians/pubspec.yaml
The +1 build number is intentionally stable for web releases. The release version must be
strictly greater than the version currently on main; the release guard rejects downgrades
and stale version bumps.
What a production deploy publishes¶
A successful Deploy - Production run produces one GitHub release covering the whole
train (backend functions, Firestore rules, Medplum bots, the Web Professionals portal and
both apps), replacing the three per-component releases the old per-app workflows cut. The
body is generated by scripts/deploy/release-notes.ts and the tag is derived from the
members pubspec version.
Re-running a production deploy does not fail on an already-published tag: an existing release is left untouched. The run also closes a single GitHub deployment record for the environment. See Deployments.
Flutter web source maps¶
Both apps are built with --source-maps, and each deploy job uploads main.dart.js.map to
Datadog with datadog-ci flutter-symbols upload so prod errors arrive as Dart file names and
line numbers instead of main.dart.js offsets.
Two invariants keep that working:
- The upload
--versionmust equal the version the RUM SDK reports at runtime. The SDK sends"${packageInfo.version}-${packageInfo.buildNumber}"(seeinitialize_datadog_config.dart), and the workflow derives the same string from the pubspec by replacing+with-. A mismatch is silent: Datadog accepts the upload and never applies it.--service-namemust likewise match the SDK'sservice. - The maps are never served publicly.
firebase.jsonignores**/*.mapin every hosting target, so the.mapfiles exist only in the build artefact and in Datadog. They stay inbuild/webfor the upload step, which runs after the hosting deploy in the same job.
datadog-ci exits 0 even when it skips every map, so each upload step asserts that
main.dart.js.map was uploaded and raises a workflow error if it was not. The step is
continue-on-error, so a Datadog outage annotates the run without failing a shipped release.
The two skipped maps it always reports (flutter.js.map, hls.min.js.map) are
sourceMappingURL references to files Flutter does not emit, and are expected.
QA fixes¶
During QA, fixes land on develop first. Forward-port the same fix to the open
release/X.Y.Z branch so the release contains the reviewed change and develop stays the
source of truth.
Hotfixes¶
Use a hotfix when a fix must reach production before the next normal release. The rules for
main PRs (branch prefix, version bump) are in
Branching & releases.
A hotfix is not the fastest way out of a bad deploy
A hotfix still has to go through review, CI and a full production pipeline run. When production is actively broken, roll back first and fix forward after. See the Rollback runbook.
For urgent production incidents:
- Branch from
mainashotfix/PPL-NNNN. - Keep the fix minimal.
- Open a PR to
main. - Merge with a merge commit after review and required checks pass.
- Let the automatic
sync/main-to-developPR carry the hotfix back todevelop.
For non-urgent hotfixes, prefer fixing develop first:
- Branch from
developand merge the fix todevelopthrough the normal PR path. - Branch from
mainashotfix/PPL-NNNN. - Cherry-pick the minimal fix commit from
developonto the hotfix branch. - Open the hotfix PR to
mainand merge it with a merge commit. - Let the automatic
sync/main-to-developPR reconcile the production merge back todevelop.
Before cherry-picking from develop, check the fix does not depend on unreleased
develop-only changes. If it does, make a smaller hotfix directly from main instead.
Required repository settings¶
main keeps its protections and requires:
- Pull requests; no direct pushes.
- Required status check: Release PR Guard.
- Merge commits allowed for
release/*,hotfix/*and sync PRs. - Squash remains the default for ordinary PRs to
develop. - PRs to
mainmust come fromrelease/*,hotfix/*orsync/main-to-develop*. The versioned enforcement lives inscripts/release-pr-guard.ts; if GitHub branch rulesets are available, mirror the same source-branch allow-list in themainruleset.
DORA metrics¶
Four numbers tell us whether our delivery is getting better: deployment frequency, lead time for change, change failure rate and time to restore. All four are derived by Datadog from two event streams that the deploy pipeline emits.
What CI emits¶
The finish job of .github/workflows/deploy.yml calls the .github/actions/dora-event
composite action once per service, only when the run succeeded:
| DORA service | Version reported |
|---|---|
perci-platform-backend |
commit SHA (matches DD_VERSION) |
members-app-frontend |
app version from pubspec.yaml, + rewritten to - |
clinicians-app-frontend |
app version from pubspec.yaml, + rewritten to - |
The two app versions are reported as 1.1.28-1, not the 1.1.28+1 the pubspec carries: the
build jobs normalise the separator because + is not safe everywhere the version is passed
through. Expect the hyphen form when correlating a DORA event against tooling that reports the
raw pubspec value.
The service names deliberately match the services those components already report to Datadog
(the datadog-ci sourcemap and Cloud Run instrumentation steps in the same pipeline), so DORA
events line up with the APM and RUM data for the same service. Events are tagged with the
run's environment, so a staging run reports env:staging and a production run env:prod.
Because all three now come from one job, the three services always report the same run and the
same started_at.
The action sends two kinds of event to the EU Datadog site (api.datadoghq.eu):
- Deployment event (
POST /api/v2/dora/deployment) on every successful deploy. This feeds deployment frequency and lead time for change. Thestarted_attimestamp is captured by theRecord deploy startstep, the first step of thebeginjob, before any build or deploy work in the run. Capture it later and both the deployment duration and the lead time come out wrong. - Change-failure event (
POST /api/v2/dora/incident) when the deploy is a hotfix. This feeds change failure rate and time to restore. Because production deploys are triggered by a push tomain, the ref is alwaysmain, so a hotfix is recognised from the merge commit GitHub writes for ahotfix/*PR (Merge pull request #N from <org>/hotfix/...). That is reliable because PRs intomainmay only come fromrelease/*orhotfix/*and always land as a merge commit, never a squash. See Branching & releases.
The failure window is reported as previous production commit to hotfix deployed: the incident
started_at is the commit time of HEAD^ (the version that was live and broken) and
finished_at is the moment the hotfix finished deploying. That is an approximation, because it
assumes the breakage arrived with the previous production deploy, and it is the best signal CI
has today. If HEAD^ cannot be resolved (a shallower checkout than fetch-depth: 2) the start
falls back to the deploy start rather than to finished_at, so a failed lookup cannot report a
zero-length incident and flatter a time-to-restore figure.
Robustness¶
Emission is best-effort and can never break a release:
.github/actions/dora-event/dora-event.shnever exits non-zero. A missing key, a Datadog outage, a 4xx or a malformed input logs a GitHub warning annotation and returns 0.- The calling step is additionally guarded with
continue-on-error: true. - The steps are gated on the pipeline outcome the
finishjob resolves from every other job, so nothing is emitted for a run that failed or was cancelled anywhere along the way. - Missing
DATADOG_API_KEYskips emission with a warning, matching how the sourcemap upload steps behave.
No new repository secret is needed: the action authenticates with the existing
DATADOG_API_KEY, the same secret the sourcemap and Cloud Run instrumentation steps already
use. The DORA intake authenticates with the API key alone, so no application key is sent.
Still to do by hand¶
The event pipeline is only half the deliverable. These parts live in the Datadog console and on Confluence and are not in this repo:
- Confirm events are arriving. After the next production deploy, open Datadog → Software Delivery → DORA Metrics and check a deployment event exists for each of the three services above. Confirm the exact metric and event field names in the console before wiring widgets to them.
- Build the dashboard. One dashboard, four widgets per service (deployment frequency, lead time for change, change failure rate, time to restore), grouped by service so backend, members and clinicians can be read separately as well as together. If Datadog offers an out-of-the-box DORA dashboard, clone that rather than hand-building it.
- Record the baseline. Once a full four weeks of events have accumulated, write the starting value of each of the four metrics onto the engineering strategy page in Confluence, so later improvement is measured against something rather than asserted.
- Review on a cadence. Recommended: review the four numbers monthly at the engineering review, and re-baseline quarterly. Treat a metric that has not moved for two quarters as a signal that the underlying delivery problem was misidentified, not that the metric is wrong.
Known gaps¶
- Hotfixes are the only change-failure signal today. A production incident fixed by a normal release, or one resolved without a code change, does not produce an incident event, so change failure rate and time to restore are floors, not exact figures.
- Canary auto-rollback is not wired. That signal belongs to PPL-3149 and does not exist
yet. The seam is already in the action: call it with
emit_incident: 'true'plusincident_nameandincident_started_at(the rollback knows when the bad version went out, so it can report a far more accurate failure window than theHEAD^approximation above). - The pipeline's own
rollback-backendjob does not emit an incident event either. A verification failure that triggers an automatic Cloud Run rollback is invisible to change failure rate today. It uses the same seam as the item above. - Staging deploys now emit deployment events too, tagged
env:staging. Filter onenv:prodwhen reading the four DORA numbers, which are meant to describe production.
Troubleshooting¶
release must be cut from the exact origin/develop commit¶
version must be greater¶
Pick the next semver above the version currently on both app pubspecs. Do not reuse a version that has already shipped.
PR branch appears to be grounded in main¶
Close the bad release PR and re-run pnpm run cut-release -- X.Y.Z from the latest
origin/develop.
.gitignore files were added¶
Remove generated build output from the release branch. Common examples are Flutter Linux
generated files under apps/*/linux/**, .dart_tool, and build/.
mcp/*/package-lock.json¶
Remove the npm lockfile. MCP packages use pnpm, and the root pnpm-lock.yaml is the
lockfile source of truth.
Back-merge PR has conflicts¶
Check out sync/main-to-develop, merge origin/main, resolve conflicts, then push the
branch. Keep the PR as a merge commit into develop.