Skip to content
Unverified — AI-generated content. Help verify this page

Release Engineering

Release engineering is the discipline of getting software from a developer's machine to production reliably, reproducibly, and safely. It sounds simple until you need to coordinate changelogs across a monorepo, sign artifacts for supply chain security, publish packages to five registries simultaneously, and roll back a bad release at 2 AM.

This page covers the full release lifecycle: how to version your software, automate changelogs, choose a release strategy, publish to package registries, and coordinate releases in monorepos.

Related: Deployment Strategies | Monitoring | Incident Response


Semantic Versioning Deep Dive

Semantic versioning (SemVer) is the dominant versioning scheme for libraries and frameworks. The format is MAJOR.MINOR.PATCH, but the rules behind it are more nuanced than most developers realize.

The Rules

Given version MAJOR.MINOR.PATCH:

MAJOR — Incompatible API changes (breaking changes)
MINOR — New functionality, backwards-compatible
PATCH — Bug fixes, backwards-compatible

Pre-release and Build Metadata

1.0.0-alpha.1        Pre-release: alpha
1.0.0-beta.3         Pre-release: beta
1.0.0-rc.1           Pre-release: release candidate
1.0.0+build.456      Build metadata (ignored in precedence)
1.0.0-beta.1+sha.abc Both pre-release and build metadata

Version Ranges in Package Managers

SyntaxMeaningExample
^1.2.3Compatible with 1.x.x (>=1.2.3, <2.0.0)npm default
~1.2.3Approximately 1.2.x (>=1.2.3, <1.3.0)Patch-level only
>=1.2.3At least 1.2.3Minimum version
1.2.xAny patch version of 1.2Wildcard
*Any versionDangerous
1.2.3 - 2.0.0Range inclusiveExplicit range

WARNING

The caret (^) is npm's default and allows MINOR updates. This means ^1.2.3 will install 1.9.9 if available. For libraries that do not follow SemVer strictly, this can introduce breaking changes. Use lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml) to pin exact installed versions.

When SemVer Gets Hard

ScenarioWhat Version to Bump?
Dropping Node.js 16 supportMAJOR — consumers on Node 16 break
Fixing a bug that people depend onMAJOR (technically), but often PATCH with a note
Adding a new optional parameter to a functionMINOR
Changing the type of an exported TypeScript typeMAJOR — type consumers break
Improving performance with no API changePATCH
Removing a deprecated APIMAJOR
Adding a peer dependencyMAJOR — consumers must install it

CalVer: The Alternative

Some projects use calendar-based versioning instead:

ProjectFormatExample
UbuntuYY.MM24.04
pipYY.N24.0
TerraformMAJOR.MINOR.PATCH but releases monthly1.7.0
Black (Python)YY.MM.MICRO24.3.0

CalVer works well for projects where "backwards compatibility" is not the primary concern — applications, distributions, and tools with rolling updates.

Conventional Commits

Conventional Commits is a specification for writing structured commit messages that can be parsed by automation tools to generate changelogs and determine version bumps.

The Format

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Commit Types

TypeSemVer ImpactDescription
featMINORNew feature
fixPATCHBug fix
docsNoneDocumentation only
styleNoneFormatting, whitespace
refactorNoneCode change that neither fixes nor adds
perfPATCHPerformance improvement
testNoneAdding or fixing tests
buildNoneBuild system or dependencies
ciNoneCI configuration
choreNoneOther maintenance

Breaking Changes

feat(auth)!: replace session tokens with JWT

BREAKING CHANGE: The /api/auth/session endpoint now returns a JWT
instead of a session cookie. All clients must update their
authentication handling.

Closes #1234

The ! after the type/scope and the BREAKING CHANGE: footer both signal a MAJOR version bump.

Enforcing Conventional Commits

json
// .commitlintrc.json
{
  "extends": ["@commitlint/config-conventional"],
  "rules": {
    "type-enum": [2, "always", [
      "feat", "fix", "docs", "style", "refactor",
      "perf", "test", "build", "ci", "chore"
    ]],
    "subject-max-length": [2, "always", 72],
    "body-max-line-length": [2, "always", 100]
  }
}
json
// package.json — husky + commitlint
{
  "scripts": {
    "prepare": "husky"
  }
}
bash
# .husky/commit-msg
npx --no -- commitlint --edit "$1"

Changelog Automation

With conventional commits, changelogs write themselves.

Tools

ToolApproachBest For
conventional-changelogParses git log, generates CHANGELOG.mdSimple single-package repos
release-pleaseGitHub Action, creates release PRsGoogle-style release workflow
semantic-releaseFully automated, publishes to registryHands-off CI/CD publishing
changesetsDeveloper-authored change descriptionsMonorepos with human-readable changelogs
cliff (git-cliff)Highly configurable, Rust-basedCustom changelog formats

semantic-release Pipeline

semantic-release Configuration

json
// .releaserc.json
{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    "@semantic-release/npm",
    "@semantic-release/github",
    ["@semantic-release/git", {
      "assets": ["CHANGELOG.md", "package.json"],
      "message": "chore(release): ${nextRelease.version} [skip ci]"
    }]
  ]
}

Release Strategies

Comparison

StrategyRelease CadenceRiskCoordination Overhead
Continuous DeliveryEvery merge to mainLow per releaseLow
Release TrainsFixed schedule (weekly/biweekly)Medium (batched changes)Medium
Feature ReleasesWhen feature is completeVariableHigh
LTS + CurrentParallel tracksLow (LTS) / Medium (Current)High

Release Trains

A release train departs on schedule, regardless of what is on it. Features that are not ready wait for the next train.

TIP

Release trains work well for medium-to-large teams. They create a predictable cadence that marketing, support, and documentation teams can plan around. The key rule: the train leaves on time. If a feature is not ready, it does not delay the train — it waits for the next one.

Continuous Delivery

Every merge to the default branch is a potential release. The pipeline decides whether to publish based on commit analysis.

yaml
# GitHub Actions — release on every merge to main
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Package Publishing Pipelines

npm (JavaScript/TypeScript)

yaml
# .github/workflows/publish-npm.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write  # Required for npm provenance
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm test
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

PyPI (Python)

yaml
# .github/workflows/publish-pypi.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write  # Trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install build
      - run: python -m build
      - uses: pypa/gh-action-pypi-publish@release/v1
        # No token needed — uses OIDC trusted publishing

Docker Hub

yaml
# .github/workflows/publish-docker.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            myorg/myapp:${{ github.ref_name }}
            myorg/myapp:latest
          platforms: linux/amd64,linux/arm64

Publishing Checklist

StepWhy
Run full test suiteNever publish untested code
Build from clean checkoutAvoid leaking local state
Verify package contentsnpm pack --dry-run, python -m build
Tag the git commitLink published artifact to exact source
Generate provenanceProve the artifact came from your CI
Test the published packagenpm install yourpkg@latest in a clean env

Artifact Signing and Verification

Supply chain attacks (SolarWinds, ua-parser-js, event-stream) have made artifact signing critical.

Signing Methods

MethodToolEcosystem
npm provenancenpm CLI + Sigstorenpm packages
Sigstore cosigncosignContainer images, binaries
GPG signinggpgGit tags, Debian/RPM packages
Apple notarizationcodesign + notarytoolmacOS binaries
Windows AuthenticodesigntoolWindows binaries

Sigstore: Keyless Signing

bash
# Sign a container image (keyless — uses OIDC identity)
cosign sign myregistry.io/myapp:v1.2.3

# Verify a container image
cosign verify myregistry.io/myapp:v1.2.3 \
  --certificate-identity=ci@myorg.com \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com

DANGER

Never commit signing keys to your repository. Use CI/CD secrets, hardware security modules (HSMs), or keyless signing (Sigstore) instead. A leaked signing key compromises the entire trust chain for all your artifacts.

npm Provenance

npm provenance links a published package to its source commit and CI build:

json
// Provenance attestation (automatically generated)
{
  "predicateType": "https://slsa.dev/provenance/v1",
  "predicate": {
    "buildDefinition": {
      "externalParameters": {
        "workflow": ".github/workflows/publish.yml",
        "ref": "refs/tags/v1.2.3"
      }
    },
    "runDetails": {
      "builder": {
        "id": "https://github.com/actions/runner"
      }
    }
  }
}

Monorepo Release Coordination

Releasing multiple interdependent packages from a single repository is one of the hardest problems in release engineering.

Changesets

Changesets is the most popular tool for monorepo release coordination. Developers create "changeset" files describing their changes, and the tool handles versioning and changelog generation.

Changeset File Format

markdown
---
"@myorg/core": minor
"@myorg/utils": patch
"@myorg/cli": minor
---

Added new `transform` API to core package.
Updated utils to support the new transform types.
CLI now exposes the transform command.

Lerna

Lerna (now maintained by Nx) takes a more automated approach:

json
// lerna.json
{
  "version": "independent",
  "npmClient": "pnpm",
  "command": {
    "version": {
      "conventionalCommits": true,
      "message": "chore(release): publish"
    },
    "publish": {
      "registry": "https://registry.npmjs.org"
    }
  }
}
ModeBehavior
Fixed ("version": "1.0.0")All packages share one version number
Independent ("version": "independent")Each package versioned independently

Monorepo Dependency Graph

When publishing packages that depend on each other, order matters:

Publish in topological order: utils first, then core, then cli and web. If core bumps a major version, all dependents need their dependency range updated, potentially triggering their own version bumps.

Release Automation Best Practices

PracticeWhy
Automate everythingManual releases are error-prone and unrepeatable
Use lockfilesEnsure builds are reproducible
Publish from CI onlyNever npm publish from a developer laptop
Pin CI action versionsactions/checkout@v4 not actions/checkout@main
Test the release artifactInstall your own package in a clean environment
Keep releases smallSmaller releases are easier to debug and roll back
Write migration guides for majorsYour users need to know what changed and how to update
Maintain a security policySECURITY.md with disclosure instructions and supported versions

Further Reading

"What I cannot create, I do not understand." — Richard Feynman