Skip to content

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

  1. Make sure the intended changes are merged to develop.
  2. From a clean checkout at the latest origin/develop, run:
git checkout develop
git pull --ff-only origin develop
pnpm run cut-release -- X.Y.Z
  1. Review the generated release/X.Y.Z -> main PR.
  2. Merge the release PR to main with a merge commit. This starts Deploy - Production, which ships every component in one run and cuts the GitHub release when it finishes.
  3. Let the Back Merge Main to Develop workflow open or update sync/main-to-develop.
  4. Merge the sync PR to develop with a merge commit.
  5. Watch the deploy land. The run posts to #product-team either 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 example 1.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.yaml
  • apps/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 --version must equal the version the RUM SDK reports at runtime. The SDK sends "${packageInfo.version}-${packageInfo.buildNumber}" (see initialize_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-name must likewise match the SDK's service.
  • The maps are never served publicly. firebase.json ignores **/*.map in every hosting target, so the .map files exist only in the build artefact and in Datadog. They stay in build/web for 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:

  1. Branch from main as hotfix/PPL-NNNN.
  2. Keep the fix minimal.
  3. Open a PR to main.
  4. Merge with a merge commit after review and required checks pass.
  5. Let the automatic sync/main-to-develop PR carry the hotfix back to develop.

For non-urgent hotfixes, prefer fixing develop first:

  1. Branch from develop and merge the fix to develop through the normal PR path.
  2. Branch from main as hotfix/PPL-NNNN.
  3. Cherry-pick the minimal fix commit from develop onto the hotfix branch.
  4. Open the hotfix PR to main and merge it with a merge commit.
  5. Let the automatic sync/main-to-develop PR reconcile the production merge back to develop.

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 main must come from release/*, hotfix/* or sync/main-to-develop*. The versioned enforcement lives in scripts/release-pr-guard.ts; if GitHub branch rulesets are available, mirror the same source-branch allow-list in the main ruleset.

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. The started_at timestamp is captured by the Record deploy start step, the first step of the begin job, 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 to main, the ref is always main, so a hotfix is recognised from the merge commit GitHub writes for a hotfix/* PR (Merge pull request #N from <org>/hotfix/...). That is reliable because PRs into main may only come from release/* or hotfix/* 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.sh never 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 finish job resolves from every other job, so nothing is emitted for a run that failed or was cancelled anywhere along the way.
  • Missing DATADOG_API_KEY skips 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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' plus incident_name and incident_started_at (the rollback knows when the bad version went out, so it can report a far more accurate failure window than the HEAD^ approximation above).
  • The pipeline's own rollback-backend job 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 on env:prod when reading the four DORA numbers, which are meant to describe production.

Troubleshooting

release must be cut from the exact origin/develop commit

git checkout develop
git pull --ff-only origin develop
pnpm run cut-release -- X.Y.Z

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.