In our post on versioning, we laid the groundwork for understanding software package versioning — a key part of managing dependencies and tracking changes effectively.
We also explored the pros and cons of multirepo vs. monorepo architectures, and why we recommend starting with a monorepo setup for most teams.
In another deep dive, we unpacked strategies for handling independent vs. locked versioning in a shared workspace.
Now, in this post, we're bringing all those threads together. We'll walk through how to design a robust, scalable release and deployment pipeline that builds on the principles we've already discussed.
At Prosopo, we've grown rapidly by delivering high-quality software at scale. But, as with any scaling software team, we've encountered our fair share of growing pains.
From day one, we adopted a simple three-branch model:
main
– The production branch. Any code pushed here is automatically released and deployed via CI/CD pipelines.staging
– Mirrors main
, but deploys to a staging environment instead.dev
– Our bleeding-edge development branch, used for local development only. It’s not connected to any environment or CI/CD pipeline.We initially opted for a multi-repo setup, but quickly switched to a monorepo. This transition helped alleviate early pains and significantly accelerated development.
This structure served us well in the early days. The staging
branch gave us a clean environment for QA and testing, while main
ensured seamless, automated production deployments. For a small, fast-moving team, it was ideal.
However, as our product matured and our engineering team expanded, cracks began to show:
dev
branch entirely, pushing and merging directly into staging
.staging
became a bottleneck: every new feature and bug fix branched from and merged into it.staging
was rarely in a production-ready state. Partial features and incomplete fixes made it hard to ship anything without breaking something else.This single-track development approach led to frequent blocking, frustration, and ultimately — hotfixes being made directly on main
. While this sped up urgent fixes, it sometimes skipped QA entirely, leading to minor regressions.
Because main
was wired to auto-release, changes were deployed immediately — great for speed, terrible for rollbacks. We've had a few rough (thankfully minor) releases where a broken service couldn’t be easily reverted because our DevOps system doesn’t support quick rollbacks (i.e., no easy way to redeploy a previous version).
To complicate matters further, we use a locked versioning strategy. This means every package bump requires a coordinated update across the entire monorepo. In practice:
In short, things got messy. But this isn’t uncommon.
What started as a lightweight model — perfect for a small team and a new product — eventually buckled under scale. We successfully scaled our product and codebase, but not our DevOps infrastructure. We estimate this inefficiency has slowed us down by at least 5x, thanks to feature blocking and insufficient hotfix testing.
Our development velocity was dropping. We knew we had to act fast.
Down the rabbit hole we go...
Before we dive into solutions, it's important to deconstruct the challenge into smaller, actionable requirements. Here's what we need our system to support:
To address the challenges we outlined, here's the approach we've designed:
main
– Continues to serve as our production branch.staging
– Remains our integration/testing branch for pre-production validation.build/*
– Any branch following this pattern is treated as a build branch, used for creating temporary or pre-release package versions.This setup gives us clear boundaries between stable releases, staging changes, and experimental builds.
We've adopted Changesets as our tool of choice for managing versioning across the monorepo. It offers a lightweight and flexible way to track and apply version bumps based on actual changes.
Other tools we considered include:
nx
– Feature-rich but too opinionated and heavy for our use case.lerna
– Once popular, but now largely a wrapper around nx
after becoming unmaintained.rush
– A strong alternative to changesets
that we may explore further (we discovered it a bit too late in our implementation 🙃).Changesets works by generating small markdown files that describe changes in one or more packages. For example:
---
"my-package": patch
---
Fixed a bug where the API would return incorrect data under certain conditions.
changesets
simplifies version management by storing change descriptions in markdown files under the .changesets
directory. For example:
/.changesets/
change1.md
change2.md
change3.md
To create these files, changesets
provides an interactive CLI tool. By running npx @changesets/cli add
, users are guided through selecting the affected packages, specifying the type of change (patch, minor, or major), and providing a description. This ensures all changes are properly documented and versioned.
Additionally, changesets
can validate that every modified package has a corresponding changeset file using npx @changesets/cli status
. This is particularly valuable for monorepos with multiple packages, as it guarantees that all changes are accounted for. To enforce this, we've integrated a CI/CD pipeline that runs on pull requests. Any pull request must include changeset files for modified packages, ensuring that every change is described, justified, and categorized (patch, minor, or major).
By adopting an independent versioning strategy, we ensure that only the packages with actual changes are version-bumped. This approach works seamlessly with changesets
, allowing us to accumulate changeset files across multiple pull requests and release them efficiently when ready.
One of the standout features of changesets
is its robust support for pre-release versions. By enabling pre-release mode (npx @changesets/cli pre
), we can generate pre-release versions of our packages, making it easier to test new features or bug fixes in non-production environments before rolling them out to production. These versions are tagged with a suffix, such as my-package@1.2.3-alpha.0
, allowing us to clearly distinguish between pre-release and production-ready packages.
Pre-releases help prevent version pollution. Without them, every test iteration would require a version bump. For instance, fixing a bug in my-package@1.2.3
might take 10 iterations, resulting in versions like my-package@1.2.4
, my-package@1.2.5
, and so on, up to my-package@1.2.13
. This would clutter the version history with incomplete fixes, leading to confusing changelogs and unnecessary noise.
With pre-release versions, we can incrementally test changes using versions like my-package@1.2.4-alpha.0
, my-package@1.2.4-alpha.1
, and so forth, until the bug is resolved. Once the fix is finalized, we can release the package as my-package@1.2.4
, keeping the version history clean and concise. Pre-release versions are stored in a private registry to avoid cluttering the public registry. After the final release, these pre-release versions can be removed from the private registry, ensuring a tidy and streamlined versioning process. This approach provides a much cleaner and more efficient way to manage intermediate changes during development.
With a collection of changesets in place, releasing becomes a streamlined process. By running changesets
, the changeset markdown files are applied to their respective packages, automatically bumping their versions. For example:
Suppose we have the following packages:
a
@1.2.3
b
@1.2.3
c
@1.2.3
Here, package c
depends on b
.
Now, consider a single changeset file:
---
"a": minor
"b": major
---
Added a new feature to a, fixed a bug in b.
When we apply the changesets using npx @changesets/cli version
, the result is:
a
@1.3.0
(minor version bumped due to a new feature)b
@2.0.0
(major version bumped due to a breaking change)c
@1.2.4
(patch version bumped because its dependency b
was updated)changesets
handles versioning intelligently, ensuring dependent packages are updated appropriately and eliminating the need for manual version management. This approach guarantees consistent and accurate versioning across all packages.
Additionally, the release pipeline adapts based on the branch. On the main
branch, pre-release mode is disabled, and all packages are published as production-ready versions to both private and public registries. On other branches (e.g., staging
or dev
), pre-release mode is enabled, and packages are published as pre-release versions to private registries only.
Deployments can quickly become unmanageable if deployment logic is intertwined with business logic. For instance, in our lambdas
package, we have several handlers for serverless functions. These handlers are bundled into a single JavaScript file and deployed to AWS using the aws serverless
tool. Previously, the package itself included both the build and deploy commands as npm scripts. However, this approach is unsuitable for our new structure because it tightly couples deployment logic with the package.
If we were to change the deployment logic—such as switching from AWS to Google Cloud—we would need to release a new version of the package to accommodate these changes. Similarly, deploying an older version of the package to a new platform would be impossible because the older version would still contain deployment logic specific to AWS.
To address this, we decoupled deployment logic from the packages and centralized it in a dedicated deploy
package. This separation allows us to version our deployment scripts independently. For example:
deploy
@1.2.3
contains logic to deploy the lambdas
package to AWS.deploy
@1.2.4
contains logic to deploy the lambdas
package to Google Cloud.This setup enables us to deploy any version of the lambdas
package to Google Cloud using version 1.2.4
of the deploy
package. If we need to deploy an older version of the lambdas
package to AWS (e.g., for a legacy hotfix), we can use an earlier version of the deploy
package that targets AWS.
This decoupling ensures flexibility, allowing us to adapt to changes in deployment logic, infrastructure providers, or service management without impacting the business logic of the lambdas
package.
Another critical improvement we made is ensuring that a package's build artifacts include everything required to run it. This means any version published to a registry can be downloaded and executed without additional dependencies. Runtime data, such as environment-specific configurations, is supplied via environment variables or configuration files. This approach further decouples package logic from deployment-specific configurations, enhancing portability and maintainability.
The deploy
package is responsible for preparing the environment by populating variables and configuration files before deploying a specific package version to a target environment. It can be executed using commands like:
npx @prosopo/deploy --package lambdas@1.2.3 --environment staging
This command can be run locally or within CI/CD pipelines, providing flexibility and maintaining development velocity.
Here’s an overview of our CI/CD pipelines:
test
: Executes automated tests to validate code changes. This pipeline is triggered by pull requests into main
or staging
and blocks merging if any tests fail.lint
: Ensures code quality by running linting checks. Similar to the test
pipeline, it is triggered by pull requests into main
or staging
and prevents merging if issues are detected.changesets
: Verifies that changeset files are included for all modified packages. This pipeline runs on pull requests to any branch, ensuring that all changes are properly tracked and documented.release
: Processes changeset files to bump package versions. This pipeline is triggered by pull requests into main
, staging
, or build/*
branches. For branches other than main
, it generates pre-release versions of the packages.deploy
: A manually triggered pipeline that uses the deploy
package to deploy specified packages to a target environment. This pipeline simplifies deployments by automating the execution of the deploy command within the CI/CD system.You can checkout our workflows for GitHub Actions here.
lambdas@1.2.3
PackageLet’s walk through an example of fixing a bug in the lambdas@1.2.3
package using our streamlined process.
Start by branching off main
to fix/my-bug-fix
. Implement your initial fix for the bug, then run the following command to create a changeset file:
npx @changesets/cli add
This generates a file in the .changesets
directory with the following content:
---
"lambdas": patch
---
Fixed a bug where the API would return incorrect data under certain conditions.
Next, create a build branch from fix/my-bug-fix
named build/my-bug-fix
and push it to the remote repository. This triggers the release pipeline, which detects the changeset file and recognizes that the branch is not main
. As a result, a pre-release version of the package is created: lambdas@1.2.3-my-bug-fix.0
. This version is then published to the GitHub NPM registry under private permissions.
To test the fix, deploy the pre-release version to a development environment using the following command:
npx @prosopo/deploy --package lambdas@1.2.3-my-bug-fix.0 --environment dev-env-1
This deploys the lambdas@1.2.3-my-bug-fix.0
package to the dev-env-1
environment using the deployment logic in the deploy
package, where you can QA the fix.
If the bug persists, return to the fix/my-bug-fix
branch, make additional changes, and repeat the process. Each iteration produces a new pre-release version, such as lambdas@1.2.3-my-bug-fix.1
. Deploy the updated version to the development environment for further testing:
npx @prosopo/deploy --package lambdas@1.2.3-my-bug-fix.1 --environment dev-env-1
Once the fix is verified, you’re ready to proceed.
Submit a pull request from fix/my-bug-fix
to main
. The following pipelines will run to ensure quality:
changesets
: Verifies the presence of a valid changeset file.test
: Ensures all tests pass.lint
: Checks for code quality issues.If any pipeline fails, address the issues and repeat the QA process to confirm the fix.
Once the pull request is merged, the release pipeline detects the main
branch and performs a full version bump. The changeset file is processed, and the lambdas
package is updated to 1.2.4
. This version is published to both the GitHub NPM registry and NPMJS registry.
To deploy the new version to production, run:
npx @prosopo/deploy --package lambdas@1.2.4 --environment production
Alternatively, you can manually trigger the deploy pipeline with the same arguments. This deploys lambdas@1.2.4
to the production environment.
After the release, clean up your branches:
fix/my-bug-fix
branch after merging.build/my-bug-fix
branch after the release.To simplify this, we’ve configured GitHub to automatically delete branches after merging. Additionally, we have a manually triggered pipeline to purge build/*
branches when needed.
By following this process, you can efficiently fix bugs, test changes, and deploy updates without blocking other developers. The use of changesets ensures proper versioning and changelog updates, while the pre-release workflow allows for thorough QA before production deployment.
You might wonder why we don’t simply work directly on the build/my-bug-fix
branch instead of creating a separate fix/my-bug-fix
branch. The reason is that every change pushed to a build/*
branch automatically triggers the CI/CD pipeline, initiating a build and release process. Frequent pushes during development would unnecessarily consume CI/CD resources and clutter the registry with multiple development builds. By working on the fix/my-bug-fix
branch, we can iterate freely without triggering builds, pushing to the build/my-bug-fix
branch only when we’re ready to create a package build for QA or release.
Suppose you've recently migrated to Google Cloud, replacing the AWS-specific deployment logic in the deploy
package. The AWS logic was part of deploy@1.0.0
, while deploy@2.0.0
introduced the Google Cloud deployment logic. You deploy lambdas@1.2.4
to production using deploy@2.0.0
, but later discover that the Google Cloud is having issues.
To address this, you decide to temporarily revert to the AWS deployment logic while troubleshooting the Google Cloud setup. Thanks to the decoupled deployment logic, this is straightforward. Simply install the older version of the deploy
package (deploy@1.0.0
) and use it to deploy the lambdas@1.2.4
package to production:
npx @prosopo/deploy@1.0.0 --package lambdas@1.2.4 --environment production
And just like that, the lambdas@1.2.4
package is successfully deployed using the AWS deployment logic. This seamless rollback ensures production remains unaffected while you work on resolving the Google Cloud issues. By decoupling deployment logic from the package, you maintain flexibility and avoid unnecessary downtime during transitions.
Here's an example of how developers can work on the same package in parallel using our updated release and deployment pipeline.
Imagine Developer A is fixing a bug in the lambdas
package, while Developer B is adding a new feature to the same package. Both developers start by branching off main
into fix/my-bug-fix
and feature/my-new-feature
, respectively.
Each developer creates a changeset file to document their changes—Developer A for the bug fix and Developer B for the new feature.
For simplicity, let’s assume both changes have passed QA and pre-release testing and are ready to be merged.
When Developer A merges their branch into main
, the release CI/CD pipeline runs, bumps the version of the lambdas
package to 1.2.4
, and publishes it to both the GitHub NPM registry and NPMJS registry as lambdas@1.2.4
.
Later, when Developer B merges their branch into main
, the release CI/CD pipeline runs again, bumps the version of the lambdas
package to 1.3.0
, and publishes it as lambdas@1.3.0
.
This workflow allows both developers to work independently without blocking each other. Changesets ensure that their updates are versioned correctly and consistently, while the release pipeline guarantees timely releases to production. Each change is isolated into its own version, with its own release and corresponding changelog entry.
To maintain the integrity of the versioning process, the release CI/CD pipeline must not run concurrently. Concurrent runs can create race conditions, leading to mismatched version numbers or incomplete releases.
To prevent this, GitHub Actions allows you to define a concurrency
key in the workflow file. This ensures that only one release pipeline runs at a time, queuing subsequent runs until the current one finishes.
concurrency:
group: ${{ github.ref }}
cancel-in-progress: false # Enqueue new runs if one is already in progress
This configuration guarantees that the release pipeline processes changes sequentially, preserving the accuracy of versioning and ensuring a smooth development workflow.
Implementing versioning and release pipelines can be challenging. Here are some common pitfalls and how to avoid them:
Forgetting to Create Changesets
Mismanaging Pre-Releases
alpha
, beta
, etc.) for testing and ensure they are published to private registries. Clean up pre-release versions after the final release to keep the registry tidy.Skipping QA
main
. Automate this process with CICD pipelines to ensure consistency.Concurrent Release Pipeline Runs
concurrency
key in GitHub Actions to ensure only one release pipeline runs at a time.Tightly Coupled Deployment Logic
deploy
package, allowing flexibility in deployment strategies.Even with a robust pipeline, bad releases can happen. Here's how to handle rollbacks effectively:
Identify the Problem
Redeploy a Previous Version
deploy
package to redeploy a stable version. For example:npx @prosopo/deploy --package lambdas@1.2.3 --environment production
Fix and Test
Document the Incident
Automate Rollbacks
By implementing a streamlined release and deployment pipeline using Changesets, we’ve significantly improved our development workflow. This approach allows us to manage versioning, releases, and deployments efficiently, reducing friction and increasing developer velocity. We’ve also decoupled deployment logic from package code, enabling us to adapt to changes in infrastructure or service providers without impacting our core business logic. This flexibility is crucial for maintaining a scalable and maintainable codebase. As we continue to grow, we’ll keep refining our processes and tools to ensure we can deliver high-quality software at scale. We hope this post provides valuable insights into building a robust CICD pipeline that meets the needs of a growing software team.
Changesets: A tool for managing versioning in monorepos. It creates markdown files describing changes to packages (e.g., patch, minor, or major updates) and automates version bumps and changelogs.
Pre-Release Versions: Versions tagged with a suffix (e.g., 1.2.3-alpha.0
) used for testing changes before releasing them to production. These versions are typically published to private registries.
Independent Versioning: A versioning strategy where each package in a monorepo is versioned independently. Only packages with changes are bumped, reducing unnecessary updates.
Locked Versioning: A versioning strategy where all packages in a monorepo share the same version number. Any change to one package results in a version bump for all packages, which can lead to inefficiencies in large projects.
CICD: Continuous Integration and Continuous Deployment. A set of practices and tools that automate the process of integrating code changes, running tests, and deploying applications to production.
CICD Pipeline: A series of automated steps that run in a specific order to build, test, and deploy code changes. Each step is defined in a configuration file (e.g., YAML) and can include tasks like running tests, building packages, and deploying to environments.
GitHub Actions: A CI/CD tool integrated into GitHub that allows developers to automate workflows, such as building, testing, and deploying code.
Monorepo: A single repository that contains multiple packages or projects. This approach simplifies dependency management and versioning across related projects.
Multirepo: A software development strategy where each package or project is stored in its own repository. This can lead to versioning challenges and increased complexity in managing dependencies.