Skip to content

Dependency updates (Renovate)

Renovate opens the dependency-bump PRs for this repo. Config lives in .github/renovate.json5 and is the source of truth — change it there, not in the Renovate UI.

What is in scope

enabledManagers is set to ['github-actions', 'dockerfile', 'npm', 'pub', 'fvm', 'custom.regex'], which covers:

Manager Reads Covers
github-actions .github/workflows/**, .github/actions/** Action versions and digests
dockerfile any FROM line The backend base image
npm every package.json, plus pnpm-workspace.yaml Backend (functions, medplum bots, web-professionals), the MCP servers, qa/** and the root release tooling
pub every pubspec.yaml Dart/Flutter dependencies in both apps and the shared packages, plus the Dart and Flutter SDK constraints
fvm .fvmrc The Flutter SDK version CI installs
custom.regex .vscode/settings.json The editor's copy of the Flutter SDK version (below)

Terraform is still manual on purpose; widening the scope means adding the manager to enabledManagers in a PR of its own, so the review noise lands one ecosystem at a time.

Three things are matched and then explicitly disabled:

  • engines in any package.json. Node is pinned in .nvmrc, .node-version, the backend Dockerfile and the CI workflows, none of which Renovate manages, so bumping engines alone only creates drift.
  • overrides in pnpm-workspace.yaml. Those are hand-written security floors, deliberately scoped per major (brace-expansion@1, brace-expansion@2, …) because the majors are not API-compatible with each other, and the lockfile's overrides snapshot has to be re-resolved alongside any change. Read the comments in that file before touching it.
  • scripts/frontend/goldens.Dockerfile. See Docker below.

Docker

apps/perci-platform-backend/Dockerfile is what the preview backend and the production Cloud Run service both build from — a base image change here reaches both. The base image is pinned to a sha256 digest, with the human-readable tag kept as a trailing comment on the same FROM line:

FROM gcr.io/distroless/nodejs22-debian13@sha256:<digest> # nodejs22-debian13

A digest bump is a real change to the runtime — distroless base images get OS package and Node patch updates, not just metadata — so treat a Renovate docker PR the same as any other dependency bump, not as a no-op. pinDigests: true on the dockerfile manager means an un-pinned FROM (e.g. after a manual edit) gets auto-pinned by Renovate rather than left floating. Digest and patch updates are grouped into one PR; a major (a new Node major) is left ungrouped so it gets its own PR and review, same as GitHub Actions majors.

To roll back a bad digest bump, revert the Renovate commit (it only ever touches the FROM line and its comment) and redeploy.

The repo's other Dockerfile, scripts/frontend/goldens.Dockerfile, is disabled for Renovate. It is not a deployable image: it is the fixed rasterisation environment the golden baselines were rendered in, and its base digest and apt snapshot are pinned precisely so the libc/freetype stack cannot drift. A digest bump there shifts glyph rendering and invalidates every committed baseline, so it is bumped by hand together with melos run goldens_update.

pnpm

Renovate updates package.json and re-resolves pnpm-lock.yaml with the pnpm version from the packageManager field. Non-major updates are grouped by the area that owns the code, so a bump lands with the reviewers and the CI path filters that already cover it, and a revert stays small:

Group Files
Backend non-major apps/perci-platform-backend/**
Repo tooling non-major root package.json, pnpm-workspace.yaml, mcp/**, qa/**

Majors are ungrouped and get a PR each.

Most of our dependencies use caret ranges, and for those Renovate updates the lockfile only — the package.json line stays as it is until a release falls outside the range. That is the same resolution pnpm install would reach on its own; the PR exists so the change is reviewed and CI runs against it.

Note that qa/** is not a pnpm workspace member, so there is no lockfile to re-resolve for it and Renovate only edits the manifest.

Security audit autofix

The Security Audit job in CI - Backend runs pnpm audit --audit-level high. It is not part of the required Backend checks passed gate, because a new upstream advisory fails every backend PR at once, whatever the PR changed.

When it fails, security-audit-autofix.yml runs for that PR:

  1. It audits develop. If develop is already clean, it comments on the PR to merge develop (or, for a PR into release/*, hotfix/* or main, to cherry-pick the fix) and stops.
  2. If an audit-autofix PR or issue is already open, it links it on the failing PR and stops. If an audit-autofix PR was closed unmerged in the last 24 hours, it stops.
  3. Otherwise the security-audit-fix agent (.github/agents/) edits the overrides and auditConfig in pnpm-workspace.yaml and regenerates pnpm-lock.yaml. It uses per-major scoped pins when more than one major is in the tree, respects minimumReleaseAge, and adds an ignoreGhsas entry only for a build-time-only advisory with no usable fix.
  4. The gate is pnpm audit --audit-level high plus pnpm install --frozen-lockfile. The workflow then opens a draft PR into develop from an audit-fix/<hash> branch, labelled audit-autofix and auto-fix (and needs-human if the gate failed). The draft PR's own CI - Backend run is the full regression check.
  5. If the agent changes nothing (for example, the only patched release is younger than minimumReleaseAge), it opens an audit-autofix issue instead. Close the issue to let the autofix try again.

The bot's PR has no ticket key: add one before you mark it ready. The fix lands on develop only; cherry-pick it into an open release branch by hand. It uses the same secrets as the Patrol autofix. To pause it, remove the security_audit_autofix job from ci-backend.yml.

Dart and Flutter

Each package declares its own dependencies in its own pubspec.yaml, and that is the only place a version lives. The four workspace members carry resolution: workspace, so pub does a single resolution across all of them and picks one version of each package, intersecting whatever constraints the members declare. Renovate's pub manager edits those files directly.

Two consequences worth knowing:

  • Caret-ranged packages rarely get a PR. rangeStrategy is replace, so dio: ^5.3.0 only moves when a release falls outside the range. flutter pub get already resolves to the newest in-range version, so there is nothing to bump. Exactly-pinned packages (stream_video: 1.4.1) get a PR every release.
  • pubspec.lock comes from a postUpgradeTask, not from the pub manager. Renovate's updateArtifacts looks for a lockfile beside the member pubspec, and a pub workspace keeps a single one at the workspace root instead, so the manager finds nothing to update. Because CI resolves with --enforce-lockfile that would leave every Flutter PR red, so Renovate runs flutter pub get itself after applying the update and commits the regenerated lockfile in the same commit. This is the reason Renovate is self-hosted here — see How Renovate runs. The one case it does not cover is a Flutter SDK bump; that PR's body says so.

Non-major updates are grouped into one Flutter non-major PR, which keeps the plugin families that are pinned in lockstep (url_launcher_*, path_provider_*, shared_preferences_*, stream_video / stream_video_flutter) moving together. Majors get a PR each, and for those families you will need to bump the siblings by hand in the same PR.

The Flutter and Dart SDKs

The Flutter version is pinned in four tracked places, and Renovate updates three of them in one Flutter SDK PR because all three report the dependency as flutter:

File What it drives
.fvmrc The SDK CI installs — setup-flutter-app reads it through flutter-fvm-config-action, so every workflow follows it automatically
apps/perci-platform-members/pubspec.yaml, apps/perci-platform-clinicians/pubspec.yaml environment.flutter, which makes a build with a different SDK fail loudly
.vscode/settings.json dart.flutterSdkPath, which points at .fvm/versions/<version> and breaks analysis in the editor if it is left behind

A green Flutter SDK PR is not a finished Flutter upgrade. Renovate cannot complete these, and the PR body repeats the list:

  1. scripts/frontend/goldens.Dockerfile and scripts/frontend/run-goldens.sh carry a FLUTTER_COMMIT SHA next to FLUTTER_VERSION, and the flutter-version datasource does not expose the commit for a release. Bumping the version without the SHA fails the container build outright, so Renovate is kept out of both files. Get the SHA with git ls-remote https://github.com/flutter/flutter refs/tags/<version>.
  2. Regenerate every golden baseline with melos run goldens_update. A different Flutter build rasterises differently, so the committed baselines stop matching.
  3. apps/*/patrol_test/tools/run_patrol.sh has a hard-coded VER= fallback, used only when .fvmrc cannot be read.

The Dart SDK (environment.sdk) is a range, >=3.8.0 <4.0.0, so under replace it only produces a PR when Dart goes major. That is the intent: the Dart SDK ships inside Flutter, so the floor is a compatibility statement we raise deliberately, not something to track release by release.

The rules

Setting Value Why
minimumReleaseAge 30 days Nothing newer than 30 days old. Gives the ecosystem time to yank or patch a bad (or compromised) release before it reaches our CI. internalChecksFilter: strict makes sure the age check is never skipped.
baseBranchPatterns develop PRs target develop, like any other change. main only ever receives release/hotfix branches.
helpers:pinGitHubActionDigests on Third-party actions stay pinned to a commit SHA with a # vN comment. Renovate bumps the SHA and the comment, and pins any tag it finds (a few @v4/@v6 remain).
Grouping one PR per ecosystem for minor/patch/digest, one PR per major A weekly grouped PR keeps the noise down; majors land alone so a breaking change is easy to spot and revert.
Schedule 06:00 UTC Monday Set by the cron in renovate.yml, not by a schedule in the config — see How Renovate runs. Bumps arrive as one batch at the start of the week.
Limits 3 open PRs, 2/hour Renovate can't flood the queue or the Blacksmith runners.

Commits come out as ci(deps): ..., which satisfies commitlint (any scope is allowed). Renovate branches are renovate/* and carry no PPL key, so the Jira sync workflows log "No PPL issue key found" and skip — expected, not a failure.

Reviewing a Renovate PR

Same bar as any other PR: one approval and the required checks (Backend checks passed / Flutter checks passed) green. Renovate PRs that touch ci-flutter.yml or a Flutter composite action run the full Flutter matrix; the rest pass through the path-filter gate quickly. Read the release notes in the PR body before approving — the age delay reduces risk, it doesn't remove it.

How Renovate runs

Renovate is self-hosted: .github/workflows/renovate.yml runs it from npm on a scheduled job. The Mend-hosted GitHub App is not installed on this repo, and installing it would double every PR.

The reason is postUpgradeTasks, which only exists in self-hosted Renovate. It is what regenerates pubspec.lock, and without it every Flutter PR would arrive red under --enforce-lockfile.

Renovate runs straight from npm rather than through the renovate/renovate image, with binarySource: global, so it uses the toolchain the job installs: the FVM-pinned Flutter that postUpgradeTasks needs, and the Node and pnpm the npm manager needs to re-resolve pnpm-lock.yaml. Those are the same versions CI uses, which is the point — a lockfile written by Renovate has to match the one CI would produce.

Two settings live in the workflow rather than in renovate.json5, because Renovate only accepts them as self-hosted config:

Env Why
RENOVATE_ALLOWED_COMMANDS The allow-list postUpgradeTasks is checked against. Keeping it out of repo config is the security boundary: a PR cannot grant itself the right to run a new command.
RENOVATE_BINARY_SOURCE=global Use the tools the job installed rather than downloading a second set.

Running it by hand

The workflow has a workflow_dispatch trigger with a dry run toggle (logs what it would do, creates nothing) and a log-level choice. Use the dry run to check a config change before letting it loose.

The config has no schedule option — the cron owns timing. Adding one back would make a manual dispatch outside the window silently do nothing.

Credentials

The job mints a token from a dedicated GitHub App (RENOVATE_APP_CLIENT_ID / RENOVATE_APP_PRIVATE_KEY). It is deliberately a different app from the autofix one, which should not hold Workflows write.

Its repository permissions must be:

Permission Level Why
Contents write Push branches
Pull requests write Open and update the PRs
Issues write The Dependency Dashboard
Workflows write Easy to miss: without it Renovate cannot push changes under .github/workflows/**, which is most of what it does here
Commit statuses read & write Renovate reads a branch's status the moment it pushes it
Checks read The other half of branch status
Metadata read Always required

A missing permission does not degrade gracefully. Renovate turns any 403 into integration-unauthorized and aborts the entire repository, so one missing scope stops every update, not just the feature that needed it. The symptom is branches appearing with no pull requests behind them. GitHub names the scope it wanted in the response, but only at LOG_LEVEL=debug:

GET /repos/.../commits/<sha>/statuses  403 Resource not accessible by integration
x-accepted-github-permissions: statuses=read

Commit identity

RENOVATE_GIT_AUTHOR is built in the workflow from a lookup of the app's own user id, not hard-coded. Commits pushed through a GitHub App carry GitHub's canonical <numeric-id>+<slug>[bot]@users.noreply.github.com, and Renovate compares every branch's author against gitAuthor to decide whether a human has edited it. Get it wrong and Renovate disowns its own branches, logs "result": "pr-edited", and opens nothing — while the job stays green.

When PRs stop arriving

Check, in order: the Renovate workflow's last run in the Actions tab; the Dependency Dashboard issue, which lists everything Renovate is holding back (including updates still inside the 30-day window); and whether the App token has expired or lost a permission.

Because we own the runtime now, a broken workflow means updates stop silently rather than Mend noticing. The failed run is visible in the Actions tab, but nothing pages anyone.

Validate a config change before pushing it:

npx --yes --package renovate renovate-config-validator