Skip to content

Release Process

martyy-code edited this page Aug 4, 2026 · 5 revisions

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

  1. Mental model
  2. The three trigger paths
  3. Authoring a Changeset
  4. Pipeline steps in order
  5. Concurrency and permissions
  6. Local equivalents
  7. Hotfix flow
  8. End-to-end author flow
  9. Known limitations
  10. Quick reference

1. Mental model

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.Z on the main branch;
  • a GitHub Release page with auto-generated notes.

All five are produced by the same workflow job, in that order.

2. The three trigger paths

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'))

2.1 PR merge into main with a Changeset

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.

2.2 Tag push vX.Y.Z on main

Used for hotfixes. The tag itself signals "a release must happen", and the workflow skips the merge-specific detection.

2.3 Manual workflow_dispatch with dry_run

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.

3. Authoring a Changeset

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.

4. Pipeline steps in order

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.

4.1 Checkout

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.

4.2 Toolchain setup

  • pnpm/action-setup and actions/setup-node with 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.

4.3 Detect changesets

A shell step writes has_changesets to the step output:

  • Tag push → always true.
  • Manual dispatch → always true.
  • PR mergegit diff --name-only HEAD~1 HEAD filtered to ^\.changeset/.*\.md$ (excluding README.md). At least one match means true; 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).

4.4 Bump versions

pnpm changeset version

This 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.

4.5 Push the bump back to main

Only on the PR-merge path. The workflow:

  1. Configures the github-actions[bot] identity.
  2. git add -A and git diff --cached --quiet to detect whether the bump actually produced a change.
  3. If there is something to push, git commit -m "chore(release): version packages".
  4. 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.

4.6 Anti-republish guard

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
fi

A 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.

4.7 Build, test, and smoke-test

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 + ')');
  }
}

4.8 Publish

pnpm changeset publish --tag latest

Credentials 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.

4.9 Tag and GitHub Release

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.

5. Concurrency and permissions

The workflow declares:

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

Two 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.

6. Local equivalents

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.

7. Hotfix flow

For a hotfix where a tag is already in mind:

  1. Land the fix on main with a Changeset.
  2. Merge through the normal PR path; the workflow will tag the release itself.
  3. Alternatively, push a manual vX.Y.Z tag at the tip of main after changeset version has 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.

8. End-to-end author flow

  1. Branch off main.

  2. Make the code change.

  3. Add a Changeset file.
    .changeset/cool-otters-dance.md:

    ---
    "@deessejs/fp": minor
    ---
    
    Add `Result.tryCatch` family
    
  4. Open the PR.
    ci.yml runs four parallel jobs — lint, typecheck, build, test. None of them publish. Review and approval happen here.

  5. Merge the PR into main.
    The merge commit contains the changeset file. The publish.yml workflow fires on pull_request: closed.

  6. The release workflow runs end-to-end — see §4.

  7. main ends up with one extra commit, chore(release): version packages, and npm has a new version of @deessejs/fp under the latest dist-tag.

9. Known limitations

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/main before pushing, to avoid non-fast-forward errors when multiple PRs land in quick succession.
  • #385 — replace the git diff heuristic with pnpm changeset status, the supported Changesets API.
  • #386 — add a deprecate workflow for fast incident response, distinct from the release workflow.

10. Quick reference

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.

Clone this wiki locally