semantic-release for Dual-Format Packages
Automate versioning, changelog, and npm publish for dual ESM/CJS packages with semantic-release, including provenance and build-before-publish ordering.
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
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/npmstill publishes an olderdist/.Root cause:
@semantic-release/exec’sprepareCmdwas placed after@semantic-release/npmin the plugins array;semantic-releaseexecutes each plugin’spreparehook in array order, not by importance.Fix: Always place the build-executing plugin entry before
@semantic-release/npmin thepluginsarray, 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: 0is required on the checkout step —semantic-releasereads 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-releaseconfiguration and independent tag prefixes (@acme/[email protected]rather than a barev2.2.0); the default single-package assumption otherwise causes version collisions across packages. @semantic-release/changelog(a separate plugin fromrelease-notes-generator) writesCHANGELOG.mdto disk and must run before@semantic-release/gitcommits 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_PROVENANCEis 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.
Related
- Automating npm Releases with GitHub Actions — the manual tag-based workflow this guide’s automated version replaces.
- Configuring OIDC Trusted Publishing for npm — remove the
NPM_TOKENsecret from the workflow shown here entirely once a trusted publisher is registered. - Automating Changelogs with Changesets — an alternative to semantic-release’s commit-parsing model, using explicit changeset files instead of commit conventions.