Wiring semantic-release into a dual-format package’s pipeline looks trivial until the first automated release ships without a dist/ directory at all. Consumers who install the new version see:

npm error path /home/user/project/node_modules/@acme/sdk/dist/index.mjs
npm error code ENOENT
npm error syscall open
npm error enoent ENOENT: no such file or directory, open '.../node_modules/@acme/sdk/dist/index.mjs'

semantic-release computes the version, writes the changelog, tags the commit, and calls npm publish — but none of its default plugins run your build tool. If the build step is missing from the pipeline, the tool cheerfully publishes whatever dist/ happens to exist in the checkout, which after a fresh npm ci is nothing. This guide wires the automated release workflow so the build always runs before @semantic-release/npm executes.


Root Cause

semantic-release’s plugin pipeline runs through fixed lifecycle phases — verifyConditions, analyzeCommits, generateNotes, prepare, publish — and each plugin only performs the specific action it is designed for. @semantic-release/npm’s prepare step updates package.json’s version field; its publish step calls npm publish. Neither step compiles source code. Without an explicit build command wired into the prepare phase (via @semantic-release/exec or a custom plugin), the publish phase runs against whatever the CI checkout already contains, which is source only unless a prior workflow step built it.


Minimal Reproduction

This .releaserc.json reproduces the failure — it has no build step anywhere in the pipeline:

{
  "branches": ["main"],
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/npm",
    "@semantic-release/github"
  ]
}
# .github/workflows/release.yml — missing the build step
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx semantic-release   # dist/ was never built

Build-Before-Publish Ordering

semantic-release Phases With Build Inserted A five-step vertical flow showing commit analysis determining the next version, changelog generation, an explicit build step compiling dual ESM and CJS output, the npm publish phase running against the freshly built artifacts, and the GitHub release being created last. Analyze commits → version Generate changelog notes Build dual ESM + CJS (exec) npm publish --provenance GitHub release created

Step-by-Step Fix

Step 1 — Add @semantic-release/exec to run the build in the prepare phase

npm install --save-dev @semantic-release/exec
 {
   "branches": ["main"],
   "plugins": [
     "@semantic-release/commit-analyzer",
     "@semantic-release/release-notes-generator",
+    [
+      "@semantic-release/exec",
+      {
+        "prepareCmd": "npm run build"
+      }
+    ],
     "@semantic-release/npm",
     "@semantic-release/github"
   ]
 }

Expected result: during a release run, semantic-release executes npm run build in its prepare phase, immediately before @semantic-release/npm’s own prepare step bumps the version — the plugin order in the array determines execution order.

HAZARD PREVENTION

Symptom: The build runs, but @semantic-release/npm still publishes an older dist/.

Root cause: @semantic-release/exec’s prepareCmd was placed after @semantic-release/npm in the plugins array; semantic-release executes each plugin’s prepare hook in array order, not by importance.

Fix: Always place the build-executing plugin entry before @semantic-release/npm in the plugins array, as shown above.

Step 2 — Ensure the build output matches the exports map before publish

Verify the build script actually emits what package.json’s exports field declares, by adding a validation command to the same prepareCmd chain:

     [
       "@semantic-release/exec",
       {
-        "prepareCmd": "npm run build"
+        "prepareCmd": "npm run build && npx publint --strict && npx attw --pack ."
       }
     ],

Expected result: a broken exports map or a types resolution mismatch fails the release before any git tag is created or version bump is committed — semantic-release aborts the entire run if any plugin’s phase throws.

Step 3 — Pass provenance flags through @semantic-release/npm

{
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    [
      "@semantic-release/exec",
      { "prepareCmd": "npm run build && npx publint --strict && npx attw --pack ." }
    ],
    [
      "@semantic-release/npm",
      { "npmPublish": true }
    ],
    "@semantic-release/github"
  ]
}
# workflow — id-token: write makes --provenance available to the underlying npm publish call
permissions:
  contents: write
  id-token: write

steps:
  - run: npx semantic-release
    env:
      NPM_CONFIG_PROVENANCE: "true"
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Expected result: @semantic-release/npm shells out to the same npm publish binary configured on the runner; setting NPM_CONFIG_PROVENANCE=true (npm’s environment-variable equivalent of --provenance) causes it to attach a Sigstore attestation exactly as a manual invocation would, as long as id-token: write is granted at the job level.

Step 4 — Commit the full workflow

name: Release
on:
  push:
    branches: [main]

permissions:
  contents: write
  id-token: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # semantic-release needs full commit history
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npx semantic-release
        env:
          NPM_CONFIG_PROVENANCE: "true"
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Expected result: merges to main with conventional commits trigger a fully automated version bump, changelog, dual-format build, validation, provenance-signed publish, and GitHub release — with no manual tagging step at all.


Verification

# Preview the computed version and plugin execution order without publishing
npx semantic-release --dry-run

# After a real release, confirm the tarball contains built output
npm view @acme/sdk@latest dist.tarball | xargs curl -sL | tar -tz | grep dist/

Expected --dry-run output:

[semantic-release] › ℹ  Running prepare step...
[semantic-release] › ℹ  Running @semantic-release/exec prepareCmd
[semantic-release] › ✔  Build completed, publint passed, attw passed
[semantic-release] › ℹ  The next release version is 2.2.0
[semantic-release] › ℹ  Publishing version 2.2.0 to npm registry (dry run, skipped)

Edge Cases / Gotchas

  • fetch-depth: 0 is required on the checkout step — semantic-release reads the full commit history to compute the version, and a shallow clone silently produces an incorrect or missing version calculation.
  • Monorepos need per-package semantic-release configuration and independent tag prefixes (@acme/[email protected] rather than a bare v2.2.0); the default single-package assumption otherwise causes version collisions across packages.
  • @semantic-release/changelog (a separate plugin from release-notes-generator) writes CHANGELOG.md to disk and must run before @semantic-release/git commits it back — order these two before the exec/build step if you want the build to see the updated changelog file, though this is rarely required.
  • Non-conventional commit messages are silently ignored when computing the version; a release with only chore: or unscoped commits produces no release at all, which is correct behavior, not a bug.
  • NPM_CONFIG_PROVENANCE is respected by npm CLI 9.5+; older CLI versions ignore the environment variable without error, producing an unsigned publish that looks successful but carries no attestation.

Frequently Asked Questions

Does semantic-release run my build script automatically?

No. semantic-release orchestrates versioning, changelog generation, git tagging, and publishing through plugins, but none of the standard plugins invoke your build tool. You must add an explicit prepare step, typically via @semantic-release/exec, that runs npm run build before the @semantic-release/npm publish step executes.

Can semantic-release pass --provenance to npm publish?

Yes, by setting the NPM_CONFIG_PROVENANCE environment variable or configuring publishArgs for @semantic-release/npm in your release configuration, or by ensuring the underlying npm publish call in your CI environment inherits the id-token: write permission — the plugin shells out to the same npm CLI that supports --provenance natively.

How does semantic-release decide the version number?

It parses conventional commit messages since the last release: a fix: commit triggers a patch bump, feat: triggers a minor bump, and any commit with a BREAKING CHANGE footer or an exclamation mark after the type (feat!:) triggers a major bump. Commits that do not follow the convention are ignored for version calculation.

Can I dry-run semantic-release to preview the version bump without publishing?

Yes. Run npx semantic-release --dry-run to see the computed next version, generated changelog entry, and which plugins would execute, without creating a git tag, pushing a commit, or calling npm publish.

Does semantic-release work with monorepos publishing multiple dual-format packages?

Not natively from a single semantic-release configuration, since it assumes one package per repository by default. Use semantic-release in per-package mode with distinct configuration files and independent tag prefixes, or adopt a monorepo-aware wrapper that runs semantic-release once per changed package.



↑ Back to Automating npm Releases with GitHub Actions