-
Notifications
You must be signed in to change notification settings - Fork 0
Release Process
This page documents how a new version of @deessejs/fp is produced and published to npm.
The pipeline lives in a single workflow, .github/workflows/publish.yml,
and is built around Changesets and
npm Trusted Publishing (OIDC). There is no
long-lived NPM_TOKEN secret in the repository.
Outline
- Mental model
- The three trigger paths
- Authoring a Changeset
- Pipeline steps in order
- Concurrency and permissions
- Local equivalents
- Hotfix flow
- End-to-end author flow
- Known limitations
- Quick reference
A "release" in this project is the combination of:
- a new version recorded in
packages/fp/package.json#version; - a corresponding entry in
packages/fp/CHANGELOG.md; - a published artifact on npm under the dist-tag
latest; - a git tag of the form
vX.Y.Zon themainbranch; - a GitHub Release page with auto-generated notes.
All five are produced by the same workflow job, in that order.
The publish.yml workflow fires on three different events but routes them through a single
job called release. The job only runs when its guard condition matches — non-merged PRs are
explicitly excluded:
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request'
&& github.event.pull_request.merged == true
&& github.event.pull_request.base.ref == 'main') ||
(github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v'))
This is the normal path. The PR carries a .changeset/<name>.md file; the merge commit
introduces that file, and the workflow detects it during the changeset-detection step.
Used for hotfixes. The tag itself signals "a release must happen", and the workflow skips the merge-specific detection.
Used for manual publishes. When dry_run is true, the workflow builds, tests, and
smoke-tests the artifact but skips changeset publish, the tag push, and the GitHub
Release. When dry_run is false, it publishes like any other run.
Authors describe their change in a Markdown file under .changeset/. The filename does not
matter as long as it ends in .md; the contents do:
---
"@deessejs/fp": minor
---
Add the `Result.tryCatch` and `Maybe.tryMaybe` families
The semver level is one of:
-
major— breaking change to the public API. -
minor— backwards-compatible feature. -
patch— backwards-compatible bug fix or internal cleanup.
Multiple packages can be listed in the same file. For this monorepo the active packages are
@deessejs/fp (the published library) and @deessejs/errors (a peer dep, currently in the
same repo). The pending release at the time of writing is .changeset/release-1.1.1.md
(a patch for @deessejs/fp).
Changesets are committed as part of the PR that contains the code change. The PR review is the moment to debate the semver level; once the merge lands, the version is mechanical.
When the job runs, it executes the following steps. Each step is gated on
steps.detect.outputs.has_changesets == 'true', except the detection step itself.
actions/checkout with ref: main and fetch-depth: 0 so the next step can run
git diff HEAD~1 HEAD to inspect the merge commit.
-
pnpm/action-setupandactions/setup-nodewith Node 24. -
npm install -g npm@latest— npm Trusted Publishing requires npm 11.5.1+, and the GitHub-hosted image ships an older npm. -
pnpm install --frozen-lockfile— strict lockfile install.
A shell step writes has_changesets to the step output:
-
Tag push → always
true. -
Manual dispatch → always
true. -
PR merge →
git diff --name-only HEAD~1 HEADfiltered to^\.changeset/.*\.md$(excludingREADME.md). At least one match meanstrue; otherwise the job ends here with "no changesets — skipping release".
This is the step tracked by issue #385 (replace the git diff heuristic with
pnpm changeset status, which is the supported API).
pnpm changeset versionThis rewrites packages/fp/package.json#version, updates packages/fp/CHANGELOG.md, and
applies updateInternalDependencies: patch from .changeset/config.json to other
workspace packages if they depend on what changed.
Only on the PR-merge path. The workflow:
- Configures the
github-actions[bot]identity. -
git add -Aandgit diff --cached --quietto detect whether the bump actually produced a change. - If there is something to push,
git commit -m "chore(release): version packages". -
git push origin HEAD:main.
Issue #384 tracks the race that happens when a second PR lands while this push is in flight:
the push is non-fast-forward and the job fails. The intended fix is to rebase on
origin/main before pushing.
PKG=$(node -p "require('./packages/fp/package.json').name")
VER=$(node -p "require('./packages/fp/package.json').version")
if npm view "${PKG}@${VER}" version >/dev/null 2>&1; then
echo "::error::${PKG}@${VER} is already published"
exit 1
fiA version that already exists on the npm registry fails the job with a clear error. This protects against accidental re-runs and against the case where the bump commit and the tag push disagree.
pnpm build # turbo build, dependsOn: ["^build"]
pnpm test # turbo test, dependsOn: ["^build"]The smoke test imports the built artifact via node --input-type=module and asserts
that the five core exports — ok, err, some, none, maybe — exist and have the
right typeof. This catches packaging problems that pure typechecks and unit tests miss:
import * as m from './packages/fp/dist/index.js';
for (const name of ['ok', 'err', 'some', 'none', 'maybe']) {
const t = typeof m[name];
if (t !== 'function' && t !== 'object') {
throw new Error('expected export missing or wrong type: ' + name + ' (got ' + t + ')');
}
}
pnpm changeset publish --tag latestCredentials come from npm Trusted Publishing. The job's id-token: write permission
produces an OIDC token that npm exchanges for a short-lived publish credential. There is
no NPM_TOKEN secret.
The --tag latest flag is explicit: every release goes to latest. Pre-releases would
need an explicit --tag next or similar — this workflow does not produce them.
This step is skipped when the workflow was triggered manually with dry_run: true.
VER=$(node -p "require('./packages/fp/package.json').version")
git tag -a "v${VER}" -m "Release v${VER}"
git push origin "v${VER}"Then softprops/action-gh-release with generate_release_notes: true produces the
GitHub Release page.
The workflow declares:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: falseTwo runs targeting the same ref are serialized; neither is cancelled. This is deliberate —
an in-flight v1.1.1 release should not be aborted by a second PR landing on main.
The job grants only the permissions it needs:
| Permission | Why |
|---|---|
id-token: write |
OIDC token for npm Trusted Publishing |
contents: write |
Push the version bump commit, the tag, and the release object |
pull-requests: read |
Read PR metadata for the merge guard |
It runs in the release GitHub environment, which is the only environment registered as
a Trusted Publisher on the npm side. Compromise of a contributor's PAT cannot publish
@deessejs/fp, because publishing only happens from this specific workflow in this
specific environment.
The root package.json exposes the underlying tooling for local use:
pnpm build # turbo build
pnpm test # turbo test
pnpm changeset # interactive Changesets CLI (add a new changeset)
pnpm version # run `changeset version` locally (bump + changelog)
pnpm release # build + test + `changeset publish` (local, needs npm login)pnpm release is a developer convenience. Production releases still go through GitHub
Actions.
For a hotfix where a tag is already in mind:
- Land the fix on
mainwith a Changeset. - Merge through the normal PR path; the workflow will tag the release itself.
- Alternatively, push a manual
vX.Y.Ztag at the tip ofmainafterchangeset versionhas been run. The tag-push branch of the same workflow fires, skips the merge detection, and runs the same build → test → smoke → publish → tag → release path.
The anti-republish guard means a tag push for a version that already exists on npm fails fast.
-
Branch off
main. -
Make the code change.
-
Add a Changeset file.
.changeset/cool-otters-dance.md:--- "@deessejs/fp": minor --- Add `Result.tryCatch` family -
Open the PR.
ci.ymlruns four parallel jobs —lint,typecheck,build,test. None of them publish. Review and approval happen here. -
Merge the PR into
main.
The merge commit contains the changeset file. Thepublish.ymlworkflow fires onpull_request: closed. -
The release workflow runs end-to-end — see §4.
-
mainends up with one extra commit,chore(release): version packages, and npm has a new version of@deessejs/fpunder thelatestdist-tag.
The currently open issues in deessejs/fp describe the parts of this pipeline that the
maintainers want to improve:
- #383 — split the release job so PR merges don't all need approval at the same gate.
-
#384 — rebase the version bump onto
origin/mainbefore pushing, to avoid non-fast-forward errors when multiple PRs land in quick succession. -
#385 — replace the
git diffheuristic withpnpm changeset status, the supported Changesets API. - #386 — add a deprecate workflow for fast incident response, distinct from the release workflow.
| Goal | How |
|---|---|
| Add a release note | New file under .changeset/ with --- frontmatter listing package + semver level |
| Pick the level |
major for breaking, minor for new feature, patch for fix |
| Trigger a normal release | Merge a PR into main that contains a changeset file |
| Trigger a hotfix release | Push a vX.Y.Z tag at the tip of main
|
| Trigger a dry run |
Actions → Release → Run workflow with dry_run = true
|
| Inspect the published artifact |
npm view @deessejs/fp or the GitHub Release page |
| Bump locally |
pnpm version (writes the new version into packages/fp/package.json) |
| Publish locally |
pnpm release (requires an npm login on the maintainer account) |
This page documents the actual workflow at the time of writing. If you change the pipeline, update this page in the same PR.