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:
enginesin anypackage.json. Node is pinned in.nvmrc,.node-version, the backend Dockerfile and the CI workflows, none of which Renovate manages, so bumpingenginesalone only creates drift.overridesinpnpm-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'soverridessnapshot 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:
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:
- It audits
develop. Ifdevelopis already clean, it comments on the PR to mergedevelop(or, for a PR intorelease/*,hotfix/*ormain, to cherry-pick the fix) and stops. - If an
audit-autofixPR or issue is already open, it links it on the failing PR and stops. If anaudit-autofixPR was closed unmerged in the last 24 hours, it stops. - Otherwise the
security-audit-fixagent (.github/agents/) edits theoverridesandauditConfiginpnpm-workspace.yamland regeneratespnpm-lock.yaml. It uses per-major scoped pins when more than one major is in the tree, respectsminimumReleaseAge, and adds anignoreGhsasentry only for a build-time-only advisory with no usable fix. - The gate is
pnpm audit --audit-level highpluspnpm install --frozen-lockfile. The workflow then opens a draft PR intodevelopfrom anaudit-fix/<hash>branch, labelledaudit-autofixandauto-fix(andneeds-humanif the gate failed). The draft PR's ownCI - Backendrun is the full regression check. - If the agent changes nothing (for example, the only patched release is
younger than
minimumReleaseAge), it opens anaudit-autofixissue 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.
rangeStrategyisreplace, sodio: ^5.3.0only moves when a release falls outside the range.flutter pub getalready 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.lockcomes from apostUpgradeTask, not from the pub manager. Renovate'supdateArtifactslooks 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-lockfilethat would leave every Flutter PR red, so Renovate runsflutter pub getitself 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:
scripts/frontend/goldens.Dockerfileandscripts/frontend/run-goldens.shcarry aFLUTTER_COMMITSHA next toFLUTTER_VERSION, and theflutter-versiondatasource 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 withgit ls-remote https://github.com/flutter/flutter refs/tags/<version>.- Regenerate every golden baseline with
melos run goldens_update. A different Flutter build rasterises differently, so the committed baselines stop matching. apps/*/patrol_test/tools/run_patrol.shhas a hard-codedVER=fallback, used only when.fvmrccannot 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: