How to Keep Your App Dependencies Up-To-Date?

How to Keep Your App Dependencies Up-To-Date?

What are the challenges and how to automate maintenance properly?

One aspect of software development is often overlooked: maintaining dependencies. I won’t lie, for a long time, it was the case for me and my teams.

Why spend time on maintenance?

Keeping out-of-date dependencies will not only create incompatibilities, or look uncool. It raises two huge issues: security and performance.

Remember the Equifax breach? Well, that’s a $425M settlement that could have been avoided, but how? René Gielen, vice president of Apache Struts, says:

Most breaches we become aware of are caused by failure to update software components that are known to be vulnerable for months or even years

Yes, maintaining dependencies also has a huge impact on performance. A use-case with React Native navigation is a good example. It shows how the newest version brings higher performance but also helps to improve code quality.

Challenges

There is one primary issue when it comes to upgrading dependencies: time.

You must be aware of every single dependency update and apply the necessary change. For huge applications, it’s not uncommon to have multiple updates on a single day.

Applying the necessary change means:

  • Updating your configuration (package.json with NodeJS, Maven config with Java, or pip for Python, ..)

  • Usually sending the upgraded configuration to a GIT repository

  • If you want your application to work, you need to test your new configuration (and sometimes make changes) before adding them to your main work.

Some tools allow you to get a list of dependencies to update. NPM for example has a built-in command. Other community tools help achieve the same result, with more functionalities, like npm-check-updates.

And here you face the second challenge: will this update break anything?

Introduction semver

Package managers ask developers to follow a versioning system (NPM, Maven, pip). This usually consists of, at least, a minor and major version. Minor versions specify a new functionality, while major versions indicate a BREAKING CHANGE.

That’s why version ranges are often used: you specify your dependency version but accept newer, minor updates by default. So we can confidently use new minor updates, right? Well, no.

You can be as confident in minor updates not breaking anything, as you are in the developer who’s creating the library. A word of advice: don’t.

That means, for every update, you need to verify nothing is broken. It’s possible for any project, without setting up a dedicated architecture & process, by testing manually. But is that the right solution?

For an application of Medium size, listing and updating out-of-date dependencies every day is already a struggle. Adding manual tests is simply unrealistic, you would neither have the time for it nor would you be able to avoid breaking changes every time.

Solution

One word: automation. That’s the only way to make this process not only bearable but realistic.

Getting out-of-date dependencies

First and foremost, we need to fetch which dependency needs to be updated, on a recurring basis. There are already several great solutions out there, that can be integrated into GIT providers.

Once configured, they create a pull request with an updated configuration for the new dependency. You can, in turn, choose to merge those to your main branch. I’ve considered two tools during my search: renovatebot and dependabot.

Both are great tools and work with a wide range of languages. Dependabot has the advantage to be directly integrated into Github. On the other hand, Renovate can work with multiple providers.

My journey started with GitLab, so I went with Renovate and continued to do so, even if I’m using it with Github right now. If you’re working with Github, feel free to start with Dependabot!

Pinning dependencies

Before talking about issues related to dependency updates, let’s talk about pinning dependencies. The first pull request made by Renovate is to pin your dependencies: what is it, and why?

Renovate has a great section about version range and pinning dependencies. Pinning dependencies means using an exact version for dependencies, instead of a range. Remember my advice about not blindly trusting developers who are creating libraries?

I can bet 99.9% of my readers had the following issue happen at some point in their journey.

You worked on a project for some time, but life happens and you put it on hold, everything is working fine and saved on your favorite GIT provider. Several months later, you clone your repository and start working on it again, but look out: you cannot build your app, something is broken.

How? Well, that’s the danger with version range. At some point, a library creator released a faulty version. Maybe it rendered it incompatible with another library, or it can’t work with your code.

Version ranges are a great idea, in theory. Use exact versions instead, or in other words, pin your dependencies.

Avoid breaking changes

At this point, we should understand and accept any dependency update might break something.

Actually, updating a dependency is no different than adding a new feature. Software Engineering already has techniques to ensure everything works fine, what I’m personally using is:

  • Static analysis

  • Unit/Integration tests

  • e2e tests

  • Sometimes, manual testing

Because dependency update tools work with pull requests, it’s very easy to run checks/tests. This allows for complete and quick feedback on: did this update break anything? Also, we ensure everything is fine before merging anything to our main work.

In practice, this is done thanks to a CI. In Renovate case, branches are created with the name renovate/<dependency>. CI can be configured to automatically run syntax checks, unit/integration, or e2e tests on those specific branches.

Sometimes, we want even more confidence, for example about a major version of an important library. Applications can be deployed, allowing for easy manual testing when needed.

Most updates won’t need manual testing, meaning everything can be automated, even merging the dependency update!

Configuration Options

That’s right, most dependency update tools can be configured to merge pull requests automatically if tests succeed. Exceptions can be given, for the most important libraries when automated tests are not enough, or for development tools.

How to ensure a major version of husky doesn’t break your configuration? What about commitlint? Risks coming with auto-merge can be accepted, or an exception added.

Suggestions

Updating a dependency to its latest version doesn’t invariably improve security and performance.

Occasionally, it could introduce a vulnerability. If you’re unlucky, a change in the build system could add a huge chunk to your bundle, in turn decreasing performance.

This is not a reason to avoid updates: a new version might cause an issue, an older version will be way worse. It means performance testing, as well as security tools, should be used.

For example, a huge difference between multiple runs of lighthouse might indicate a performance issue.

On the other hand, regularly running snyk is a great security practice.

Dependency Update in Practice

At the time of writing these lines, I have several hosted apps. They are made with React (with either CRA, Gatsby, or Next), React Native, and Electron (React Forge + React).

They are all configured with Renovate and hosted on Github. I work with CircleCI as it’s performant and not too expensive. CircleCI is configured to run tests on all renovate/<dependency> branches and deploy everything to a test environment.

Auto-merge is enabled, to run the following tests and merge the dependency update if everything succeeds. Some exceptions are added for specific libraries I want to manually verify before upgrading.

Unit tests and syntax

The first tests are always the same, no matter which technology is used:

  • All of those applications are made in TypeScript. My first syntax checks use tsc with the --noEmit flag, as well as ESLint and Prettier.

  • Then, my unit/integration tests are running. Those are written thanks to Testing Library.

From a general perspective, the second part of the process is the same, but the tech stack differs.

End-to-end

For applications made with CRA, Gatsby, or Next, e2e tests are run thanks to Cypress.

Cypress cannot be used for React Native and no longer supports Electron.

I have selected Detox for React Native as it was my favorite from experience. For Electron, Spectron is now deprecated but the deprecation announcement suggests Playwright.

All of those can be configured quite easily to run on CircleCI. There is an issue for React Native, which needs an Android and IOS environment to run.

Fortunately, CircleCI provides Android and IOS executors.

This way, I’m able to run all my tests in the right environment, making me confident enough to allow auto-merge.

Deployment

I need to access a production build of my app in two situations:

  • For my main branch, to have the final result.

  • For a branch created to update a specific dependency (where auto-merge is disabled).

I use different services depending on which technology I’m using.

Netlify is a great solution for a static app made with CRA or Gatsby for example. It can be configured to deploy your main branch, and for all pull requests made against your main branch.

In theory, Netlify should be able to deploy a Next application, but in practice, it’s barely stable. Thankfully, Vercel provides the same functionalities, focused on Next.

For React Native, the apps for Android and IOS are built on the pipeline and uploaded as artifacts. This allows me to download them when needed and test them on my device.

Finally, apps with Electron are also uploaded as artifacts we can download.

Conclusion

You now have an idea of how medium and bigger apps keep their dependencies up-to-date. The practical solutions should help you set it up.

Feel free to share your thought and how you’re personally handling those issues!


Cover photo by fahrulazmi on Unsplash