Skip to content

Introduce semantic-release with YYYY.MM.PATCH calendar versioning #52

@iamfj

Description

@iamfj

Problem

Releases are currently a manual process: bump package.json, create a git tag, push the tag (which triggers publish.yml for npm), and manually draft a GitHub release. This is handled partly through mise but remains a manual, error-prone workflow.

Additionally, the project already follows conventional commits (feat(), fix(), refactor(), etc.) but does not leverage them for automated versioning or changelog generation.

Current state

Aspect Status
Version scheme YYYY.MM.PATCH (e.g., 2025.12.3)
Tag format Inconsistent — v2025.12.3 vs 2025.11.2
Release trigger Manual tag push → publish.yml
Changelog Manual, hand-written release notes
Pre-releases Not automated, next branch exists but unused for releases
Commit convention Conventional commits already adopted

Proposal

Introduce semantic-release to fully automate the release pipeline — version calculation, changelog generation, GitHub releases, and npm publishing — while preserving the existing YYYY.MM.PATCH calendar versioning scheme.

Version scheme: YYYY.MM.PATCH

This is not standard semver. The scheme works as follows:

  • YYYY — current year (e.g., 2026)
  • MM — current month, no leading zero for single digits (e.g., 2 for February)
  • PATCH — incremental counter within that year+month, starting at 1, resets each month

Examples:

2025.11.1 → 2025.11.2 → 2025.11.3   (same month, patch increments)
2025.11.3 → 2025.12.1                 (new month, patch resets to 1)
2025.12.3 → 2026.1.1                  (new year+month, patch resets to 1)

Pre-releases from the next branch should follow:

2026.2.1-next.1 → 2026.2.1-next.2    (incremental pre-release)

Why not just use semver?

The project already has 5 published releases under the YYYY.MM.PATCH scheme on npm. Switching to semver would break the version continuity and confuse existing users. The calendar scheme also communicates release freshness at a glance, which is a deliberate choice.

Technical approach

Custom version plugin

Since semantic-release expects semver internally, we need a custom plugin (or a calver adapter) to override version calculation. The logic:

function getNextVersion(lastRelease, commits):
  now = currentDate()
  currentYM = now.year + "." + now.month

  if lastRelease is in same YYYY.MM:
    return currentYM + "." + (lastRelease.patch + 1)
  else:
    return currentYM + ".1"

Key detail: commit type does not affect version bumping — any releasable commit (feat, fix, perf, etc.) increments the patch. There is no "major" or "minor" bump concept in this scheme. Commit types are only used for changelog categorization.

Options to implement this:

  1. semantic-release-calver — community plugin for calendar versioning. Needs evaluation for compatibility with YYYY.MM.PATCH (most calver plugins target YYYY.MM.DD or YYYY.0M.PATCH).
  2. Custom inline plugin — a small .releaserc.cjs plugin (~30 lines) that implements the analyzeCommits step and overrides version via semantic-release/exec. Most flexible and keeps control in-repo.
  3. Fork/wrap @semantic-release/commit-analyzer — intercept after analysis and remap to calver. More complex than needed.

Recommendation: Option 2 (custom inline plugin) — minimal dependency, full control, easy to understand and maintain.

Branch configuration

{
  "branches": [
    { "name": "main" },
    { "name": "next", "prerelease": "next" }
  ]
}
  • main → stable releases (2026.2.1)
  • next → pre-releases (2026.2.1-next.1)

Plugin pipeline

{
  "plugins": [
    "@semantic-release/commit-analyzer",
    "@semantic-release/release-notes-generator",
    "@semantic-release/npm",
    "@semantic-release/github"
  ]
}
  1. commit-analyzer — determines if a release is needed (any feat, fix, perf, revert commit)
  2. release-notes-generator — generates structured changelog from conventional commits
  3. npm — publishes to npm with provenance (replaces current publish.yml logic)
  4. github — creates GitHub release with generated notes

Tag format

Standardize on v prefix going forward:

{
  "tagFormat": "v${version}"
}

This aligns with the most recent tags (v2025.12.3, v2025.12.2) and the existing publish.yml trigger pattern.

CI workflow

Replace the current manual publish.yml with a semantic-release job in ci.yml (or a new release.yml):

  release:
    name: Release
    runs-on: ubuntu-latest
    needs: [test, lint]
    if: >-
      github.event_name == 'push' &&
      (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next')

    permissions:
      contents: write      # Create tags + GitHub releases
      issues: write        # Comment on released issues
      pull-requests: write # Comment on released PRs
      id-token: write      # npm provenance

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Release
        run: npx semantic-release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Migration checklist

  • Install semantic-release and plugins as dev dependencies
  • Create .releaserc.cjs (or equivalent) with calver plugin + branch config
  • Implement the custom YYYY.MM.PATCH version calculation
  • Add release job to CI workflow (gated on test + lint passing)
  • Configure repository secrets (NPM_TOKENGITHUB_TOKEN is automatic)
  • Normalize existing tags to v prefix (or configure semantic-release to recognize both)
  • Test dry-run on next branch to validate pre-release flow
  • Remove or deprecate the manual publish.yml workflow
  • Remove release-related mise tasks (if any beyond tool versions)
  • Update AGENTS.md / contributor docs with new release process
  • Document that version bumps in package.json are now automated (don't manually edit)

Risks and considerations

  • Existing tag inconsistency: Tags 2025.11.1 (no v) and v2025.12.3 (with v) coexist. semantic-release needs to find the latest tag regardless of prefix. May need a one-time tag normalization or configure tagFormat to handle both.
  • npm provenance: Current publish.yml uses OIDC (id-token: write). The new workflow should preserve this.
  • package.json version: semantic-release will update this automatically. Contributors should not manually bump it.
  • Protected branches: If main is protected, semantic-release needs a PAT or GitHub App token to push tags (the default GITHUB_TOKEN may be insufficient depending on branch protection rules).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions