DEV Community

Daniel Pertu
Daniel Pertu

Posted on

pnpm 11 holds every new release for 24 hours, and our exclude list is nine pinned Expo versions

Munchable is one pnpm workspace with two apps in it: an Expo app for Android and the web, and a Next.js site that serves the marketing pages and the API. Five workspace packages sit underneath them. One lockfile, one install.

On pnpm 11, that install will not give you a version published this morning.

The setting that does it

minimumReleaseAge has been in pnpm since 10.16, and it defaults to 1440 minutes since v11. A version has to have existed for a day before pnpm will install it, and the rule applies to transitive dependencies too, not just the ones you typed.

The reasoning in the docs is the right reasoning: most malicious releases are pulled from the registry within an hour. A day of latency costs you nothing on a normal Tuesday and saves you from being the person who installed the compromised build in the twenty minutes it was up.

Our pnpm-workspace.yaml does not set it. The default is doing the work, which is the part worth noticing: the behaviour changed under us with a major version of the package manager, not with a line anyone wrote.

Why an Expo monorepo feels it first

Expo does not ship packages, it ships a set. expo@57.0.24 expects the expo-router, expo-updates, expo-asset and @expo/cli of the same moment, and the Metro resolver is unforgiving about the combination. A React Native upgrade is therefore atomic in practice: you move the whole set or you move none of it.

Put a 24 hour quarantine in front of that and an SDK patch upgrade becomes one of two things. Either you wait a day with the diff sitting in the working tree, or you take the set as published and tell pnpm which exact versions you are accepting early.

The second one is what this repo does:

minimumReleaseAgeExclude:
  - expo-image-manipulator@57.0.15 || 57.0.19
  - '@expo/cli@57.0.26'
  - expo-asset@57.0.18
  - expo-constants@57.0.19
  - expo@57.0.24
  - '@expo/metro-runtime@57.0.16'
  - '@expo/ui@57.0.19'
  - expo-router@57.0.22
  - expo-updates@57.0.23
Enter fullscreen mode Exit fullscreen mode

Nine entries, ten versions, every one of them Expo.

The pinned form is the whole trick

minimumReleaseAgeExclude started as a list of package names, and a name-only entry is a permanent hole. expo on that list means every future version of expo skips the wait, forever, including the one published by somebody who has just taken over the publish token.

Version-pinned entries landed in pnpm 10.19, and they behave the way an exception should: expo@57.0.24 exempts exactly that version. The next release gets the full day again, because nobody has written it down. The exception expires on its own.

That also means the list is a historical record rather than a policy. Nine lines of "we were in a hurry on this specific version", which is a much easier thing to read in six months than nine package names with no dates on them.

The cost is cleanup. minimumReleaseAgeExcludePrune would strip entries pnpm no longer needs, but it arrived in 11.22 and this repo is pinned to pnpm@11.18.0 in packageManager. So for now the list is pruned by hand, and the honest status of three or four of those lines is "nobody has been back to delete them yet".

The sibling setting nobody mentions

Two lines above the exclude list:

allowBuilds:
  esbuild: true
  sharp: true
Enter fullscreen mode Exit fullscreen mode

That is the other half of the same posture. A dependency with an install script does not get to run it unless it is named here, and an unlisted one fails the install with ERR_PNPM_IGNORED_BUILDS rather than quietly skipping. Two packages in this repo have a legitimate reason to compile something at install time: sharp, which arrives as an optional dependency of next@16.2.4, and esbuild, which comes in through the build toolchain. Everything else in the tree is data as far as the installer is concerned.

Between them, those two settings describe a fairly plain rule: code from the registry is allowed to be new, or allowed to run, and if it wants both it needs a line with a version number in it.

The two overrides that are not about security at all

While we are in this file, the other block is a peer dependency story:

overrides:
  '@react-native/metro-config': 0.86.3
  '@types/react': ~19.2.18
  '@types/react-dom': ~19.2.7
Enter fullscreen mode Exit fullscreen mode

react-native-worklets declares @react-native/metro-config: '*' as a peer, so with autoInstallPeers on, pnpm fetches the newest line (0.87.x) while @react-native/community-cli-plugin pins react-native's own 0.86.3. Radix, arriving through expo-router, lists the React type packages as optional * peers, and @types/react-dom@19.3 then demands a @types/react that Expo SDK 57 sits below.

Neither is a vulnerability. Both are the same shape of problem as the quarantine: something in the tree is allowed to resolve to "whatever is newest", and in a monorepo with a native toolchain in it, "whatever is newest" is the one thing you cannot have.

What this builds

If you want to see what comes out of the install, the two surfaces are public. The curated recipe library the app filters on device is published as pages on the site, so munchable.app/recipes is the same data the Expo app reads, rendered by Next instead of React Native. The seven condition pages at munchable.app/conditions are the content side of the same build, and munchable.app/answers is the index of the generated question pages that come out of the shared workspace packages.

One lockfile, two very different renderers, and a package manager that now has an opinion about how old a release should be before either of them sees it.

Top comments (0)