Keep Lerna Monorepos Updated with Renovate

Christian Emmer
Christian Emmer
Nov 4, 2023 · 9 min read
Keep Lerna Monorepos Updated with Renovate

Keeping dependencies up to date is important for every codebase, and there are a few strategies for Lerna monorepos.

I migrated 15 Node.js projects to a Lerna monorepo earlier this year for easier maintenance and then let it stagnate because so few people are using the packages. The projects previously had a Renovate config that would automatically bump the patch version & publish to npm once a month, and I wanted to bring that back.

Migrating Existing Repos to a Lerna Monorepo
Migrating Existing Repos to a Lerna Monorepo
Feb 20, 2023 · 9 min read

As of writing, I maintain 15 Metalsmith plugins, and it has become a pain to manage all of them independently.

Here's the methodology I used, and some alternatives you could consider.

My situation

My requirements for a new Renovate setup were:

  • Separate versions - all packages have separate versions from before they were migrated to a monorepo, and I want to keep them separate for semver reasons
  • Automated releases - I want updates to dependencies (and not dev dependencies) to bump each package's patch version, such that they will be automatically published to npm
  • Minimal oversight - I haven't been investing in these packages due to their low usage, so I want their ongoing maintenance to be as automated as possible
  • Limited schedule - to reduce the potential spam of new versions being published to npm, I want to limit automated version bumps to once a month

Strategy: grouping dependency types

My packages share a lot of the same libraries, especially dev dependencies such as Jest and ESLint . Each package has its own package.json that contains out these libraries and their version range.

By default, Renovate will create one pull request per library being updated, and every usage of that library will be updated at once. That means only a single pull request would be opened for a single ESLint update, and it would update every package.json file at once.

That's great, that's what I want! That keeps the amount of pull requests being created to a minimum, and I'd rather every Lerna package have the same library versions. But that can still be a lot of pull request spam if you make use of a lot of libraries.

I like grouping by dependency types (dependency, dev dependency, peer dependency, etc.), with major version updates of any kind always being in their own pull request. This type of grouping runs the risk of a single faulty patch or minor version update holding back updates to other libraries, but that's acceptable to me.

The Renovate config for that would look something like this:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: ['config:recommended'],

  // If multiple major updates are available, create a separate pull request for each
  separateMultipleMajor: true,

  npm: {
    packageRules: [
      // Dependencies
      {
        // Group non-major dependency updates together
        groupName: 'dependencies',
        matchDepTypes: ['dependencies'],
        matchUpdateTypes: ['patch', 'minor']
      },

      // Dev dependencies
      {
        // Group non-major devDependencies together
        matchDepTypes: ['devDependencies'],
        groupName: 'dev dependencies',
        matchUpdateTypes: ['patch', 'minor']
      },
      {
        // Group ESLint together
        matchPackagePatterns: [
          '^@typescript-eslint',
          '^eslint'
        ],
        groupName: 'ESLint'
      },
      {
        // Group Jest together
        matchPackageNames: [
          '@jest/globals',
          '@types/jest',
          'jest',
          'jest-junit',
          'ts-jest'
        ],
        groupName: 'Jest',
      },
      {
        // Separate Lerna
        matchPackageNames: ['lerna'],
        groupName: 'Lerna',
        prPriority: -10
      },

      // Everything else
      {
        matchDepTypes: ['optionalDependencies', 'peerDependencies', 'engines'],
        enabled: false
      }
    ]
  }
}

That will cut down on a lot of pull requests!

Alternative strategy: grouping by Lerna package

Maybe you would prefer to manage each package separately, creating separate pull requests for each package. Doing so will likely increase the number of pull requests, but it means library updates affecting one Lerna package won't hold back updates to other packages.

The Renovate config for that would look something like this:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: ['config:recommended'],

  npm: {
    // Group by each package.json's parent directory name
    additionalBranchPrefix: '{{{parentDir}}}-',
    commitMessagePrefix: '{{#if parentDir}}{{{parentDir}}}:{{/if}}',

    packageRules: [
      {
        // Group non-major dependency updates together
        groupName: 'non-major dependencies',
        matchUpdateTypes: ['patch', 'minor']
      }
    ]
  }
}

Strategy: automatic version bumps

I have a GitHub Action workflow that runs lerna publish on every commit to the main branch. This ensures that any version bump in any pull request will result in a release.

Because I want Renovate dependency updates to be as automated as possible, I want the patch version of every relevant package.json to get bumped on dependency updates only (not dev dependencies, peer dependencies, etc.).

The Renovate config for that would look something like this:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: ['config:recommended'],

  // Automatically merge PRs without a human approval, creating automated releases
  automerge: true,
  platformAutomerge: true,

  npm: {
    // Bump version ranges even if the new library version is in the old range
    rangeStrategy: 'bump',

    packageRules: [
      {
        // Dependency updates bump the patch version
        matchDepTypes: ['dependencies'],
        bumpVersion: 'patch'
      }
    ]
  }
}

Strategy: reducing pull request spam

Because I want dependency updates to create new patch versions, this has the possibility of publishing new versions to npm frequently. That would be really annoying to users, so I want to reduce how often versions might get published.

There are a few Renovate settings you should consider:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: ['config:recommended'],

  // Open PRs immediately for vulnerability alerts, ignoring any other schedule
  // Requires "dependency graph" as well as Dependabot "alerts" and "security updates" enabled for the repo
  vulnerabilityAlerts: {
    // Renovate's defaults for `vulnerabilityAlerts`
    groupName: null,
    schedule: [],
    dependencyDashboardApproval: false,
    minimumReleaseAge: null,
    rangeStrategy: 'update-lockfile',
    commitMessageSuffix: '[SECURITY]',
    branchTopic: '{{{datasource}}}-{{{depName}}}-vulnerability',
    prCreation: 'immediate'
  },

  // Limit dependency updates to once a month
  schedule: 'on the 1st day of the month',
  prCreation: 'immediate', // default
  prHourlyLimit: 0, // no limit
  automerge: true,
  platformAutomerge: true,

  npm: {
    // Update lockfiles, but do it one day after dependencies to reduce conflicts
    lockFileMaintenance: {
      enabled: true,
      schedule: 'on the 2nd day of the month'
    },

    packageRules: [
      // Group/separate all dependency pinning, perform it immediately
      {
        matchUpdateTypes: ['pin'],
        groupName: 'dependency ranges',
        // Renovate's defaults for these options
        schedule: 'at any time',
        prCreation: 'immediate'
      }
    ]
  }
}

Bringing it all together

The current emmercm/metalsmith-plugins Renovate config combines all of these strategies together, resulting in a Lerna monorepo with unattended version updates:

{
  $schema: 'https://docs.renovatebot.com/renovate-schema.json',
  extends: [
    'config:recommended',
  ],
  dependencyDashboard: true,
  configMigration: true,

  // Personal preferences
  timezone: 'America/Los_Angeles',
  assignees: ['@emmercm'],
  reviewers: ['@emmercm'],
  assignAutomerge: false, // default

  // Open PRs immediately for vulnerability alerts
  // Requires "dependency graph" as well as Dependabot "alerts" and "security updates" enabled for the repo
  vulnerabilityAlerts: {
    labels: ['security'],
    platformAutomerge: true,
    // Renovate's defaults for `vulnerabilityAlerts`
    groupName: '',
    schedule: [],
    dependencyDashboardApproval: false,
    minimumReleaseAge: '',
    rangeStrategy: 'update-lockfile',
    commitMessageSuffix: '[SECURITY]',
    branchTopic: '{{{datasource}}}-{{{depName}}}-vulnerability',
    prCreation: 'immediate',
  },
  // WARN: "When the lockfileVersion is higher than 1 in package-lock.json, remediations are only possible when changes are made to package.json."
  transitiveRemediation: true,

  // Separate potentially breaking updates, group others
  separateMultipleMajor: true,
  separateMajorMinor: true, // default
  separateMinorPatch: false, // default

  // Allow auto-merging of PRs, but reduce their spam on the commit log
  schedule: 'on the 1st day of the month',
  prCreation: 'immediate', // default
  prHourlyLimit: 0, // no limit
  automerge: true,
  platformAutomerge: true,

  // Don't update any Node.js versions (nvm, Volta, etc.)
  packageRules: [
    {
      matchCategories: ['node'],
      enabled: false
    },
  ],

  npm: {
    lockFileMaintenance: {
      // These options are required to override the `lockFileMaintenance` defaults
      enabled: true,
      schedule: 'on the 2nd day of the month', // one day after the above, to de-conflict
    },

    // Stability settings: don't raise a PR until a dependency is at least 3 days old
    rangeStrategy: 'bump',
    minimumReleaseAge: '3 days',
    internalChecksFilter: 'strict',

    packageRules: [
      // Dependencies
      {
        // Group non-major dependency updates together
        groupName: 'dependencies',
        matchDepTypes: ['dependencies'],
        matchUpdateTypes: ['patch', 'minor']
      },
      {
        // Dependency updates bump the patch version
        matchDepTypes: ['dependencies'],
        bumpVersion: 'patch'
      },

      // Dev dependencies
      {
        // Group devDependencies together
        matchDepTypes: ['devDependencies'],
        groupName: 'dev dependencies',
        // Only group non-major updates
        matchUpdateTypes: ['patch', 'minor']
      },
      {
        // Group ESLint together so files can be fixed automatically
        matchPackagePatterns: [
          '^@typescript-eslint',
          '^eslint',
        ],
        groupName: 'ESLint'
      },
      {
        // Group Jest together because of peerDependencies
        matchPackageNames: [
          '@jest/globals',
          '@types/jest',
          'jest',
          'jest-junit',
          'ts-jest',
        ],
        groupName: 'Jest'
      },
      {
        // Separate Lerna
        matchPackageNames: ['lerna'],
        groupName: 'Lerna',
        prPriority: -10,
      },

      // Group/separate all dependency pinning, perform it immediately
      {
        matchUpdateTypes: ['pin'],
        groupName: 'dependency ranges',
        // Renovate's defaults for these options
        schedule: 'at any time',
        prCreation: 'immediate',
        platformAutomerge: true
      },

      // Everything else
      {
        matchDepTypes: ['optionalDependencies', 'peerDependencies', 'engines'],
        enabled: false
      }
    ]
  }
}