# Release Engineering

> Version, changelog, tag, and publish a release. Use when cutting a release, setting up automated publishing to npm/PyPI/crates.io/Maven/Homebrew, deciding whether a change is major/minor/patch, writing release notes, adopting conventional commits or changesets, planning a 1.0, or handling a bad release that needs to be yanked. Covers semver decision rules, changelog quality, release automation with provenance, pre-releases, LTS branches, and deprecation timelines.

- Skill: `the-open-agent/release-engineering` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add the-open-agent/release-engineering`
- Raw SKILL.md: https://api.skillmd.com/api/skills/the-open-agent/release-engineering/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: the-open-agent (https://skillmd.com/u/the-open-agent)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/the-open-agent/release-engineering

---


# Release Engineering

A release is a promise. Semver is the notation for that promise, and users treat it
literally — a breaking change in a patch release breaks builds at 3 a.m. for people who
did nothing wrong.

## Semver, decided correctly

`MAJOR.MINOR.PATCH` — increment MAJOR for incompatible changes, MINOR for
backward-compatible additions, PATCH for backward-compatible fixes.

The judgment calls, resolved:

| Change | Bump | Note |
|---|---|---|
| Add an optional parameter | minor | |
| Add a required parameter | **major** | |
| Add a new export | minor | |
| Remove or rename any export | **major** | Deprecate first (`api-design`) |
| Change a default value | **major** | Behavior changes silently — the worst kind |
| Fix a bug users may depend on | **major** in practice | Hyrum's Law; judge by blast radius |
| Add a new error type | minor | Unless callers must handle it exhaustively |
| Change an error *message* | patch | Unless documented as stable |
| Change an error *type* | **major** | |
| Tighten input validation | **major** | Previously-working input now fails |
| Loosen validation | minor | |
| Raise minimum runtime version | **major** | Node 18→20, Python 3.9→3.10 |
| Add a required peer dependency | **major** | |
| Bump a dependency's major | **major** if its types are in your public API, else minor | |
| Performance improvement | patch/minor | major if a complexity guarantee changes |
| Docs, tests, CI | none | No release needed |
| Change output ordering | **major** if previously stable | Someone parses it |

**0.x is not an exemption from thinking.** Convention: `0.MINOR.PATCH` where minor
carries breaking changes. But once real users exist, communicate breaks regardless of
what the version number technically permits.

**1.0 means "the API is stable and I will honor semver."** Ship it once that is true.
Sitting at 0.x for years while enterprises depend on you is worse than an honest 1.0 —
`0.x` is read by procurement as "not production ready", and by dependency tooling as
"pin exactly".

## Changelogs humans read

`CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com) form. Newest first,
grouped by kind, written for the person deciding whether to upgrade.

```markdown
## [4.2.0] - 2026-03-14

### Added
- `--watch` mode re-runs on file change ([#412](link)) — thanks @contributor!

### Changed
- Default timeout raised 5s → 30s. Set `timeout: 5000` to restore. ([#420](link))

### Fixed
- Crash on config files containing a BOM ([#418](link))

### Deprecated
- `parse()` — use `parseConfig()`. Removed in v5.0.0. ([migration guide](link))
```

Rules:

- **Write for the upgrader**, not the committer. "Refactored the visitor" tells them
  nothing; "20% faster on large files, no API change" does.
- **Every entry links to its PR or issue.**
- **Credit contributors by handle.** This is a large part of what people get paid in.
- **Breaking changes get their own section, at the top, with the migration action
  inline.** Not a link to a link.
- **Say what to do**, not only what changed.
- **Never auto-dump commit subjects.** A changelog of `fix: stuff` and `chore: bump`
  is noise that trains users not to read it.

Auto-generation (release-please, semantic-release, changesets) is fine as a *draft*.
Edit it before publishing.

## Automation

Pick one model and commit to it:

| Approach | How it decides the version | Best for |
|---|---|---|
| **Conventional commits + semantic-release** | Parses commit messages | Single-package repos, disciplined commit history |
| **release-please** | Commits → maintains a release PR | Same, with a human approval gate |
| **changesets** | Contributor writes a `.changeset/` file in their PR | Monorepos; intent is captured by the person who knows it |
| **Manual** | You decide | Small projects, or where judgment matters more than throughput |

Changesets has a real advantage for community projects: the contributor states the
impact of their own change at PR time, when the context is fresh, and the maintainer
reviews that claim as part of review.

If using conventional commits, enforce them on the PR *title* rather than every commit
(squash-merge makes the title the commit message). Rejecting a first-time
contributor's commit message format is a bad first interaction; a maintainer editing
the title at merge is free.

### Release workflow

```yaml
name: Release
on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: write        # create the GitHub Release
  id-token: write        # OIDC — trusted publishing, no long-lived token

jobs:
  release:
    runs-on: ubuntu-latest
    environment: release          # require manual approval + scoped secrets
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', registry-url: 'https://registry.npmjs.org' }
      - run: npm ci
      - run: npm test
      - run: npm publish --provenance --access public
```

Non-negotiables:

- **Publish from CI, never from a laptop.** Laptop publishes ship uncommitted files,
  wrong branches, and stale builds — and they are unattestable.
- **Use trusted publishing / OIDC** where available (npm, PyPI, crates.io). No
  long-lived registry token to leak.
- **Emit provenance attestations** (`--provenance`, PyPI attestations). See
  `supply-chain-security`.
- **Tests must pass in the release job**, not just "they passed on main last week".
- **Use a protected GitHub Environment** so publishing requires an approval click.

## Pre-1.0, pre-releases, and channels

- **`4.0.0-rc.1`** for release candidates; publish to a `next` dist-tag so nobody
  installs it accidentally. Ask specific downstream users to try it.
- **`4.0.0-beta.3`** when the API may still shift.
- **Nightly/canary builds** from `main` for projects with eager early adopters — but
  only if you will actually respond to bugs found there.
- **LTS branches** once enterprises depend on you: maintain `3.x` with security and
  critical fixes for a stated window, publish the support matrix and the end-of-life
  dates in the README. Stating a date you will honor is worth more than a longer
  window you will abandon.

## The release checklist

```
[ ] All milestone issues closed or moved
[ ] CI green on main, on the full matrix
[ ] CHANGELOG updated and human-edited
[ ] Version bumped in every manifest (package.json, Cargo.toml, __init__.py, ...)
[ ] Docs updated; migration guide written if there are breaking changes
[ ] README install commands re-verified against the new version
[ ] Deprecation warnings added for anything being removed next major
[ ] Tag signed and pushed: git tag -s v4.2.0 -m "v4.2.0" && git push origin v4.2.0
[ ] GitHub Release created with notes and binaries/checksums
[ ] Package published with provenance
[ ] Install verified from the registry in a clean container
[ ] Announced where your users actually are
```

That second-to-last line catches more real breakage than any other check:

```bash
docker run --rm -it node:22 sh -c 'npm i -g yourpkg@4.2.0 && yourpkg --version'
docker run --rm -it python:3.13 sh -c 'pip install yourpkg==4.2.0 && python -c "import yourpkg"'
```

Missing files in the published artifact (`files` in `package.json`,
`MANIFEST.in`, `include` in `Cargo.toml`) is the most common release bug and it is
invisible from inside the repo.

## When a release is bad

Speed matters more than dignity.

1. **Assess.** Broken install or data loss → act now. Cosmetic → fix forward.
2. **Deprecate the version on the registry** — do not delete it. `npm deprecate
   pkg@4.2.0 "Broken: use 4.2.1"`, `yank` on crates.io, PyPI "yank". Deleting breaks
   every lockfile that already references it, including for people who were fine.
   (npm unpublish is only permitted within 72 hours and should still be a last resort.)
3. **Publish the fix as a new patch version.** Never re-publish a version number.
4. **Never move a tag.** People have it cached; a moved tag makes verification fail
   and looks like an attack.
5. **Pin the issue**, update the release notes with a warning, and post where you
   announced it.
6. **Write the postmortem into the checklist.** The failure means a check is missing.

## Anti-patterns

- **Breaking changes in a patch release.** The trust-destroying mistake.
- **No changelog**, or a changelog of raw commit subjects.
- **Publishing from a laptop.**
- **Long-lived registry tokens in repo secrets** when OIDC is available.
- **Unsigned, unattested artifacts** for anything widely installed.
- **Version numbers that disagree** across manifest, tag, and changelog.
- **Deleting a published version.** Yank or deprecate instead.
- **Sitting at 0.x with 500k weekly downloads.**
- **A 1.0 that is followed by 2.0 six weeks later.** 1.0 is a commitment; earn it first.
- **Releasing on a Friday afternoon** with nobody around to respond.

