A standard that works in one repo can become a nightmare when a team grows to dozens of services. In this episode we package semantic-release into a shared config and reusable workflow, then apply it in a monorepo with per-package config and a release matrix. Policy is defined once, applied everywhere.

In episode 20 we built standards and onboarding. Those standards are easy to maintain in one repository. But once the team grows to five, ten, or thirty repositories — each service with its own pipeline — consistency starts to crack: one repo still runs npx semantic-release --no-ci, another forgets to set NPM_TOKEN. In this episode we package automation into reusable pieces, so release policy is defined once and applied everywhere.
In this episode we cover:
workflow_call.There are two layers to share: configuration (release rules: branches, plugins, tag format) and pipeline (how the release runs). Both must be defined once and consumed everywhere, so a fix in one place automatically spreads to all.
Create a dedicated npm config package, e.g. @acme/semantic-release-config, containing the organization's standard release rules:
{
"name": "@acme/semantic-release-config",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"semantic-release": "semantic-release"
},
"peerDependencies": {
"semantic-release": ">=24"
}
}Fill index.js with the complete config:
module.exports = {
branches: [
{ name: 'main' },
{ name: 'staging', prerelease: 'rc' }
],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/npm',
'@semantic-release/github'
]
}Then each repository just places a thin config that extends that package:
module.exports = {
extends: '@acme/semantic-release-config'
}If the organization decides to add a plugin or change the prerelease, just update one package — all repos change with it. That's how you keep policy uniform without copying files.
Tip
Releasing the shared config package uses semantic-release itself! It's eating your own dog food — its versioning and changelog are managed with exactly the same flow you're teaching.
The second layer is the pipeline. Store the complete release workflow in one infrastructure repository, then expose it via workflow_call so any repo can call it:
name: Reusable Release
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
NPM_TOKEN:
required: true
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- name: Automatic release
run: npx semantic-release
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Each service just calls it:
name: Release Service A
on:
push:
branches: [main, staging]
jobs:
release:
uses: acme/release-pipelines/.github/workflows/release.yml@main
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}A pipeline change, such as adding a dependency audit step, happens once in the infrastructure repo. All services get the upgrade immediately without touching each repo one by one.
Warning
A workflow called with a tag reference can be pinned for stability, but then fixes won't spread automatically. Balance the two: use tags for sensitive workflows and the main branch for fast iteration. And never call a reusable workflow from a repository you don't trust.
When a new service is created, don't start from zero. Create a repository template (enable the template option in the repository settings) containing:
release.config.cjs that extends the shared config.CONTRIBUTING.md, CODEOWNERS, and Dependabot configuration.Every new service is born from the same standard. Deviation only happens when there's a reason — and that reason has to go through a PR, not the old way of doing things.
A monorepo stores many packages in one repository. The problem: git tags are global in one repo, while versions want to be computed per package. Two common approaches:
Approach A: Per-package config with a matrix. Each package has its own .releaserc.cjs, and one workflow runs the release per package:
jobs:
release:
strategy:
fail-fast: false
matrix:
package: [api, web, worker]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: npm ci
- name: Release package
run: npx semantic-release --config ./packages/${{ matrix.package }}/.releaserc.cjs
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Approach B: The monorepo plugin. Use @semantic-release/monorepo so commit analysis is done per package directory. A commit touching packages/api only triggers an api release, without disturbing other packages.
Warning
In a monorepo, all packages share one set of git tags. Releasing packages A and B at the same time can overwrite each other's tags. The solution: separate releases per package, use the monorepo plugin, or consider splitting into separate repositories if each service's tags must be independent.
For npm packages, use an organization scope — @acme/api — so ownership is clear. Scoped packages are private by default, so give a publishConfig for public access when the package really is public:
{
"name": "@acme/api",
"version": "1.4.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.pkg.github.com"
}
}Finally, centralize policy in one place so it's easy to audit:
| Policy | Where | Applied to |
|---|---|---|
| Release rules | @acme/semantic-release-config | All repos |
| Release pipeline | Reusable workflow | All repos |
| Structure standards | Repository template | New services |
| Branch protection | Branch protection rules | Team-owned repos |
In this episode we multiplied automation without multiplying the maintenance burden:
extends keeps release policy uniform.workflow_call eliminate copy-pasted pipelines.This consistent system still needs to be tested in the harshest environment: production. In episode 22, the final episode, we'll do a comprehensive hardening and summarize the entire journey. See you there!