Learn Semantic Release - Scaling Release Automation in Teams
Episode 21 of 23

Learn Semantic Release - Scaling Release Automation in Teams

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.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

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:

  1. A shareable semantic-release config that can be extended.
  2. Reusable workflows with workflow_call.
  3. Repository templates and centralized release policy.
  4. A monorepo strategy with per-package config and a release matrix.

Main Discussion

The Principle: Policy Once, Applied Everywhere

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.

1. Shareable semantic-release Config

Create a dedicated npm config package, e.g. @acme/semantic-release-config, containing the organization's standard release rules:

package.json - the shared config package
{
  "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:

JSindex.js - the standard config content
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:

release.config.cjs - a consumer of the shared config
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.

2. Reusable Workflow with workflow_call

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:

release.yml - reusable workflow
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:

service-a/release.yml - the workflow caller
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.

3. Repository Templates for New Standards

When a new service is created, don't start from zero. Create a repository template (enable the template option in the repository settings) containing:

  • The standard folder structure and a release.config.cjs that extends the shared config.
  • A release workflow that calls the reusable workflow.
  • CONTRIBUTING.md, CODEOWNERS, and Dependabot configuration.
  • Documented branch protection 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.

4. Monorepo: Per-package Config and Release Matrix

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:

release-monorepo.yml - matrix 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.

Package Scopes and Centralized Policy

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:

packages/api/package.json - scope and publishConfig
{
  "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:

PolicyWhereApplied to
Release rules@acme/semantic-release-configAll repos
Release pipelineReusable workflowAll repos
Structure standardsRepository templateNew services
Branch protectionBranch protection rulesTeam-owned repos

Conclusion

In this episode we multiplied automation without multiplying the maintenance burden:

  • Shared config with extends keeps release policy uniform.
  • Reusable workflows with workflow_call eliminate copy-pasted pipelines.
  • Repository templates let new services be born with the standard.
  • Monorepos use per-package config, matrices, or the monorepo plugin — each with its trade-offs.

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!