diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 30f8a13..8c06885 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -10,32 +10,39 @@ Standardized release workflow for @stackbox-dev/fp-plugins. ## Workflow -1. **Pre-flight checks** - - Ensure working tree is clean (`git status`) - - Ensure on `main` branch - - Run `pnpm test` to verify all tests pass - - Run `pnpm run build` to verify build succeeds - -2. **Version bump** - - Ask user for bump type: patch, minor, or major - - Update `version` in `package.json` - - Create a commit with message: `X.Y.Z` (version number only, matching existing convention) - -3. **Verify** - - Show the user the version diff and commit - - Remind the user that publishing is triggered by **creating a GitHub Release**, - not by pushing. `npm-publish-github-packages.yml` runs `on: release: [created]`; - a push to `main` alone publishes nothing. +1. **Pre-flight** + - Working tree clean, on `main`, up to date + - `pnpm test` and `pnpm run build` pass + +2. **Cut the version on a branch** — `main` requires a PR, so this cannot be pushed + directly: + + ```bash + git checkout -b release/X.Y.Z + pnpm version # commit "X.Y.Z" + local tag vX.Y.Z + git push -u origin release/X.Y.Z # branch only, not the tag + ``` + + Ask the user for the bump type. `pnpm version` writes a commit whose message is + just the version, matching existing history. + +3. **Open the PR**, titled with the version (see `release/2.14.0`, PR #4). + +4. **Merging is the release.** `.github/workflows/release.yml` runs on every push to + `main`: if `package.json`'s version has no matching `vX.Y.Z` tag, it runs the full + gate (lint, build, unit tests, MinIO integration), then tags, creates the GitHub + Release and publishes to GitHub Packages. Nothing else is required. ## Notes -- The version bump belongs on `main` and nowhere else. Never put one in a feature or - fix PR — those PRs describe changes, releases decide versions. A bump riding along - in a feature branch also makes the PR harder to review and forces a rebuild if the - release is deferred. -- Do NOT push automatically — let the user decide when to push -- Do NOT create git tags by hand. Creating the GitHub Release creates the tag. -- Follow existing commit message convention (see `git log` — version bumps use just the version number like `2.12.0`) -- `main` is protected by a ruleset requiring one approving review, code-owner review, - and `require_last_push_approval` — so any push after an approval dismisses it. Get - the branch final before asking for review. +- **Do not push the tag by hand.** The workflow creates it. `pnpm version` makes one + locally; delete it (`git tag -d vX.Y.Z`) or just leave it unpushed. +- **Do not create the GitHub Release by hand.** The workflow does that too, with + generated notes. +- A version bump belongs on `main` and nowhere else — never in a feature or fix PR. +- Pushes to `main` whose version is already tagged are a no-op, so ordinary merges do + not publish. +- A failed run can be retried with `workflow_dispatch` without bumping the version + again; every step is idempotent. +- `main` requires one approving review and sets `require_last_push_approval`, so any + push after an approval dismisses it. Get the branch final before requesting review. diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml deleted file mode 100644 index 5faba15..0000000 --- a/.github/workflows/npm-publish-github-packages.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Publishes to GitHub Packages when a release is published. -# See .claude/skills/release/SKILL.md for the release procedure. - -name: Node.js Package - -on: - release: - # published, not created: GitHub fires `created` when a *draft* is saved, which - # would publish from the default branch before the tag exists — and then fires - # `published` (not `created`) when that draft is released, so the real release - # would ship nothing. - types: [published] - -env: - NODE_VERSION: 24 - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run lint - - run: pnpm run build - - run: pnpm test - - publish-gpr: - needs: build - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: pnpm - registry-url: https://npm.pkg.github.com/ - - run: pnpm install --frozen-lockfile - - # The release tag and package.json must agree, or the published version silently - # differs from the tag people will go looking for. TAG comes through env rather - # than being interpolated into the script. - - name: Verify tag matches package.json version - env: - TAG: ${{ github.event.release.tag_name }} - run: | - VERSION="v$(node -p 'require("./package.json").version')" - if [ "$VERSION" != "$TAG" ]; then - echo "Release tag $TAG does not match package.json $VERSION" >&2 - exit 1 - fi - - # npm publish, not pnpm publish: pnpm adds git-state checks the release flow - # does not need. prepublishOnly runs the build either way. - # No --provenance: GitHub Packages does not accept provenance attestations. - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c536801 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,122 @@ +# Merging a version bump to main is the whole release. This workflow tags, creates the +# GitHub Release and publishes to GitHub Packages. +# +# Why one workflow instead of "create a release, let release:published publish it": +# a release created with GITHUB_TOKEN does not trigger further workflow runs — GitHub +# blocks that to avoid recursion — so the publish would silently never happen. +# +# Every step is idempotent, so a failed run can be retried with workflow_dispatch +# without bumping the version again. + +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +env: + NODE_VERSION: 24 + +jobs: + check: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.check.outputs.version }} + release: ${{ steps.check.outputs.release }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: check + run: | + VERSION="$(node -p 'require("./package.json").version')" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "release=true" >> "$GITHUB_OUTPUT" + echo "manual run: releasing v$VERSION" + elif git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "release=false" >> "$GITHUB_OUTPUT" + echo "v$VERSION is already tagged — nothing to release" + else + echo "release=true" >> "$GITHUB_OUTPUT" + echo "v$VERSION is new — releasing" + fi + + release: + needs: check + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # create the tag and the release + packages: write # publish + env: + VERSION: ${{ needs.check.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + registry-url: https://npm.pkg.github.com/ + - run: pnpm install --frozen-lockfile + + # Publishing is irreversible, so the full gate runs first. + - run: pnpm run lint + - run: pnpm run build + - run: pnpm test + + - name: Start MinIO + run: | + docker run -d --name release-minio -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:latest server /data + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:9000/minio/health/live; then + echo "minio ready"; exit 0 + fi + sleep 2 + done + echo "minio did not become healthy" >&2 + exit 1 + - run: pnpm run test:integration + env: + MINIO_TEST_ENDPOINT: http://127.0.0.1:9000 + - name: Stop MinIO + if: always() + run: docker rm -f release-minio || true + + - name: Tag + run: | + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "tag v$VERSION already exists" + else + git tag "v$VERSION" + git push origin "v$VERSION" + fi + + - name: GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "v$VERSION" >/dev/null 2>&1; then + echo "release v$VERSION already exists" + else + gh release create "v$VERSION" --title "v$VERSION" --generate-notes + fi + + # npm publish, not pnpm publish: pnpm adds git-state checks this flow does not + # need. prepublishOnly rebuilds. No --provenance: GitHub Packages rejects + # attestations. A duplicate version 409s, which is the desired failure. + - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm publish diff --git a/CLAUDE.md b/CLAUDE.md index 14ffd34..c7a2a7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,8 @@ the issue thread or the PR that resolves it. - **`tsconfig.build.json` sets `skipLibCheck: true`** — still required. Turning it off fails on `thread-stream@4.2.0` (a pino dependency), whose `.d.ts` references `TransferListItem`, a name its `worker_threads` types do not export. -- **Releasing**: version bumps happen on `main`, in their own commit named just the - version (`2.16.0`). Do not put a version bump in a feature PR and do not create - tags by hand — publishing is triggered by creating a GitHub Release. See +- **Releasing**: bump the version with `pnpm version` on a `release/X.Y.Z` branch and + merge it. `.github/workflows/release.yml` does the rest — tag, GitHub Release, + publish — on any push to `main` whose version has no matching tag. Do not push tags + or create releases by hand, and never put a version bump in a feature PR. See `.claude/skills/release/SKILL.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb7898f..46d0432 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,14 +35,19 @@ updated lockfile. ## Releases -**Do not put a version bump in a feature or fix PR**, and do not create tags by hand. +Cut the version on a branch — `main` requires a PR: -Releasing is a separate act on `main`: +```bash +git checkout -b release/X.Y.Z +pnpm version +git push -u origin release/X.Y.Z # branch only; do not push the tag +``` + +Open a PR titled with the version. **Merging it is the release**: the `Release` +workflow tags, creates the GitHub Release and publishes to GitHub Packages, after +running lint, build, unit tests and the MinIO integration suite. -1. Bump `version` in `package.json` in its own commit, whose message is just the - version number (`2.16.0`), matching existing history. -2. Create a **GitHub Release**. That is what creates the tag and triggers - `npm-publish-github-packages.yml` to publish. Pushing to `main` alone publishes - nothing. +Do not push tags or create releases by hand. Merges whose version is already tagged +are a no-op, so normal PRs do not publish. -See `.claude/skills/release/SKILL.md` for the full checklist. +See `.claude/skills/release/SKILL.md`.