How I Established a Good Release Process in JavaScript

As an experienced full-stack JavaScript developer, I‘ve seen firsthand how much impact the release process can have on the success of a project. Releasing software is complex and fraught with risk – a bad release can result in downtime, lost revenue, and frustrated users. But a good release process allows teams to deliver value quickly and reliably.

In this article, I‘ll share how my team established a release workflow for our Node.js applications that increased our velocity and quality. I‘ll dive into the key elements of an effective release process, the specific approaches and tools we use, and the lessons we learned along the way. Whether you‘re a developer, a team lead, or just interested in leveling up your DevOps skills, I hope you‘ll find these battle-tested insights useful.

The High Cost of Bad Releases

Before getting into the solution, it‘s important to understand the problem. Bad releases are costly in many ways:

  • Lost revenue and productivity – Downtime and malfunctioning software directly impacts the business‘ ability to make money and operate efficiently. Amazon, for example, is estimated to lose $220,000 for every minute of downtime.

  • Damaged reputation – Buggy or unreliable software erodes user trust. In a survey by Dimensional Research, 88% of respondents said they‘d be less likely to use a service again after a bad experience.

  • Wasted development cycles – Every hour spent fighting fires after a bad release is an hour not spent building new features or improving the product.

The data shows that bad releases are extremely costly. So what can software teams do to prevent them? It starts with having a robust release process in place.

Key Elements of a Good Release Process

An effective release process is the result of many tools and practices working together in concert. Here are the key elements we focused on in building ours:

Source Control and Git Workflows

At the heart of the release process is the way the team manages source code. We use Git for version control, which gives us powerful tools for parallel development, code review, and release management.

But Git is very flexible, and teams need to agree on a specific workflow that defines how changes flow from development to production. Two of the most popular are:

  • Git Flow – Created by Vincent Driessen, Git Flow uses long-running develop and master branches, along with temporary feature, release, and hotfix branches. It‘s a comprehensive model but can be overly complex, especially for web applications.

  • GitHub Flow – Popularized by GitHub, this simpler model uses a single long-running master branch along with short-lived feature branches. It optimizes for continuous deployment and works well for SaaS applications.

We opted for a modified GitHub Flow. Here‘s a step-by-step walkthrough:

  1. A developer creates a new feature branch from master
  2. They make their changes in the feature branch, committing locally
  3. When done, they push the feature branch to GitHub and open a pull request
  4. The team reviews the changes, discusses, and requests modifications if needed
  5. Once approved, the PR is merged into master, kicking off automated tests and deployments

Using feature branches keeps master stable and allows for easy rollbacks if issues are detected. And the PR flow ensures all code is reviewed before merging.

Semantic Versioning

With the source control workflow established, we needed a way to version and identify our releases. For that we rely on semantic versioning (SemVer).

SemVer specifies a MAJOR.MINOR.PATCH versioning scheme, where:

  • MAJOR is incremented for incompatible API changes
  • MINOR is incremented for backwards-compatible feature additions
  • PATCH is incremented for backwards-compatible bug fixes

For example, if our current version is 2.4.3:

  • A new feature release would be 2.5.0
  • A bugfix release would be 2.4.4
  • A breaking change would be 3.0.0

This gives meaning to our version numbers and allows us to communicate the scope of changes in each release. Tools like NPM use SemVer to determine dependency resolution.

We use the npm version command to bump the version in package.json:

npm version patch -m "Bump version to %s"

This also creates a Git tag like v2.4.4 pointing at the release commit. We can then push the tag to GitHub and use it to identify the exact code that went into that release.

Dependency Management

Another critical part of the release process is dependency management. Our Node.js apps have dozens of third-party dependencies specified in package.json.

Initially we used non-pinned dependency versions, like this:

"dependencies": {
  "lodash": "^4.17.11" 
}

The caret (^) means "any version greater than 4.17.11 but less than 5.0.0". This caused inconsistencies between environments and made it difficult to reproduce bugs, because every npm install could potentially upgrade a dependency.

Now we pin all dependencies to a specific version:

"dependencies": {
  "lodash": "4.17.11"
}

And we commit the package-lock.json file, which stores the exact versions used. This ensures all environments install the same dependency tree.

But pinned dependencies introduce a new challenge: keeping them up-to-date with the latest security fixes and improvements. To automate this, we use a tool called Renovate. Renovate scans our dependencies and opens PRs with version bumps for us to review.

For example, if lodash releases v4.17.12, Renovate will open a PR like this:

Renovate PR Example

This lets us keep dependencies fresh without manual toil. And the PR flow means we can test the upgrades before applying them.

Automation and CI/CD

The final key to our release process is automation, powered by continuous integration and deployment (CI/CD). We use tools like CircleCI and Travis to automatically run tests and deploy releases.

Here‘s a simplified overview of our CI/CD pipeline:

  1. Developer pushes code to GitHub
  2. CI server detects changes and checks out the code
  3. CI runs automated tests and linters
  4. If tests pass, CI builds a deployable artifact (Docker image, zip file, etc)
  5. CI deploys the artifact to a staging environment
  6. Team manually smoke tests the staging deploy
  7. If validated, release is promoted to production

The key is that every change goes through the same pipeline. We can deploy to production with confidence because every commit has passed through this gauntlet. And by automating the mechanical parts of the process, we free up the team to focus on higher level concerns.

Lessons Learned

Of course, implementing all of this was a journey. Here are some of the key lessons we learned:

Pull Requests Are Powerful

We‘re religious about putting every change behind a pull request, even small ones. At first this felt heavy, but it has huge benefits:

  • Every change is peer reviewed, catching bugs and spreading knowledge
  • Discussions are captured in the PR, creating a useful log
  • Code quality is higher and more consistent
  • The team has better visibility into what‘s changing

If I could recommend one practice to adopt, it would be this. Pull requests are a forcing function for better collaboration and higher quality code.

Keep Feature Branches Short

Scoping feature branches to small, incremental changes was another lesson hard-won. Long-running feature branches tend to diverge from master, making them difficult to merge and deploy.

We aim to get feature branches merged within a day or two at most. This keeps us in a state where master is always deployable, and reduces the risk and pain of merges.

Avoid Merge Commits

By default, Git creates a merge commit when you merge a branch. These don‘t serve a useful purpose and pollute the commit history.

Instead, we use the --squash flag when merging PRs:

git merge --squash my-feature-branch

This takes all the changes from the feature branch and squashes them into a single commit on master. The result is a clean, linear history that‘s easy to read and revert if needed.

Pin All Dependencies

As noted above, we pin all dependency versions in package.json. This includes dev dependencies like linters and test frameworks.

Pinning dependencies in package.json and committing package-lock.json ensures deterministic builds across all environments. It eliminates "works on my machine" bugs and makes deployments more predictable.

Automate Almost Everything

A theme you may have noticed is that we automate as much of the release process as possible. From testing to versioning to deployment, if a computer can do it, we prefer to let it.

Automation reduces human error, speeds up cycles, and makes the process reproducible. Crucially, it doesn‘t eliminate the need for human judgment and communication – it just applies them to higher-value concerns.

We‘re always looking for opportunities to further automate our release pipeline. Tools like semantic-release are on our radar to automate the versioning and changelog generation pieces. But we‘re careful not to over-automate and remove human input entirely.

Adapt the Process to the Team

Finally, it‘s important to recognize that no single process fits every team perfectly. While the overall release flow we‘ve landed on works great for us, we‘re always tweaking and adapting it based on new learnings and team changes.

For example, we realized that not every dependency update from Renovate needed to go through the full PR process. So we added some automated rules that automerge patch-level updates after they pass CI.

The key is to stay flexible and adapt the process to the team, not the other way around. Regularly reflect on what‘s working and what‘s not, and be open to experimenting with new tools and approaches.

Recommended Best Practices

Drawing from our experience, here are some best practices I recommend to any team looking to improve their JavaScript release process:

  • Use semantic versioning (SemVer) and meaningful Git tags to identify releases
  • Follow a Git workflow like GitHub Flow, keeping master deployable and using short-lived feature branches
  • Have every change go through a pull request for review and discussion
  • Pin all dependency versions and commit package-lock.json to ensure deterministic builds
  • Automate testing, building, and deployment with CI/CD
  • Monitor production closely after each release to catch issues early
  • Stay flexible and adapt the process as the team and project evolve

Tools and Resources

Here are some of the tools and resources we‘ve found indispensible in evolving our release process:

Conclusion

Developing software is hard. Releasing it to the world is even harder. But with the right process and tools, it‘s possible to make releasing software a reliable, repeatable, and even enjoyable part of the development lifecycle.

The process I‘ve outlined here has served my team incredibly well – but it didn‘t emerge fully-formed overnight. It was the result of many iterations, experiments, and learning from our failures and successes.

If there‘s one thing I‘ve learned in my career as a software engineer, it‘s that the process is never "done". Technologies evolve, team dynamics change, and what worked yesterday may not work today. The key is to stay open to change and to relentlessly refine and improve.

So whether you adopt the tools and processes I‘ve suggested here or blaze your own trail, I encourage you to treat your release workflow as a product unto itself. Invest time and thought into making it the best it can be for your team‘s unique context and constraints. The effort you put in will pay huge dividends in productivity, quality, and team happiness.

Happy shipping!

Similar Posts