---
name: release-new-version
description: Cut a new release of @ilamy/calendar — analyze commits since the last tag, suggest a semver bump, draft a CHANGELOG entry in the project's existing style, run the CI gate, commit, tag, push to origin, and create the GitHub release page (marked as `latest`). Pauses for the user to run `bun run release` (build + publish) interactively, then (on their signal) updates and deploys the docs site (`apps/website`, with demo/playground updates when needed) and comments on every issue closed by the release, closing any still open. Use whenever the user says "release a new version", "cut a release", "ship v1.x", "bump the version", "prep a release", "publish the next version", "I just published X", "close the released issues", or anything that implies a new version should go out or a shipped version needs its issue follow-ups.
---

# Release a new version of @ilamy/calendar

You're shipping a new version of the `@ilamy/calendar` npm package. The flow is conservative on purpose: suggest, wait for approval, apply, wait for approval, push. The user handles the actual publish manually via `bun run release` (it needs an interactive login).

**Monorepo note.** This is a Bun-workspaces monorepo (`workspaces`: `packages/*`, `packages/plugins/*`, `apps/*`) but **only `@ilamy/calendar` is published**. Not every other package is bundled into it — be precise:
- **Bundled into the published `@ilamy/calendar`** (private, no separate npm release): `@ilamy/types`, `@ilamy/utils`, `@ilamy/ui`, `@ilamy/calendar-recurrence`, `@ilamy/calendar-agenda` (the two plugin packages live under `packages/plugins/`). They are declared as `devDependencies` of `packages/calendar` and inlined via bunup's `noExternal`; `prepack` then drops `devDependencies` so the published manifest stays clean.
- **Private but NOT bundled and NOT published**: the apps `@ilamy/demo` and `@ilamy/website`, and `@ilamy/playground`. They never ship to npm and are not part of a release. The docs site (`apps/website`) deploys separately (Cloudflare Pages) — cutting an npm release does **not** redeploy it.
- The shipped version lives in **`packages/calendar/package.json`**, not the root `package.json` (the root is private and versionless). Bump and read the version there.
- The published package exposes four entry points via its `exports` map: `.`, `./testing`, `./plugins/recurrence`, `./plugins/agenda`. If a release changes a plugin's public surface, name the relevant subpath in the changelog.
- Publishing is a root command: **`bun run release`** = `build:lib` (`bun run --filter '@ilamy/calendar' build` — builds only the published package, bundling the internal packages above) then `publish:lib` (`cd packages/calendar && bun publish`, which honors `publishConfig.access: public` and runs `prepack`/`postpack`). `bun run release:dry` does the same with `--dry-run`.
- `CHANGELOG.md` and `docs/logs/` stay at the repo root.

**Why the pauses matter.** Recent git history shows version churn (`1.7.0` → `1.6.1` → `1.6.0` → `1.6.1`) from premature version bumps. Every step that writes to the tree, commits, tags, or pushes should be explicitly approved. It's cheap to pause and expensive to revert a tag.

## Step 1 — Preflight

Do these in parallel and report anything wrong. Do not proceed if any check fails; tell the user and let them resolve it.

- `git branch --show-current` → must be `main` (releases land on main per recent history).
- `git status --short` → must be empty. If there are staged/unstaged changes, ask the user whether to commit, stash, or abort.
- `git fetch origin main` then `git rev-list --left-right --count origin/main...HEAD` → local must be **equal to or 0 ahead** of `origin/main`. If local is behind, pull first. If ahead, confirm with the user that the unpushed commits are intended for this release.
- `git describe --tags --abbrev=0` → capture the last release tag (e.g., `v1.6.1`). This is the baseline for the commit log and the changelog `compare` link.
- Read **`packages/calendar/package.json`** `version` field (NOT the root — it's private and versionless). Sanity-check it matches the last tag minus the `v` prefix. If they diverge (as happened with the `1.7.0` → `1.6.1` revert), surface the mismatch and ask the user what the correct baseline is before continuing.

Do **not** run `bun run ci` yet — save it for Step 4 so we don't waste a clean build if the user doesn't like the version or the changelog.

## Step 2 — Suggest a version bump

Goal: propose `patch`, `minor`, or `major` with evidence, and wait for the user's call.

1. `git log <last-tag>..HEAD --pretty=format:"%h %s"` — list every commit since the last tag.
2. Classify each commit by its conventional prefix:
   - `feat:` → minor
   - `fix:` / `perf:` → patch
   - `refactor:` / `chore:` / `docs:` / `test:` / `style:` → patch (internal-only; doesn't bump minor)
   - `!:` suffix anywhere, or `BREAKING CHANGE:` in body → major
3. The suggested bump is the **highest** level across all commits. Example: 5 `fix:` + 1 `feat:` → minor.
4. Present a short table to the user:

   ```
   Since v1.6.1 (7 commits):
     feat (1):  feat: add weekViewGranularity prop (#113)
     fix  (3):  fix: ..., fix: ..., fix: ...
     perf (1):  perf: ...
     chore(2):  chore: ..., chore: ...
   Suggested bump: minor → v1.7.0
   ```

5. Ask: *"Bump to v1.7.0 (minor), or different?"*. Wait for approval. If the user picks a different level, use their choice — don't argue.

**Edge cases to call out up front (don't fix silently):**
- A commit with no conventional prefix: show it to the user and ask how to classify.
- A `feat:` that looks trivial or a `fix:` that looks like a breaking change: flag it; the user decides.
- Zero commits since the last tag: stop, tell the user there's nothing to release.

## Step 3 — Draft the CHANGELOG entry

Match the **existing format** in `CHANGELOG.md` exactly — the repo has a consistent style and downstream tooling/readers expect it.

### Entry template

```markdown
#### [vX.Y.Z](https://github.com/kcsujeet/ilamy-calendar/compare/vPREV...vX.Y.Z)

> DD Month YYYY

##### Features

- feat: <rewritten, user-facing description> ([`#N`](https://github.com/kcsujeet/ilamy-calendar/pull/N)) — Thanks [@handle](https://github.com/handle)!

##### Fixes

- fix: <rewritten, user-facing description> ([`#N`](https://github.com/kcsujeet/ilamy-calendar/pull/N))

##### Performance

- perf: <description>

##### Internal

- chore: <description>
```

### Rules

- **Sections, in this order, only if non-empty**: `Features`, `Fixes`, `Performance`, `Internal`. Skip empty sections entirely — don't render an empty `##### Performance` heading.
- **Section mapping**:
  - `feat:` → Features
  - `fix:` → Fixes
  - `perf:` → Performance
  - `chore:` / `refactor:` / `docs:` / `test:` / `style:` → Internal (but see "what to include" below)
- **Date**: today's date in UTC, formatted `D Month YYYY` (e.g., `19 April 2026`). Use `date -u "+%-d %B %Y"` — no leading zero on the day.
- **Compare link**: `https://github.com/kcsujeet/ilamy-calendar/compare/vPREV...vX.Y.Z`. Always include it, even if `vPREV` doesn't exist yet as a tag on GitHub — it'll render once pushed.
- **PR link format**: `([`#N`](https://github.com/kcsujeet/ilamy-calendar/pull/N))` — backticks around `#N`, trailing space before the parenthesis group.
- **Issue-closing**: if a PR body closes an issue, append `— Closes [`#M`](https://github.com/kcsujeet/ilamy-calendar/issues/M)`.
- **Contributor attribution**: for each PR, run `gh pr view <N> --json author,number`. If the author login is **not** `kcsujeet`, append `— Thanks [@<handle>](https://github.com/<handle>)!`. Don't thank the repo owner.
- **Attribute taken-over PRs to whoever opened the original.** Some contributor PRs are closed unmerged and re-done as a maintainer PR — same diagnosis, different implementation. The merged PR's author is then `kcsujeet`, so the author check above finds nobody to thank and the person who did the work silently loses the credit. Sweep for this every release:

  ```bash
  # closed-but-never-merged PRs from contributors, since the last release
  gh pr list --state closed --limit 150 --json number,title,author,mergedAt,closedAt \
    -q '.[] | select(.mergedAt == null) | select(.author.login != "kcsujeet")
        | select(.closedAt > "<last-release-date>")
        | "#\(.number) @\(.author.login) \(.closedAt[0:10]) \(.title)"'
  ```

  For each hit, ask whether something in this release replaced it — usually obvious from the title, and the superseding PR's body often says so outright (`#253` states "credit for the diagnosis belongs to @mattanderson-io"). If so, thank the original author on the bullet for the merged PR. A closed PR that was **not** replaced gets no credit; nothing of theirs shipped.
- **Do not thank issue reporters.** Credit is for authoring a PR, including one that was taken over. Reporting a bug, however well diagnosed, is not the same thing, and the changelog's `Closes #N` link already records who filed it.
- **Bullet wording**: rewrite commit messages into user-facing prose. "feat: add prop X" → "feat: add `X` prop — lets consumers do Y". Read the PR/commit body for the why. Avoid internal jargon the user-facing audience won't parse. When the original commit message is already good, lifting it directly is fine.

### What to include vs. omit

- **Always include**: `feat`, `fix`, `perf` — these affect consumers.
- **Internal section**: include only things a library user might care about — dep upgrades, bundle-size wins, tooling that changes how contributors work (e.g., "Tailwind upgraded to v4.2.2"). **Omit**: test-only changes, doc-only changes not affecting the public surface, lint tweaks, dev-log updates. When in doubt, leave it out — a short section reads better than a noisy one.
- **Group duplicates**: if three commits all touch the same feature (e.g., iterating on a fix), consolidate into one bullet that describes the net outcome, with the PR link pointing at the merge PR.

### Present the draft and wait

Insert the new entry at the **top** of `CHANGELOG.md` (after the `### Changelog` preamble, before the previous release). Show the user the full draft entry (not a diff — a diff of a prepend is noisy). Ask: *"Changelog looks right? Any wording changes?"*. Wait for approval. Iterate until they're happy.

## Step 4 — Run the CI gate

Only now run `bun run ci` (root). It runs, in order, `check` (Biome lint + format) → `build` → `type-check` → `test`. Monorepo wrinkle: root `bun run build` builds **every** workspace, including the `@ilamy/demo` and `@ilamy/website` apps, so this gate is slow and can fail on an app/website build issue that has nothing to do with the published package. If it fails, stop and surface the failure — do not bump the version or commit anything. The user fixes it (or, if it's an unrelated app-build failure, decides how to proceed — don't make that call yourself), then re-runs the skill (Step 1 will re-check state).

## Step 5 — Apply the bump

In this order:

1. Edit **`packages/calendar/package.json`** to set `"version": "X.Y.Z"` (the published package — not the root). Don't use `npm version` — its default behavior creates its own commit and tag with a message you don't control, and the recent revert chaos suggests you want full control over the commit.
2. Write the new changelog entry into `CHANGELOG.md` (already drafted in Step 3).
3. Update `docs/logs/YYYY-MM-DD.md` (today, per the project's mandatory dev-log rule) with a one-line summary: "**[release]**: Cut vX.Y.Z — <short summary of what's in it>".
4. Stage **only** these three files explicitly by name — never `git add -A`:
   ```
   git add packages/calendar/package.json CHANGELOG.md docs/logs/<today>.md
   ```
5. Commit with message exactly `X.Y.Z` (matching the project's existing release-commit style — see commits `1.6.0`, `1.6.1`, `1.7.0` in `git log`). No conventional prefix, no body, no co-author trailer (project CLAUDE.md forbids AI co-author trailers).
6. Tag: `git tag vX.Y.Z`. Lightweight tag, no `-a -m` — matches existing tag style in this repo unless `git tag -n <last-tag>` shows otherwise, in which case match that.

Show the user `git log -1 --stat` and `git tag --points-at HEAD` and ask: *"Ready to push `main` and tag `vX.Y.Z` to `origin`?"*. Wait for approval.

## Step 6 — Push

On explicit approval:

```
git push origin main
git push origin vX.Y.Z
```

Two separate pushes. If either fails (e.g., non-fast-forward on `main` because someone pushed while you were drafting), stop immediately — **do not** use `--force`. Tell the user and let them resolve.

## Step 7 — Create the GitHub release

Turn the pushed tag into a published release page on GitHub, marked as `latest`. This is public-facing, so follow the project's workflow rule: draft the body, get explicit approval, then post.

1. **Extract the release notes** from the changelog. Read the block you just added to `CHANGELOG.md`, starting **after** the `#### [vX.Y.Z](...)` heading (GitHub's title row already renders the version + compare link) and ending **before** the next `#### [v` heading (or EOF). Keep the `> DD Month YYYY` date line and the `##### Features` / `##### Fixes` / etc. sections verbatim.
2. **Write to a temp file**: `/tmp/release-notes-vX.Y.Z.md`. Don't inline the body as a CLI arg — shell quoting mangles backticks and newlines. A file is lossless.
3. **Show the body to the user** and ask: *"Post this as the vX.Y.Z release on GitHub and mark it latest?"* Wait for approval. Project rule (`.agents/rules/workflow.md`): never post public-facing content without explicit approval, even if the user has broadly opted-in to this skill.
4. **On approval**, create the release:
   ```
   gh release create vX.Y.Z \
     --repo kcsujeet/ilamy-calendar \
     --title "vX.Y.Z" \
     --notes-file /tmp/release-notes-vX.Y.Z.md \
     --latest \
     --verify-tag
   ```
   Notes:
   - `--latest` pins this release as the one GitHub's UI/npm badges/readme install snippets point at. Pass it explicitly — don't rely on `gh`'s auto-latest heuristic, because backport releases (e.g., a 1.5.3 after 1.6.0 exists) would otherwise lose the flag silently.
   - `--verify-tag` fails loudly if the tag didn't make it to the remote. Better than creating a release pointing at nothing.
   - No `--draft`. The user already approved the body in step 3; publishing a draft just means they have to click "Publish" again.
5. **Report the release URL** from `gh`'s output so the user can open it.

## Step 8 — Hand off for publish

Only one manual step is left before the issue follow-ups:

```
Released vX.Y.Z. Remaining (manual):

  bun run release

  (from the repo root — builds @ilamy/calendar then publishes it; needs an
  interactive npm login, which is why this skill stops here)

Once published, say "published" (or similar) and I'll update/deploy the
docs site and notify the issues this release closes.
```

`bun run release` (= `build:lib` then `publish:lib`) builds only `@ilamy/calendar` (bundling the private internal packages) and runs `bun publish` from `packages/calendar` — it honors `publishConfig.access: public` and the prepack/postpack scripts (which inject `sideEffects: false` and strip devDependencies). Tip: suggest `bun run release:dry` first to preview the tarball without uploading.

Do not offer to run `bun run release` yourself — it requires credentials you don't have. **Pause here.** Do not comment "Shipped in" / "Fixed in vX.Y.Z" on any issue (Step 10) until the publish has landed — saying so before the package is on npm would be a lie to the reporter.

**Confirm the publish on the registry itself**, not only on the user's word or npm's confirmation email. The email can arrive before the registry and its CDN catch up, and a plain `npm view` can serve a stale copy: during v3.1.0 it reported `3.0.1` as latest for a while after the email. Query the registry with a cache-buster and allow a delay before concluding anything:

```bash
curl -s -H 'Cache-Control: no-cache' "https://registry.npmjs.org/@ilamy%2Fcalendar?t=$(date +%s)" \
  | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['dist-tags']['latest'], d['time'].get('X.Y.Z'))"
```

If the version is missing, say so plainly as "not on the registry yet", re-check after a minute or two, and only then ask the user for the publish output. (The website deploy in Step 9 may run once the release is tagged, but is normally done right after the publish too.)

## Step 9 — Update and deploy the website

The docs site and live demo (`apps/website`) live in this repo but ship **separately** from the npm package — a release isn't really done until the website reflects it. Most releases need only **docs** changes; some also touch the **demo / playground**.

### Decide what the website needs

Cross-reference the changelog you wrote in Step 3:

- **`feat:` / public-API or prop changes** → update the matching page under `apps/website/src/content/docs/**` (a new prop → its row in `components/calendar.mdx`; a new plugin → a new `plugins/<name>.mdx` page **plus** a sidebar entry in `apps/website/astro.config.mjs`).
- **A new or changed user-facing capability worth showing off** → update the demo (`apps/demo`) and/or the shared playground (`packages/playground`) so the live demo exercises it.
- **`fix:`-only release with no public-surface change** → docs usually need no edits. Either skip the deploy, or redeploy anyway to pick up unrelated doc fixes already on `main`. Say which you're doing.

Write any doc/demo changes the same way as the rest of the codebase: **verify every API claim against source** (don't trust memory — past doc passes shipped fabricated members), and validate with the website build (`bun run --filter '@ilamy/website' build`). These are ordinary repo changes — branch + PR per the workflow rule, or commit per the user's call. They are **not** part of the release commit/tag from Step 5.

### Deploy (manual, like the npm publish)

The site goes to Cloudflare Pages and is **not** auto-deployed on push. From the repo root:

```
bun run deploy:website
```

That's `build:lib` (so docs build against the just-released `@ilamy/calendar` dist) then the website's own `deploy` (`astro build && wrangler pages deploy dist --project-name=calendar-ilamy-dev --branch=main`). It needs `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in the environment (Pages-scoped secrets; the repo is **public**, so never commit, echo, or paste them). If they aren't set in your environment, hand the `deploy` command to the user — same as the npm publish. Deploying is an outward-facing publish, so get explicit approval before running it (per `.agents/rules/workflow.md`) and confirm the live site afterward.

## Step 10 — Notify closed issues

Triggered when the user confirms the publish succeeded (phrases like "published", "it's on npm", "done", "pushed to npm"). Comment on every issue that this release closes and close any still open — in a single approval gate, not per-issue.

### Find the issues

Collect issue numbers from two sources and deduplicate:

1. **The new CHANGELOG entry** — scan the block you wrote in Step 3 for `Closes [`#N`](…/issues/N)` patterns. This is the canonical list.
2. **The commit range `<prev-tag>..<new-tag>`** — `git log <prev>..<new> --pretty=format:"%B"` then grep for `(Closes|Fixes|Resolves|closes|fixes|resolves) #\d+` in commit messages **and** in each referenced PR's body (`gh pr view <N> --json body`). Catches issues closed via a PR description that didn't make it verbatim into the changelog bullet.

For each number, check state with `gh issue view <N> --json state,title,number`:

- `CLOSED` (auto-closed when the PR merged): comment only, don't re-close.
- `OPEN` (PR reference didn't auto-link, or the fix landed via a commit not a PR): comment and close.

Skip numbers that resolve to pull requests, not issues. `gh issue view` on a PR number errors loudly — catch that and drop it.

### Comment template

Keep it short and human. No marketing copy, no emoji, no changelog repetition — the reporter can click through to the release page for details.

The first word depends on what the issue asked for. Read it off the changelog bullet that closes the issue (you wrote it in Step 3): a bullet under **Features** gets **"Shipped in"**, one under **Fixes** gets **"Fixed in"**. Telling someone their feature request was "fixed" reads as if their idea were a bug.

```
Shipped in vX.Y.Z — https://github.com/kcsujeet/ilamy-calendar/releases/tag/vX.Y.Z

Thanks for trying ilamy calendar! Feel free to open more issues any time.
```

```
Fixed in vX.Y.Z — https://github.com/kcsujeet/ilamy-calendar/releases/tag/vX.Y.Z

Thanks for trying ilamy calendar! Feel free to open more issues any time.
```

The release URL must match the one `gh release create` printed in Step 7 — don't reconstruct it manually and risk a typo.

### Single approval gate

Present the full plan in one message (issues + bodies + actions) and ask once. Example:

```
Ready to post on these 2 issues:

  #N "<feature request title>" (Features, currently CLOSED) — comment only
       Shipped in vX.Y.Z — https://github.com/kcsujeet/ilamy-calendar/releases/tag/vX.Y.Z

  #M "<bug report title>" (Fixes, currently OPEN) — comment + close
       Fixed in vX.Y.Z — https://github.com/kcsujeet/ilamy-calendar/releases/tag/vX.Y.Z

Every body ends with:

  Thanks for trying ilamy calendar! Feel free to open more issues any time.

Post?
```

The project's workflow rule (`.agents/rules/workflow.md`) requires explicit approval before any public-facing post — one approval for the batch is fine since the user has seen every body and the full list.

### Post on approval

For each issue, in sequence (small set, no benefit to parallel):

```
gh issue comment <N> --repo kcsujeet/ilamy-calendar --body "<template>"
# then, if the issue was OPEN:
gh issue close <N> --repo kcsujeet/ilamy-calendar
```

If `gh issue close` reports "already closed" (the auto-close landed between your state check and the close call), that's fine — keep going. If `gh issue comment` fails for a specific issue (e.g., the reporter deleted their account and the issue got locked), surface that one failure to the user and continue with the rest; don't abort the whole batch.

After the batch, report what landed:

```
Posted on #N, #M. Closed #M. All done.
```

## Failure modes to watch for

- **Tag already exists** (`git tag vX.Y.Z` fails): somebody already cut this version, or a previous attempt got halfway. Ask the user before using `-f`.
- **`packages/calendar/package.json` version doesn't match latest tag**: the canonical example is the recent `1.7.0` → reverted state. Flag it, ask what the true baseline is.
- **Unmerged PR references in the changelog**: the user closed a PR by rebasing/squashing. When you run `gh pr view <N>`, confirm the PR is `merged` (not `closed`). If it's `closed` without merging, don't credit it.
- **`bun run ci` is slow** (~60s+ for the full gate on this repo): run it in the background and keep drafting; don't block the user unnecessarily.
