Skip to content

Release Process

martyy-code edited this page Aug 5, 2026 · 7 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.

1.1 Every merge to main is a release

There is no "merge now, release later" mode. Once a pull request lands on main, its content is published to npm. A maintainer who merges a PR is, by that act, shipping a new version of @deessejs/fp to users under the latest dist-tag.

This is not a separate rule — it is the consequence of two rules that already hold:

  1. Every pull request adds a Changeset file (see the Authoring Changesets guide). A dedicated CI job refuses to merge any PR that does not.
  2. The release workflow runs on every push to main. Once the Version Packages PR merges, publish.yml runs end-to-end and publishes to npm.

Together: every merge to main carries at least one Changeset, and the workflow publishes it. The release is therefore not a decision a maintainer makes; it is a mechanical effect of a successful merge.

Two practical consequences follow:

  • There is no "batch PR" that lands several changes without producing a release. Each merge produces one version, each Changeset in it produces one changelog entry. To ship multiple changes in a single release, put them in the same PR.
  • A PR that is purely documentation-only still produces a release. The per-PR Changeset rule has no exemption for non-code changes, so a PR that only touches docs/ or apps/web/ carries a patch Changeset (often empty via pnpm changeset --empty). The merge still produces a version bump on npm. This is the price of the rule and it is intentional — the audit trail is the point.

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

The full guide for writing a Changeset — frontmatter shape, semver decision tree, good vs bad summaries, multi-package changesets, --empty, anti-patterns, and worked examples — is in the dedicated Authoring Changesets page. This section is a pointer, not a duplicate.

For the release pipeline, the only thing that matters is that every PR carries a Changeset file under .changeset/ with a valid YAML frontmatter and a semver level that matches the impact. The semver level drives the version bump on release; the body drives the changelog entry.

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

5.1 Concurrency

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.

5.2 Permissions and the release environment

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

The hotfix flow is a separate, faster path that ships an urgent production fix without dragging unfinished work from staging. It is the one place where the rule that every merge to main is a release produces a release without a corresponding changelog entry on the merge commit itself.

The full process — branch cut, PR target, ship mechanics, back-merge, and the follow-up PR on staging — is documented in the dedicated Hotfix Flow page. This section is a pointer, not a duplicate.

The only thing the regular release pipeline needs to know about hotfixes is the one-line summary in § 1.1: a hotfix is a main merge that ships without an in-line Changeset, and the changelog entry is added afterwards via a follow-up PR.

8. End-to-end author flow

8.1 Before merge

  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 — and a planned fifth, changeset-check (tracked in #389). None of them publish. Review and approval happen here.

8.2 At merge

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

8.3 After merge

  1. The release workflow runs end-to-end — see §4.
  2. 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.