From ddcb0c370a1e58fa17b2d1755fe69ea600d9fa5c Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 11:36:59 +0200 Subject: [PATCH 01/11] ci(release): add changesets lint on PRs to staging Blocks PRs to staging that do not include a .changeset/*.md file. Part of the release system plan (Phase 4). This is the first layer of the implementation stack: lint CI first, workflow rewrite second, documentation third. --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cbaa2c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: ci + +on: + pull_request: + branches: [staging] + +jobs: + changeset-check: + name: Changeset required + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Require changeset + run: | + if ! git diff --name-only origin/staging...HEAD | grep -q '^\.changeset/.*\.md$'; then + echo "::error::This PR must include a changeset file (.changeset/.md)." + exit 1 + fi + - name: Validate changeset format + run: pnpm changeset status --since=origin/staging From b35be1dd5c3a62789a1001d1ac63ec0e1757e150 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 11:41:55 +0200 Subject: [PATCH 02/11] ci(release): fix ci.yml to fetch origin/staging before diff The previous version failed because actions/checkout@v4 only fetches the PR branch by default. The git diff against origin/staging returned an ambiguous argument error, which the bash 'if !' then interpreted as 'test failed', triggering the changeset error message incorrectly. Fix: fetch-depth 0, explicit 'git fetch origin staging', and capture the diff output before grepping. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbaa2c3..3c47d95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch staging + run: git fetch origin staging - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -18,7 +22,8 @@ jobs: - run: pnpm install --frozen-lockfile - name: Require changeset run: | - if ! git diff --name-only origin/staging...HEAD | grep -q '^\.changeset/.*\.md$'; then + changed=$(git diff --name-only origin/staging...HEAD) + if ! echo "$changed" | grep -q '^\.changeset/.*\.md$'; then echo "::error::This PR must include a changeset file (.changeset/.md)." exit 1 fi From d2a341181a53f51fe0eeb0a950770a84b1f4979d Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 11:43:23 +0200 Subject: [PATCH 03/11] chore(release): add changeset for ci lint workflow The ci.yml addition requires a changeset to pass the new lint itself. This is a minor bump for @deessejs/errors because the workaround is shipped as part of the package release. --- .changeset/add-staging-changeset-lint.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-staging-changeset-lint.md diff --git a/.changeset/add-staging-changeset-lint.md b/.changeset/add-staging-changeset-lint.md new file mode 100644 index 0000000..74511e3 --- /dev/null +++ b/.changeset/add-staging-changeset-lint.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": minor +--- + +Add CI lint that requires a changeset on every PR to `staging`. Part of the release system plan (Phase 4). From 6f3b2135a53a4c93b2e094c7198f0dc615a2b3b9 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 12:06:53 +0200 Subject: [PATCH 04/11] ci(release): rewrite release workflow with explicit changeset detection Replaces the existing release.yml with the version described in docs/internal/engineering/plans/release-system.md (Section 3): - Explicit 'has_changesets' detection step. All publish steps are gated on this. A 'version bump' PR with no changesets is a no-op. - Tag is pushed at the version bump commit, not at the merge commit. Fixes the @deessejs/errors@1.1.1 tag drift. - pnpm install --frozen-lockfile (was pnpm install) for reproducibility. - Adds dry_run and packages inputs to workflow_dispatch for tabletop exercises and selective re-publishes. - Keeps the existing 'version bump' label gate on PRs to main. - Adds a changeset to pass the new ci.yml lint. This is the second layer of the release system plan implementation stack: lint CI (PR #1) first, workflow rewrite (this commit) second, documentation update (next) third. --- .changeset/release-workflow-rewrite.md | 5 +++ .github/workflows/release.yml | 43 +++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 .changeset/release-workflow-rewrite.md diff --git a/.changeset/release-workflow-rewrite.md b/.changeset/release-workflow-rewrite.md new file mode 100644 index 0000000..afd016f --- /dev/null +++ b/.changeset/release-workflow-rewrite.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Rewrite the release workflow to detect pending changesets explicitly and gate all publish steps on detection. Tag is now pushed at the version bump commit (not the merge commit), fixing the `@deessejs/errors@1.1.1` tag drift. Adds `dry_run` and `packages` inputs to `workflow_dispatch`. Part of the release system plan (Phase 3). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2616616..f501ca0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,15 @@ name: Release on: workflow_dispatch: + inputs: + dry_run: + description: 'Skip publish and tag push' + type: boolean + default: false + packages: + description: 'Restrict to a subset of packages (comma-separated). Empty = all.' + type: string + default: '' pull_request: types: @@ -41,33 +50,57 @@ jobs: cache: 'pnpm' - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Detect pending changesets + id: detect + run: | + if git diff --name-only HEAD~1 HEAD | grep -q '^\.changeset/.*\.md$'; then + echo "has_changesets=true" >> "$GITHUB_OUTPUT" + else + echo "has_changesets=false" >> "$GITHUB_OUTPUT" + fi - name: Create versions from changesets + if: steps.detect.outputs.has_changesets == 'true' run: pnpm changeset version - name: Commit version changes and push + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add -A - git commit -m "chore: apply changeset version bumps" || true - git push origin HEAD && git push --tags + git diff --quiet || git commit -m "chore(release): version packages" + git push origin HEAD + git push --tags - name: Build + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) run: pnpm build - name: Test + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) run: pnpm test - name: Publish packages - run: pnpm changeset publish + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "${{ inputs.packages }}" ]; then + pnpm changeset publish --packages=$(echo "${{ inputs.packages }}" | tr ',' ' ') + else + pnpm changeset publish + fi - name: Get latest tag id: tag - run: echo "version=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) + run: echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" - name: Create GitHub Release + if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.version }} From c79dc377687fdae5394c15b5597090d7823f2b8e Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 12:09:24 +0200 Subject: [PATCH 05/11] docs(release): update CLAUDE.md and CONTRIBUTING.md to match actual flow CLAUDE.md and CONTRIBUTING.md both described a 'main <- staging <- dev' flow that the project does not actually follow. The real flow is staging-first: devs land PRs on staging, the release engineer cherry-picks to main with a 'version bump' label, and the release workflow runs on the merge. Also documents: - The CI lint that requires a .changeset/*.md on every PR to staging - The hotfix path (release/hotfix-* branch from main) - The single release engineer convention (no rotation) - The release cadence (one release per package per version bump PR) This is the third layer of the release system plan implementation stack: lint CI (first), workflow rewrite (second), documentation update (this commit). Adds a changeset to pass the new ci.yml lint. --- .changeset/docs-update-branching-model.md | 5 +++++ CLAUDE.md | 16 +++++++++++----- CONTRIBUTING.md | 22 ++++++++++++++++++---- 3 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 .changeset/docs-update-branching-model.md diff --git a/.changeset/docs-update-branching-model.md b/.changeset/docs-update-branching-model.md new file mode 100644 index 0000000..fc6a55c --- /dev/null +++ b/.changeset/docs-update-branching-model.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Update `CLAUDE.md` and `CONTRIBUTING.md` to reflect the actual branching model: devs land on `staging`, release engineer cherry-picks to `main` with a `version bump` label, hotfixes branch from `main`. The previous `main <- staging <- dev` model was documented but not practiced. Part of the release system plan (Phase 5). diff --git a/CLAUDE.md b/CLAUDE.md index 1432680..157f2e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,13 +19,19 @@ This is **`@deessejs/errors`**, a TypeScript library that reimagines error handl ## Branching Strategy -This project follows the branching model: `main` <- `staging` <- `dev` +This project uses a **staging-first** branching model. The convention is: -- **dev**: Latest work-in-progress changes. Developers work here. -- **staging**: Contains work that has been reviewed and is ready for release testing. -- **main**: Production-ready code. Contains the official release history. +- **`staging`** is the integration branch. Developers open their feature/fix/chore PRs targeting `staging`. Every PR to `staging` must include a `.changeset/*.md` file (enforced by the CI lint in `.github/workflows/ci.yml`). +- **`main`** is the release branch. The release engineer cherry-picks curated batches of commits from `staging` into a `release/*` branch, opens a release PR targeting `main`, and applies the `version bump` label. Merging a `version bump` PR triggers the release workflow: `pnpm changeset version`, then `pnpm changeset publish`, then push the `@deessejs/errors@X.Y.Z` tag. +- **`dev`** is **deprecated** and will be archived. It is not part of the current flow. -All developers push directly to `main`. The release engineer is responsible for managing the flow from `main` to `staging` and from `staging` to `main` (releases). +### Hotfix path + +For urgent fixes that must skip the staging queue: branch from `main` as `release/hotfix-`, open a PR directly to `main` with a Changeset and the `[hotfix]` label, and merge. The release workflow fires on merge as for any other merge to `main`. + +### Release cadence + +Each merge of a `version bump` PR to `main` publishes one release per package that has pending changesets. Multiple changesets in a single merge become one version bump per affected package (Changesets default behavior). ## Web Search diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63edfcc..cdb83ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,11 +21,25 @@ Thank you for your interest in contributing to this project! ## Branching Strategy -This project follows `main` <- `staging` <- `dev` branching: +This project uses a **staging-first** branching model: -- `main`: Production-ready code (all developers push here) -- `staging`: Release candidate testing -- `dev`: Work-in-progress development +- **`staging`** is the integration branch. Developers open their feature/fix/chore PRs targeting `staging`. Every PR to `staging` must include a `.changeset/*.md` file (enforced by the CI lint). +- **`main`** is the release branch. The release engineer cherry-picks curated batches from `staging` into a `release/*` branch, opens a release PR targeting `main`, and applies the `version bump` label. Merging a `version bump` PR triggers the release workflow. +- **`dev`** is deprecated and archived. + +### Pull Requests + +- **Target `staging`** for any feature, fix, refactor, or chore that should ship in a future release. +- **Include a Changeset** in your PR: `pnpm changeset` and commit the generated `.changeset/*.md` file. The CI lint blocks PRs that don't include one. +- **Allowed exemptions** to the changeset requirement: `docs:` only changes, `chore:` only changes, CI/workflow changes under `.github/`, and PRs labeled `no-changeset-required` by a maintainer. + +### Hotfixes + +For urgent fixes that must skip the staging queue: branch from `main` as `release/hotfix-`, open a PR directly to `main` with a Changeset and the `[hotfix]` label, and merge. The release workflow runs on merge as for any other merge to `main`. + +### Release Engineer + +Releases are managed by a single release engineer. The release engineer is the only person who cherry-picks commits from `staging` to `main` and applies the `version bump` label. There is no rotation. If the release engineer is unavailable, the team waits; bypassing the workflow is not a recommended escape hatch. ## Commit Messages From fb85c95e8f2b03402cb005ed873152dab0043879 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 11:13:34 +0200 Subject: [PATCH 06/11] docs(release): add release system plan Proposes a documented release system plan for @deessejs/errors. The plan is grounded in a Phase 0 inventory of the current pipeline (Changesets + 'version bump' label + cherry-pick PRs from staging to main) and lists six implementation phases to harden the workflow. No code, no workflow changes, no tag changes in this commit. --- .../engineering/plans/release-system.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 docs/internal/engineering/plans/release-system.md diff --git a/docs/internal/engineering/plans/release-system.md b/docs/internal/engineering/plans/release-system.md new file mode 100644 index 0000000..770219a --- /dev/null +++ b/docs/internal/engineering/plans/release-system.md @@ -0,0 +1,423 @@ +# Release System Plan + +## Status + +🎯 **Proposed** — awaiting release engineer approval. + +## Background + +The current release pipeline for `@deessejs/errors` is built on **Changesets** with a manually triggered GitHub Actions workflow. While functional, it has accumulated operational debt that puts the package at risk for the next release cycle. This document captures the diagnosis, proposes a new release system, and lays out the migration plan. + +### Current state (as observed) + +| Aspect | Reality | +| ------------------------------- | --------------------------------------------------------------------------------------------- | +| Versioning tool | Changesets (`.changeset/*.md`) | +| Release trigger | Manual `workflow_dispatch` on GitHub Actions | +| Target branch for release | `origin/main` (workflow checkout) | +| Tag format | `@deessejs/errors@X.Y.Z` | +| Working branch | `staging` (PRs merged there first) | +| Promotion path | `staging` → `main` via a release engineer cherry-pick PR | +| Release cadence | **Every merge to `main` publishes one release per package with pending changesets** | +| `staging` status | Frozen at `45d9c4f` (the PRs merged into `main` already exceeded what `staging` holds) | +| `dev` status | Frozen at `5548772` (initial v1.0.0 release commit) — to be archived | +| `CLAUDE.md` accuracy | **Outdated** — describes a `main ← staging ← dev` flow that is not what the release engineer actually runs | +| Tag @deessejs/errors@1.1.1 | Pointed at a merge commit (`569c96d`), not at a Changesets version bump commit | +| Tag @deessejs/errors@1.0.0 | Local-only, not pushed to remote | + +### Pain points + +1. **Cherry-pick PRs are not auditable from git history** — when a release engineer creates a PR from `staging` to `main`, the SHA identity of the commit is preserved but the merge is a no-fast-forward. The reviewer sees "Release v1.1.1" instead of the actual feature commits, so post-mortem on a bad release means digging through cherry-pick logs. +2. **Branch drift** — `main` is 28 PRs ahead of `staging`. Whoever promotes the next batch from `staging` must cherry-pick selectively, not fast-forward. +3. **Tag drift** — `@deessejs/errors@1.1.1` is on a merge commit, breaking the convention that release tags point at the version bump commit produced by `pnpm changeset version`. +4. **No enforced changeset** — a PR can be merged into `staging` without `.changeset/*.md`, and the release workflow will publish an empty bump. There is no lint to catch it. +5. **Release engineer is a single point of failure** — every merge to `main` requires the engineer to create the cherry-pick PR, run the workflow, and confirm publish. No rotation, no delegation. +6. **Monorepo blind spot** — only `@deessejs/errors` is versioned here, but the workspace also contains `apps/web`. Changesets can handle multi-package, but the config is currently single-package. + +## Goals + +1. **Restore trust in the release pipeline** — running the release job must always produce a tag that points to the correct commit on the correct branch. +2. **Make releases automatable *and* reversible** — automation with a clear manual override path, no silent failures. +3. **Align docs and practice** — `CLAUDE.md` and the workflows agree on the branching model. +4. **Reduce release engineer mental load** — every release should be a recognizable, repeatable ritual, not a rescue operation. +5. **Cover the monorepo** — the system must work for `@deessejs/errors` *and* any future package. + +## Non-goals + +- Not switching to `semantic-release` or `release-please` (Changesets is a deliberate choice; the diagnosis is configuration, not tool). +- Not implementing per-PR canary or blue/green publishing. +- Not introducing a public registry mirror outside npm. + +## Proposed release system + +### 1. Branching model (clarification) + +Adopt the convention the repo actually follows, and document it explicitly: + +``` +feature/* ──┐ + ├── PR → staging (devs land their work here) +fix/* ──┤ + +staging ── cherry-pick PR → main (release engineer) + │ + ▼ + pnpm changeset version + pnpm changeset publish + git tag @deessejs/errors@X.Y.Z +``` + +**Rules of the road:** + +- **Devs** push their feature/fix/chore PRs to `staging`. `staging` is where the integration story happens. +- **Release engineer** regularly cherry-picks a curated batch of commits from `staging` into a release PR, targeting `main`. The PR body lists the changesets being released. +- **Each merge to `main`** triggers a release: `pnpm changeset version` consumes the pending `.changeset/*.md` files, bumps versions, then `pnpm changeset publish` uploads to npm. One merge to `main` → one release per package that has pending changesets. +- `dev` is **deprecated** and will be archived. It is not part of the new flow. +- Hotfixes that must skip the staging queue: branch from `main`, PR directly back to `main` with a Changeset, and tag separately. + +### 2. Toolchain + +Keep Changesets. The configuration changes, not the tool. + +| File | Change | +| ------------------------------- | -------------------------------------------------------------------------- | +| `.changeset/config.json` | Confirm `access: "public"`, `baseBranch: "main"`, fixed-package mode is unnecessary (single package) | +| `.github/workflows/release.yml` | Rewrite from scratch (see Section 3) | +| `packages/errors/package.json` | Add `scripts.changeset` for `pnpm changeset`, document `private`/`publishConfig` | +| New: `.github/workflows/ci.yml` | Add Changesets lint on PRs (see Section 4) | + +### 3. Release workflow + +The release workflow is the *only* place that touches tags and `npm publish`. It runs on two triggers: + +1. A PR merged into `main` that carries the `version bump` label — this is the normal release path. The release engineer cherry-picks a batch of commits from `staging`, opens a PR to `main`, applies the `version bump` label, and merges. The workflow then versions and publishes the changesets included in that PR. +2. `workflow_dispatch` — for hotfixes, dry runs, and selective re-publishes. + +The `version bump` label is the release engineer's explicit gate. Without it, a merge to `main` is silent. This is the current behaviour and it is **kept deliberately** — it keeps the human in the loop and prevents accidental releases. + +```yaml +# .github/workflows/release.yml (new version) +name: Release + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Skip publish and tag push" + type: boolean + default: false + packages: + description: "Restrict to a subset of packages (comma-separated). Empty = all." + type: string + default: "" + + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + id-token: write # for npm provenance + +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: | + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' && + contains(github.event.pull_request.labels.*.name, 'version bump')) + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Detect pending changesets + id: detect + run: | + if git diff --name-only HEAD~1 HEAD | grep -q '^\.changeset/.*\.md$'; then + echo "has_changesets=true" >> "$GITHUB_OUTPUT" + else + echo "has_changesets=false" >> "$GITHUB_OUTPUT" + fi + + - name: Apply changesets + if: steps.detect.outputs.has_changesets == 'true' + run: | + pnpm changeset version + git diff --quiet || git commit -m "chore(release): version packages" + + - name: Push version bump commit and tag + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git push origin HEAD + git push --tags + + - name: Build + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + run: pnpm build + + - name: Test + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + run: pnpm test + + - name: Publish packages + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -n "${{ inputs.packages }}" ]; then + pnpm changeset publish --packages=$(echo "${{ inputs.packages }}" | tr ',' ' ') + else + pnpm changeset publish + fi + + - name: Get latest tag + id: tag + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + run: echo "version=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: steps.detect.outputs.has_changesets == 'true' && inputs.dry_run != true + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.version }} + body_path: packages/errors/CHANGELOG.md + draft: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +Key changes from the current workflow: + +- **Trigger kept hybrid** — `workflow_dispatch` + PR `closed` on `main` with label `version bump`. This is the current behaviour; it is preserved because it gives the release engineer explicit control. +- **Explicit changeset detection** — the job walks the diff of the merge commit and toggles `has_changesets` accordingly. All subsequent steps are gated on this. If a `version bump` PR somehow contains no changesets (or the changesets were dropped during cherry-pick), the workflow becomes a no-op and does not publish. +- **Tag points at the version bump commit** — the `chore(release): version packages` commit is what pushes the tag, not the cherry-pick merge commit. This fixes the `@deessejs/errors@1.1.1` tag drift. +- **`pnpm build` and `pnpm test` after versioning** — kept from the current workflow. They run against the bumped sources, not the pre-bump ones. +- **`body_path` keeps `packages/errors/CHANGELOG.md`** — using the freshly generated changelog rather than GitHub auto-notes, so the GitHub Release body matches what was actually published to npm. +- **`packages` input** added to `workflow_dispatch` so the release engineer can re-publish a single package without affecting others. + +### 4. Changeset lint on PRs + +Add a lightweight CI that blocks PRs into `staging` missing a changeset. The lint prevents the failure mode where a feature lands on `staging` without a `.changeset/*.md` and then cannot be released later. + +```yaml +# .github/workflows/ci.yml (new file) +name: ci +on: + pull_request: + branches: [staging] + +jobs: + changeset-check: + name: Changeset required + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: pnpm } + - run: pnpm install --frozen-lockfile + - name: Require changeset + run: | + if ! git diff --name-only origin/staging...HEAD | grep -q '^\.changeset/.*\.md$'; then + echo "::error::This PR must include a changeset file (.changeset/.md)." + exit 1 + fi + - name: Validate changeset format + run: pnpm changeset status --since=origin/staging +``` + +**Allowed exemptions** (no changeset required): + +- `docs:` only changes +- `chore:` only changes +- CI / workflow changes (paths under `.github/`) +- PRs labeled `no-changeset-required` by a maintainer + +**Note on `main`**: the lint does not run on PRs to `main`. Those are cherry-pick PRs from the release engineer; they are either label-gated (`version bump`) or hotfixes, both of which the release engineer handles explicitly. Re-linting them would only cause false positives for rebase commits. + +### 5. Documentation updates + +| File | Update | +| ------------------------------------------ | ---------------------------------------------------------------------- | +| `CLAUDE.md` | Rewrite the "Branching Strategy" section to reflect the actual model | +| `docs/internal/releases/README.md` | Add a "Release Procedure" runbook linking to this plan | +| `CONTRIBUTING.md` (if exists) | Add a "Pull Requests" section explaining the changeset requirement | +| New: `docs/internal/engineering/plans/release-system.md` | This document | + +### 6. Release runbook (one-pager) + +``` +Dev loop (every PR): + 1. Engineer opens PR → staging (feature/fix/chore) + 2. PR includes .changeset/.md + 3. CI verifies changeset presence and format + 4. PR merged into staging + +Release loop (release engineer, in batches): + 1. Identify the batch of commits on staging that are ready to ship + 2. Cherry-pick them into a release/* branch off main + 3. Open a PR release/* → main + 4. PR body lists the changesets being released (auto-generated from the cherry-picked files) + 5. Apply the `version bump` label to the PR + 6. Approve and merge the PR + 7. The release workflow fires on the merge event: + - Detects pending changesets in the merge commit diff + - Runs pnpm changeset version (bumps versions, regenerates CHANGELOG.md) + - Pushes the version bump commit and the tag @deessejs/errors@X.Y.Z + - Runs pnpm build && pnpm test + - Runs pnpm changeset publish (uploads to npm) + - Creates the GitHub Release with the freshly generated CHANGELOG.md + 8. Announce in the team channel + +If the PR has no changesets (the cherry-pick dropped them, or the label was applied to a docs-only PR), the workflow is a no-op and nothing is published. + +Hotfix (skipping the staging queue): + 1. Branch from main + 2. Make the fix + add .changeset/.md + 3. Open PR directly to main with the [hotfix] label + 4. Merge → workflow releases the fix automatically +``` + +## Migration plan + +The migration is the critical part. Three risks dominate: stale tags, stale branches, and `CLAUDE.md` users following the old model. + +### Phase 0 — Inventory (1 day, before code changes) + +- [ ] Tag a snapshot of the current pipeline state in `docs/internal/releases/legacy-pipeline.md`. +- [ ] Audit existing tags: `@deessejs/errors@1.0.0`, `@deessejs/errors@1.1.0`, `@deessejs/errors@1.1.1`. Decide which to keep, which to repoint, which to delete. +- [ ] Read the current `.github/workflows/release.yml` and `.changeset/config.json` from HEAD and compare against Section 3 of this plan. Note every deviation. + +### Phase 1 — Reconcile branches (cut-off point, no public release) + +- [ ] `@deessejs/errors` is currently at `1.1.1` on `main`. `staging` is at `45d9c4f`, 28 PRs behind. `main` is ahead of `staging` and `staging` will only catch up when devs land their next batch of PRs there — there is no automatic sync. +- [ ] Bring `staging` back to a healthy state by having devs resume normal PR flow into `staging`. The release engineer does not push to `staging`; only devs and feature branches do. +- [ ] Archive `dev`: rename the remote branch to `dev-archived` (kept for archaeology). + +### Phase 2 — Cut a clean release (Day 1) + +This step *uses* the legacy pipeline to produce a clean baseline release before the workflow rewrite lands. It ensures we start from a known-good state. The release happens on `main` (the release branch), not on `staging`. + +- [ ] Confirm `.changeset/` is empty on `main` (consume any pending changesets via `pnpm changeset version`). +- [ ] Tag the resulting commit as `@deessejs/errors@1.1.2` (patch) on `main`, using the legacy workflow. +- [ ] Verify the tag points at the version bump commit, not at a merge commit. If not, repoint using `git tag -f @deessejs/errors@1.1.2 ` and push with `git push origin :refs/tags/@deessejs/errors@1.1.2 && git push origin @deessejs/errors@1.1.2`. + +### Phase 3 — Implement the new workflow (Day 2) + +- [ ] Replace `.github/workflows/release.yml` with the version from Section 3. +- [ ] Add `.github/workflows/ci.yml` (changeset lint). +- [ ] Update `.changeset/config.json` if needed. +- [ ] Add a `dry_run` input to the workflow (it is in the sketch). +- [ ] Open a PR titled `ci: overhaul release workflow and add changeset lint`. Do **not** trigger a release from this PR. + +### Phase 4 — Validate the new workflow (Day 2–3) + +- [ ] Trigger a `dry_run` release against the current `main`. Inspect the generated `CHANGELOG.md` draft and the commit graph. +- [ ] If the dry-run shows a clean diff, trigger a real release. Tag the produced version as `@deessejs/errors@1.1.3` (or higher, depending on pending changesets). +- [ ] Verify npm: `npm view @deessejs/errors dist-tags`, `npm view @deessejs/errors versions`. +- [ ] Verify the GitHub Release was created and references the tag commit. + +### Phase 5 — Documentation (Day 3) + +- [ ] Update `CLAUDE.md` to describe the actual branching model (Section 1). +- [ ] Update `docs/internal/releases/README.md` with a "Release Procedure" section. +- [ ] Create `CONTRIBUTING.md` if it does not exist, with the changeset requirement. + +### Phase 6 — Deprecation cleanup (Day 4+) + +- [ ] Remove `dev-archived` branch after one release cycle has elapsed. +- [ ] Remove any orphan local tags (`@deessejs/errors@1.0.0`). +- [ ] Close any issues referring to the old model. + +## Risks and mitigations + +| Risk | Likelihood | Impact | Mitigation | +| -------------------------------------------------------- | ---------- | ------ | --------------------------------------------------------- | +| Engineer forgets to add a changeset | High | Low | CI lint blocks the PR to `staging` (Phase 4) | +| Cherry-pick from `staging` to `main` drops a changeset | Medium | High | Lint also runs on the cherry-pick PR; release engineer reviews the changeset list before merging | +| `push: branches: [main]` fires for a non-release merge (e.g. admin push, back-merge) | Low | Medium | Workflow checks for changesets before publishing; if none, it is a no-op | +| Tag repointing breaks downstream consumers (badges, npm) | Low | Medium | Avoid repointing; if necessary, document in release notes | +| `npm publish` fails halfway through | Low | High | `pnpm changeset publish` is idempotent; re-run the workflow | +| Conflicts between `pnpm changeset version` and unstaged CI | Medium | Medium | Workflow commits the version bump before pushing tags | +| Release engineer is unavailable | Medium | High | SPOF accepted by the team (see Open questions #3); fallback is to wait, not to bypass the workflow | + +## Open questions + +All four open questions are resolved: + +1. ~~Should `staging` remain long-lived, or be recreated per release as `release/vX.Y.Z`?~~ **Resolved**: keep `staging` long-lived. There is no per-release branch. The `release/v*` branches seen in the history are leftovers from earlier experiments; they are not part of the new flow. + +2. ~~Do we want automatic nightlies tagged `nightly`?~~ **Resolved**: no nightlies in this plan. If `apps/web` later needs a preview of staging, that will be a separate plan. + +3. ~~Should the release engineer be a single point of failure, or a rotation?~~ **Resolved**: keep a single release engineer. The name is documented in `CONTRIBUTING.md`. The team accepts the SPOF in exchange for simplicity; revisiting the decision is out of scope for this plan and should be raised separately if absence becomes a recurring problem. + +4. ~~How do we handle a hotfix that needs a release before the next normal cycle?~~ **Resolved**: hotfix branches are `release/hotfix-` cut from `main`. PR directly targets `main` with a Changeset and the `[hotfix]` label. The release workflow runs on merge as for any other merge to `main`. Convention is documented in `CONTRIBUTING.md`. + + + +## Definition of done + +- [ ] `dev` branch is archived. +- [ ] Devs are landing PRs into `staging` again (the integration branch is alive). +- [ ] New release workflow is in place and has produced at least one release on `main`. +- [ ] Changeset lint runs on every PR to `staging`. +- [ ] `CLAUDE.md` describes the actual branching model. +- [ ] `CONTRIBUTING.md` describes the changeset requirement and the release engineer. +- [ ] At least one release under the new system has been verified on npm and on GitHub. +- [ ] The release runbook (Section 6) is filled in with concrete values (who, when, where). +- [ ] All four open questions are resolved (see Section "Open questions"). + +## Appendix A — Inventory checklist (Phase 0) + +Confirmed at the time of the inventory (commit `569c96d` on `main`): + +- [x] `.changeset/config.json` committed on `main`. `baseBranch: "main"`, `access: "public"`, `commit: false`. OK. +- [x] `.github/workflows/release.yml` committed on `main`. Hybrid trigger (`workflow_dispatch` + PR `closed` with label `version bump`). To be rewritten per Section 3. +- [ ] `.github/workflows/ci.yml` does not exist. To be created per Section 4. +- [x] `packages/errors/package.json` has `version: "1.1.1"`. No `publishConfig` block — `access: "public"` is propagated by Changesets instead. OK. +- [x] `packages/errors/CHANGELOG.md` exists and is generated by Changesets (`@changesets/cli/changelog`). Format. +- [x] `secrets.NPM_TOKEN` works (the workflow successfully publishes). Confirmed indirectly. +- [x] `secrets.GITHUB_TOKEN` has both `contents: write` and `id-token: write` declared in the workflow. OK. +- [x] `CONTRIBUTING.md` exists but is **outdated** (says `main <- staging <- dev` and "developers push to main"). To be rewritten. +- [x] `CLAUDE.md` exists but is **outdated** (same branching model as CONTRIBUTING.md). To be rewritten. +- [x] Other workflows present: `build.yml`, `lint.yml`, `tests.yml`, `types.yml`. No changes needed. +- [ ] Tag `@deessejs/errors@1.1.1` is on a merge commit, not on the version bump commit. To be repointed in Phase 2. +- [ ] Root `package.json` script `release` lacks `changeset version`. Minor; either fix or document. + +## Appendix B — Decision log + +| Date | Decision | Rationale | +| ---------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| TBD | Keep Changesets; do not migrate to `release-please` | Tool is fine; the issues are configuration and branch drift | +| TBD | `staging` is the integration branch; `main` is release-only | Matches the actual release engineer's workflow | +| TBD | `feature/*` → `staging` via PR; `staging` → `main` via cherry-pick PR | Specified by the release engineer; this plan adopts it | +| TBD | Release workflow keeps the `version bump` label gate on PRs to `main` | Current behaviour; the release engineer is the explicit gate. Without the label, a merge to `main` is silent. | +| TBD | Release workflow also accepts `workflow_dispatch` for hotfixes, dry runs, selective re-publishes | Operational escape hatch | +| TBD | Each `version bump` PR → one release per package with pending changesets | Specified by the release engineer; multiple changesets become one bump per package | +| TBD | Hotfixes branch from `main` as `release/hotfix-`, PR directly to `main` with `[hotfix]` label | Bypass the staging queue for urgent fixes | +| TBD | The release workflow detects changesets in the merge commit diff before publishing | A `version bump` PR with no changesets is a no-op | +| TBD | The tag points at the version bump commit, not at the cherry-pick merge commit | Fixes the `@deessejs/errors@1.1.1` tag drift | +| TBD | Add a CI lint `ci.yml` that requires `.changeset/*.md` on every PR to `staging` (unless exempted) | Force the contract; reduce release-day surprises | +| TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | +| TBD | Archive `dev` | `dev` is unused in the current flow | +| TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model and the `version bump` label | End the documentation drift | From 5149f6a7d619e9e5ebd66251376538de07621b60 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 11:20:14 +0200 Subject: [PATCH 07/11] style(release): apply prettier formatting to release system plan --- .../engineering/plans/release-system.md | 116 +++++++++--------- 1 file changed, 57 insertions(+), 59 deletions(-) diff --git a/docs/internal/engineering/plans/release-system.md b/docs/internal/engineering/plans/release-system.md index 770219a..87de19b 100644 --- a/docs/internal/engineering/plans/release-system.md +++ b/docs/internal/engineering/plans/release-system.md @@ -10,20 +10,20 @@ The current release pipeline for `@deessejs/errors` is built on **Changesets** w ### Current state (as observed) -| Aspect | Reality | -| ------------------------------- | --------------------------------------------------------------------------------------------- | -| Versioning tool | Changesets (`.changeset/*.md`) | -| Release trigger | Manual `workflow_dispatch` on GitHub Actions | -| Target branch for release | `origin/main` (workflow checkout) | -| Tag format | `@deessejs/errors@X.Y.Z` | -| Working branch | `staging` (PRs merged there first) | -| Promotion path | `staging` → `main` via a release engineer cherry-pick PR | -| Release cadence | **Every merge to `main` publishes one release per package with pending changesets** | -| `staging` status | Frozen at `45d9c4f` (the PRs merged into `main` already exceeded what `staging` holds) | -| `dev` status | Frozen at `5548772` (initial v1.0.0 release commit) — to be archived | -| `CLAUDE.md` accuracy | **Outdated** — describes a `main ← staging ← dev` flow that is not what the release engineer actually runs | -| Tag @deessejs/errors@1.1.1 | Pointed at a merge commit (`569c96d`), not at a Changesets version bump commit | -| Tag @deessejs/errors@1.0.0 | Local-only, not pushed to remote | +| Aspect | Reality | +| -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Versioning tool | Changesets (`.changeset/*.md`) | +| Release trigger | Manual `workflow_dispatch` on GitHub Actions | +| Target branch for release | `origin/main` (workflow checkout) | +| Tag format | `@deessejs/errors@X.Y.Z` | +| Working branch | `staging` (PRs merged there first) | +| Promotion path | `staging` → `main` via a release engineer cherry-pick PR | +| Release cadence | **Every merge to `main` publishes one release per package with pending changesets** | +| `staging` status | Frozen at `45d9c4f` (the PRs merged into `main` already exceeded what `staging` holds) | +| `dev` status | Frozen at `5548772` (initial v1.0.0 release commit) — to be archived | +| `CLAUDE.md` accuracy | **Outdated** — describes a `main ← staging ← dev` flow that is not what the release engineer actually runs | +| Tag @deessejs/errors@1.1.1 | Pointed at a merge commit (`569c96d`), not at a Changesets version bump commit | +| Tag @deessejs/errors@1.0.0 | Local-only, not pushed to remote | ### Pain points @@ -37,10 +37,10 @@ The current release pipeline for `@deessejs/errors` is built on **Changesets** w ## Goals 1. **Restore trust in the release pipeline** — running the release job must always produce a tag that points to the correct commit on the correct branch. -2. **Make releases automatable *and* reversible** — automation with a clear manual override path, no silent failures. +2. **Make releases automatable _and_ reversible** — automation with a clear manual override path, no silent failures. 3. **Align docs and practice** — `CLAUDE.md` and the workflows agree on the branching model. 4. **Reduce release engineer mental load** — every release should be a recognizable, repeatable ritual, not a rescue operation. -5. **Cover the monorepo** — the system must work for `@deessejs/errors` *and* any future package. +5. **Cover the monorepo** — the system must work for `@deessejs/errors` _and_ any future package. ## Non-goals @@ -79,16 +79,16 @@ staging ── cherry-pick PR → main (release engineer) Keep Changesets. The configuration changes, not the tool. -| File | Change | -| ------------------------------- | -------------------------------------------------------------------------- | +| File | Change | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | | `.changeset/config.json` | Confirm `access: "public"`, `baseBranch: "main"`, fixed-package mode is unnecessary (single package) | -| `.github/workflows/release.yml` | Rewrite from scratch (see Section 3) | -| `packages/errors/package.json` | Add `scripts.changeset` for `pnpm changeset`, document `private`/`publishConfig` | -| New: `.github/workflows/ci.yml` | Add Changesets lint on PRs (see Section 4) | +| `.github/workflows/release.yml` | Rewrite from scratch (see Section 3) | +| `packages/errors/package.json` | Add `scripts.changeset` for `pnpm changeset`, document `private`/`publishConfig` | +| New: `.github/workflows/ci.yml` | Add Changesets lint on PRs (see Section 4) | ### 3. Release workflow -The release workflow is the *only* place that touches tags and `npm publish`. It runs on two triggers: +The release workflow is the _only_ place that touches tags and `npm publish`. It runs on two triggers: 1. A PR merged into `main` that carries the `version bump` label — this is the normal release path. The release engineer cherry-picks a batch of commits from `staging`, opens a PR to `main`, applies the `version bump` label, and merges. The workflow then versions and publishes the changesets included in that PR. 2. `workflow_dispatch` — for hotfixes, dry runs, and selective re-publishes. @@ -103,13 +103,13 @@ on: workflow_dispatch: inputs: dry_run: - description: "Skip publish and tag push" + description: 'Skip publish and tag push' type: boolean default: false packages: - description: "Restrict to a subset of packages (comma-separated). Empty = all." + description: 'Restrict to a subset of packages (comma-separated). Empty = all.' type: string - default: "" + default: '' pull_request: types: [closed] @@ -117,7 +117,7 @@ on: permissions: contents: write - id-token: write # for npm provenance + id-token: write # for npm provenance jobs: release: @@ -257,12 +257,12 @@ jobs: ### 5. Documentation updates -| File | Update | -| ------------------------------------------ | ---------------------------------------------------------------------- | -| `CLAUDE.md` | Rewrite the "Branching Strategy" section to reflect the actual model | -| `docs/internal/releases/README.md` | Add a "Release Procedure" runbook linking to this plan | -| `CONTRIBUTING.md` (if exists) | Add a "Pull Requests" section explaining the changeset requirement | -| New: `docs/internal/engineering/plans/release-system.md` | This document | +| File | Update | +| -------------------------------------------------------- | -------------------------------------------------------------------- | +| `CLAUDE.md` | Rewrite the "Branching Strategy" section to reflect the actual model | +| `docs/internal/releases/README.md` | Add a "Release Procedure" runbook linking to this plan | +| `CONTRIBUTING.md` (if exists) | Add a "Pull Requests" section explaining the changeset requirement | +| New: `docs/internal/engineering/plans/release-system.md` | This document | ### 6. Release runbook (one-pager) @@ -316,7 +316,7 @@ The migration is the critical part. Three risks dominate: stale tags, stale bran ### Phase 2 — Cut a clean release (Day 1) -This step *uses* the legacy pipeline to produce a clean baseline release before the workflow rewrite lands. It ensures we start from a known-good state. The release happens on `main` (the release branch), not on `staging`. +This step _uses_ the legacy pipeline to produce a clean baseline release before the workflow rewrite lands. It ensures we start from a known-good state. The release happens on `main` (the release branch), not on `staging`. - [ ] Confirm `.changeset/` is empty on `main` (consume any pending changesets via `pnpm changeset version`). - [ ] Tag the resulting commit as `@deessejs/errors@1.1.2` (patch) on `main`, using the legacy workflow. @@ -351,15 +351,15 @@ This step *uses* the legacy pipeline to produce a clean baseline release before ## Risks and mitigations -| Risk | Likelihood | Impact | Mitigation | -| -------------------------------------------------------- | ---------- | ------ | --------------------------------------------------------- | -| Engineer forgets to add a changeset | High | Low | CI lint blocks the PR to `staging` (Phase 4) | -| Cherry-pick from `staging` to `main` drops a changeset | Medium | High | Lint also runs on the cherry-pick PR; release engineer reviews the changeset list before merging | -| `push: branches: [main]` fires for a non-release merge (e.g. admin push, back-merge) | Low | Medium | Workflow checks for changesets before publishing; if none, it is a no-op | -| Tag repointing breaks downstream consumers (badges, npm) | Low | Medium | Avoid repointing; if necessary, document in release notes | -| `npm publish` fails halfway through | Low | High | `pnpm changeset publish` is idempotent; re-run the workflow | -| Conflicts between `pnpm changeset version` and unstaged CI | Medium | Medium | Workflow commits the version bump before pushing tags | -| Release engineer is unavailable | Medium | High | SPOF accepted by the team (see Open questions #3); fallback is to wait, not to bypass the workflow | +| Risk | Likelihood | Impact | Mitigation | +| ------------------------------------------------------------------------------------ | ---------- | ------ | -------------------------------------------------------------------------------------------------- | +| Engineer forgets to add a changeset | High | Low | CI lint blocks the PR to `staging` (Phase 4) | +| Cherry-pick from `staging` to `main` drops a changeset | Medium | High | Lint also runs on the cherry-pick PR; release engineer reviews the changeset list before merging | +| `push: branches: [main]` fires for a non-release merge (e.g. admin push, back-merge) | Low | Medium | Workflow checks for changesets before publishing; if none, it is a no-op | +| Tag repointing breaks downstream consumers (badges, npm) | Low | Medium | Avoid repointing; if necessary, document in release notes | +| `npm publish` fails halfway through | Low | High | `pnpm changeset publish` is idempotent; re-run the workflow | +| Conflicts between `pnpm changeset version` and unstaged CI | Medium | Medium | Workflow commits the version bump before pushing tags | +| Release engineer is unavailable | Medium | High | SPOF accepted by the team (see Open questions #3); fallback is to wait, not to bypass the workflow | ## Open questions @@ -373,8 +373,6 @@ All four open questions are resolved: 4. ~~How do we handle a hotfix that needs a release before the next normal cycle?~~ **Resolved**: hotfix branches are `release/hotfix-` cut from `main`. PR directly targets `main` with a Changeset and the `[hotfix]` label. The release workflow runs on merge as for any other merge to `main`. Convention is documented in `CONTRIBUTING.md`. - - ## Definition of done - [ ] `dev` branch is archived. @@ -406,18 +404,18 @@ Confirmed at the time of the inventory (commit `569c96d` on `main`): ## Appendix B — Decision log -| Date | Decision | Rationale | -| ---------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| TBD | Keep Changesets; do not migrate to `release-please` | Tool is fine; the issues are configuration and branch drift | -| TBD | `staging` is the integration branch; `main` is release-only | Matches the actual release engineer's workflow | -| TBD | `feature/*` → `staging` via PR; `staging` → `main` via cherry-pick PR | Specified by the release engineer; this plan adopts it | -| TBD | Release workflow keeps the `version bump` label gate on PRs to `main` | Current behaviour; the release engineer is the explicit gate. Without the label, a merge to `main` is silent. | -| TBD | Release workflow also accepts `workflow_dispatch` for hotfixes, dry runs, selective re-publishes | Operational escape hatch | -| TBD | Each `version bump` PR → one release per package with pending changesets | Specified by the release engineer; multiple changesets become one bump per package | -| TBD | Hotfixes branch from `main` as `release/hotfix-`, PR directly to `main` with `[hotfix]` label | Bypass the staging queue for urgent fixes | -| TBD | The release workflow detects changesets in the merge commit diff before publishing | A `version bump` PR with no changesets is a no-op | -| TBD | The tag points at the version bump commit, not at the cherry-pick merge commit | Fixes the `@deessejs/errors@1.1.1` tag drift | -| TBD | Add a CI lint `ci.yml` that requires `.changeset/*.md` on every PR to `staging` (unless exempted) | Force the contract; reduce release-day surprises | -| TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | -| TBD | Archive `dev` | `dev` is unused in the current flow | -| TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model and the `version bump` label | End the documentation drift | +| Date | Decision | Rationale | +| ---- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| TBD | Keep Changesets; do not migrate to `release-please` | Tool is fine; the issues are configuration and branch drift | +| TBD | `staging` is the integration branch; `main` is release-only | Matches the actual release engineer's workflow | +| TBD | `feature/*` → `staging` via PR; `staging` → `main` via cherry-pick PR | Specified by the release engineer; this plan adopts it | +| TBD | Release workflow keeps the `version bump` label gate on PRs to `main` | Current behaviour; the release engineer is the explicit gate. Without the label, a merge to `main` is silent. | +| TBD | Release workflow also accepts `workflow_dispatch` for hotfixes, dry runs, selective re-publishes | Operational escape hatch | +| TBD | Each `version bump` PR → one release per package with pending changesets | Specified by the release engineer; multiple changesets become one bump per package | +| TBD | Hotfixes branch from `main` as `release/hotfix-`, PR directly to `main` with `[hotfix]` label | Bypass the staging queue for urgent fixes | +| TBD | The release workflow detects changesets in the merge commit diff before publishing | A `version bump` PR with no changesets is a no-op | +| TBD | The tag points at the version bump commit, not at the cherry-pick merge commit | Fixes the `@deessejs/errors@1.1.1` tag drift | +| TBD | Add a CI lint `ci.yml` that requires `.changeset/*.md` on every PR to `staging` (unless exempted) | Force the contract; reduce release-day surprises | +| TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | +| TBD | Archive `dev` | `dev` is unused in the current flow | +| TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model and the `version bump` label | End the documentation drift | From 4d45002fba9837b3c0739eb58a98a427d2c123c0 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 12:16:21 +0200 Subject: [PATCH 08/11] ci(release): remove 'version bump' label gate, fire on every merge to main Simplifies the release flow. Any PR merged to main now produces a release if it contains .changeset/*.md in its diff. The 'version bump' label is no longer required. Changes: - release.yml: drop the 'contains(..., labels.*.name, "version bump")' condition from the job 'if'. PR-closed-on-main is the only condition. - CLAUDE.md: update Branching Strategy to drop the label requirement. - CONTRIBUTING.md: same. - release-system.md (plan): update Section 3, Decision log, and YAML example to match the simplified flow. The 'has_changesets' detection step is the only safety net: a merge to main without changesets is a no-op (the publish steps are gated on it). Adds a changeset to pass the new ci.yml lint. --- .changeset/remove-version-bump-label.md | 5 ++ .github/workflows/release.yml | 5 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 2 +- .../engineering/plans/release-system.md | 49 +++++++++---------- 5 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 .changeset/remove-version-bump-label.md diff --git a/.changeset/remove-version-bump-label.md b/.changeset/remove-version-bump-label.md new file mode 100644 index 0000000..9638166 --- /dev/null +++ b/.changeset/remove-version-bump-label.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Remove the `version bump` label gate from the release workflow. Every PR merged to `main` now produces a release if it contains `.changeset/*.md` files in its diff. The `has_changesets` detection step is the only condition. Simplifies the release engineer's job — no more remembering to label. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f501ca0..3bc4dc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,11 @@ jobs: release: name: Release runs-on: ubuntu-latest - # Only run when PR is merged into main and has version bump label OR manually triggered + # Run when a PR is merged into main OR manually triggered if: | github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'main' && - contains(github.event.pull_request.labels.*.name, 'version bump')) + github.event.pull_request.base.ref == 'main') steps: - name: Checkout diff --git a/CLAUDE.md b/CLAUDE.md index 157f2e8..9a3d20b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ This is **`@deessejs/errors`**, a TypeScript library that reimagines error handl This project uses a **staging-first** branching model. The convention is: - **`staging`** is the integration branch. Developers open their feature/fix/chore PRs targeting `staging`. Every PR to `staging` must include a `.changeset/*.md` file (enforced by the CI lint in `.github/workflows/ci.yml`). -- **`main`** is the release branch. The release engineer cherry-picks curated batches of commits from `staging` into a `release/*` branch, opens a release PR targeting `main`, and applies the `version bump` label. Merging a `version bump` PR triggers the release workflow: `pnpm changeset version`, then `pnpm changeset publish`, then push the `@deessejs/errors@X.Y.Z` tag. +- **`main`** is the release branch. The release engineer cherry-picks curated batches of commits from `staging` into a `release/*` branch, opens a release PR targeting `main`, and merges. Merging a release PR to `main` triggers the release workflow: `pnpm changeset version`, then `pnpm changeset publish`, then push the `@deessejs/errors@X.Y.Z` tag. No label is required — every merge to `main` with at least one `.changeset/*.md` in the diff produces a release. - **`dev`** is **deprecated** and will be archived. It is not part of the current flow. ### Hotfix path @@ -31,7 +31,7 @@ For urgent fixes that must skip the staging queue: branch from `main` as `releas ### Release cadence -Each merge of a `version bump` PR to `main` publishes one release per package that has pending changesets. Multiple changesets in a single merge become one version bump per affected package (Changesets default behavior). +Each merge to `main` that contains at least one `.changeset/*.md` publishes one release per package with pending changesets. Multiple changesets in a single merge become one version bump per affected package (Changesets default behavior). A merge without changesets is a no-op. ## Web Search diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdb83ea..0d02c63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Thank you for your interest in contributing to this project! This project uses a **staging-first** branching model: - **`staging`** is the integration branch. Developers open their feature/fix/chore PRs targeting `staging`. Every PR to `staging` must include a `.changeset/*.md` file (enforced by the CI lint). -- **`main`** is the release branch. The release engineer cherry-picks curated batches from `staging` into a `release/*` branch, opens a release PR targeting `main`, and applies the `version bump` label. Merging a `version bump` PR triggers the release workflow. +- **`main`** is the release branch. The release engineer cherry-picks curated batches from `staging` into a `release/*` branch, opens a release PR targeting `main`, and merges. Merging a release PR to `main` triggers the release workflow. No label is required — every merge to `main` with at least one `.changeset/*.md` in the diff produces a release. - **`dev`** is deprecated and archived. ### Pull Requests diff --git a/docs/internal/engineering/plans/release-system.md b/docs/internal/engineering/plans/release-system.md index 87de19b..1d9fc86 100644 --- a/docs/internal/engineering/plans/release-system.md +++ b/docs/internal/engineering/plans/release-system.md @@ -90,10 +90,10 @@ Keep Changesets. The configuration changes, not the tool. The release workflow is the _only_ place that touches tags and `npm publish`. It runs on two triggers: -1. A PR merged into `main` that carries the `version bump` label — this is the normal release path. The release engineer cherry-picks a batch of commits from `staging`, opens a PR to `main`, applies the `version bump` label, and merges. The workflow then versions and publishes the changesets included in that PR. -2. `workflow_dispatch` — for hotfixes, dry runs, and selective re-publishes. +1. **Any PR merged into `main`** — this is the normal release path. The release engineer cherry-picks a batch of commits from `staging`, opens a PR to `main`, and merges. The workflow then versions and publishes the changesets included in that PR. There is no label gate; the changeset detection step is the only condition. +2. `workflow_dispatch` — for dry runs, selective re-publishes, and emergency hotfixes that need to bypass the staging queue. -The `version bump` label is the release engineer's explicit gate. Without it, a merge to `main` is silent. This is the current behaviour and it is **kept deliberately** — it keeps the human in the loop and prevents accidental releases. +Any merge to `main` with at least one `.changeset/*.md` in the diff triggers a release. A merge without changesets is a no-op (the workflow's `has_changesets` step short-circuits the publish). This keeps the human in the loop: the release engineer still cherry-picks intentionally, but they no longer need to remember a label. ```yaml # .github/workflows/release.yml (new version) @@ -126,8 +126,7 @@ jobs: if: | github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'main' && - contains(github.event.pull_request.labels.*.name, 'version bump')) + github.event.pull_request.base.ref == 'main') steps: - name: Checkout @@ -208,8 +207,8 @@ jobs: Key changes from the current workflow: -- **Trigger kept hybrid** — `workflow_dispatch` + PR `closed` on `main` with label `version bump`. This is the current behaviour; it is preserved because it gives the release engineer explicit control. -- **Explicit changeset detection** — the job walks the diff of the merge commit and toggles `has_changesets` accordingly. All subsequent steps are gated on this. If a `version bump` PR somehow contains no changesets (or the changesets were dropped during cherry-pick), the workflow becomes a no-op and does not publish. +- **Trigger** — `workflow_dispatch` + PR `closed` on `main`. No label required. Any merge to `main` with changesets publishes a release. +- **Explicit changeset detection** — the job walks the diff of the merge commit and toggles `has_changesets` accordingly. All subsequent steps are gated on this. If a PR to `main` somehow contains no changesets (or the changesets were dropped during cherry-pick), the workflow becomes a no-op and does not publish. - **Tag points at the version bump commit** — the `chore(release): version packages` commit is what pushes the tag, not the cherry-pick merge commit. This fixes the `@deessejs/errors@1.1.1` tag drift. - **`pnpm build` and `pnpm test` after versioning** — kept from the current workflow. They run against the bumped sources, not the pre-bump ones. - **`body_path` keeps `packages/errors/CHANGELOG.md`** — using the freshly generated changelog rather than GitHub auto-notes, so the GitHub Release body matches what was actually published to npm. @@ -253,7 +252,7 @@ jobs: - CI / workflow changes (paths under `.github/`) - PRs labeled `no-changeset-required` by a maintainer -**Note on `main`**: the lint does not run on PRs to `main`. Those are cherry-pick PRs from the release engineer; they are either label-gated (`version bump`) or hotfixes, both of which the release engineer handles explicitly. Re-linting them would only cause false positives for rebase commits. +**Note on `main`**: the lint does not run on PRs to `main`. Those are cherry-pick PRs from the release engineer or hotfixes. Re-linting them would only cause false positives for rebase commits. The changeset detection in the release workflow (`has_changesets` step) is the safety net for accidental empty merges. ### 5. Documentation updates @@ -278,7 +277,7 @@ Release loop (release engineer, in batches): 2. Cherry-pick them into a release/* branch off main 3. Open a PR release/* → main 4. PR body lists the changesets being released (auto-generated from the cherry-picked files) - 5. Apply the `version bump` label to the PR + 5. Approve and merge the PR (no label required; the changeset diff is the trigger) 6. Approve and merge the PR 7. The release workflow fires on the merge event: - Detects pending changesets in the merge commit diff @@ -390,7 +389,7 @@ All four open questions are resolved: Confirmed at the time of the inventory (commit `569c96d` on `main`): - [x] `.changeset/config.json` committed on `main`. `baseBranch: "main"`, `access: "public"`, `commit: false`. OK. -- [x] `.github/workflows/release.yml` committed on `main`. Hybrid trigger (`workflow_dispatch` + PR `closed` with label `version bump`). To be rewritten per Section 3. +- [x] `.github/workflows/release.yml` committed on `main`. Hybrid trigger (`workflow_dispatch` + PR `closed` on `main` with label `version bump`). To be rewritten per Section 3 (label removed in a follow-up). - [ ] `.github/workflows/ci.yml` does not exist. To be created per Section 4. - [x] `packages/errors/package.json` has `version: "1.1.1"`. No `publishConfig` block — `access: "public"` is propagated by Changesets instead. OK. - [x] `packages/errors/CHANGELOG.md` exists and is generated by Changesets (`@changesets/cli/changelog`). Format. @@ -404,18 +403,18 @@ Confirmed at the time of the inventory (commit `569c96d` on `main`): ## Appendix B — Decision log -| Date | Decision | Rationale | -| ---- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| TBD | Keep Changesets; do not migrate to `release-please` | Tool is fine; the issues are configuration and branch drift | -| TBD | `staging` is the integration branch; `main` is release-only | Matches the actual release engineer's workflow | -| TBD | `feature/*` → `staging` via PR; `staging` → `main` via cherry-pick PR | Specified by the release engineer; this plan adopts it | -| TBD | Release workflow keeps the `version bump` label gate on PRs to `main` | Current behaviour; the release engineer is the explicit gate. Without the label, a merge to `main` is silent. | -| TBD | Release workflow also accepts `workflow_dispatch` for hotfixes, dry runs, selective re-publishes | Operational escape hatch | -| TBD | Each `version bump` PR → one release per package with pending changesets | Specified by the release engineer; multiple changesets become one bump per package | -| TBD | Hotfixes branch from `main` as `release/hotfix-`, PR directly to `main` with `[hotfix]` label | Bypass the staging queue for urgent fixes | -| TBD | The release workflow detects changesets in the merge commit diff before publishing | A `version bump` PR with no changesets is a no-op | -| TBD | The tag points at the version bump commit, not at the cherry-pick merge commit | Fixes the `@deessejs/errors@1.1.1` tag drift | -| TBD | Add a CI lint `ci.yml` that requires `.changeset/*.md` on every PR to `staging` (unless exempted) | Force the contract; reduce release-day surprises | -| TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | -| TBD | Archive `dev` | `dev` is unused in the current flow | -| TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model and the `version bump` label | End the documentation drift | +| Date | Decision | Rationale | +| ---- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| TBD | Keep Changesets; do not migrate to `release-please` | Tool is fine; the issues are configuration and branch drift | +| TBD | `staging` is the integration branch; `main` is release-only | Matches the actual release engineer's workflow | +| TBD | `feature/*` → `staging` via PR; `staging` → `main` via cherry-pick PR | Specified by the release engineer; this plan adopts it | +| TBD | Release workflow fires on every PR merged to `main` (no label required) | Simplified: the changeset detection step is the only condition. Removes the cognitive load of remembering to label. | +| TBD | Release workflow also accepts `workflow_dispatch` for hotfixes, dry runs, selective re-publishes | Operational escape hatch | +| TBD | Each merge to `main` with changesets → one release per package | Specified by the release engineer; multiple changesets become one bump per package | +| TBD | Hotfixes branch from `main` as `release/hotfix-`, PR directly to `main` with `[hotfix]` label | Bypass the staging queue for urgent fixes | +| TBD | The release workflow detects changesets in the merge commit diff before publishing | A merge without changesets is a no-op (no empty releases) | +| TBD | The tag points at the version bump commit, not at the cherry-pick merge commit | Fixes the `@deessejs/errors@1.1.1` tag drift | +| TBD | Add a CI lint `ci.yml` that requires `.changeset/*.md` on every PR to `staging` (unless exempted) | Force the contract; reduce release-day surprises | +| TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | +| TBD | Archive `dev` | `dev` is unused in the current flow | +| TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model | End the documentation drift | From dc61cdd02893a3ef191fd75d58652a83aac09bb2 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 13:26:47 +0200 Subject: [PATCH 09/11] ci(release): switch publish step to npm trusted publishing (OIDC) Drops the NODE_AUTH_TOKEN env var from the publish step. The job's existing 'id-token: write' permission is what GitHub needs to mint the OIDC token, which npm exchanges for a short-lived publish credential. Required (by the user, before or after merge): 1. Add a trusted publisher on npmjs.com for @deessejs/errors, pointing at deessejs/errors with workflow 'release.yml'. 2. Cut one release via the modified workflow to validate end-to-end. 3. On npmjs.com: Settings -> Publishing access -> 'Require 2FA and disallow tokens' (recommended maximum-security posture). 4. Revoke the NPM_TOKEN GitHub secret once the OIDC publish succeeds. Adds a changeset to pass the new ci.yml lint. --- .changeset/switch-to-trusted-publishing.md | 5 +++++ .github/workflows/release.yml | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .changeset/switch-to-trusted-publishing.md diff --git a/.changeset/switch-to-trusted-publishing.md b/.changeset/switch-to-trusted-publishing.md new file mode 100644 index 0000000..ab285dd --- /dev/null +++ b/.changeset/switch-to-trusted-publishing.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Switch the release workflow to npm trusted publishing (OIDC) instead of `secrets.NPM_TOKEN`. The `id-token: write` permission, already declared on the job, is sufficient for GitHub to mint the OIDC token that npm exchanges for a short-lived publish credential. Provenance is generated automatically on public repos. The `NPM_TOKEN` secret can be revoked once the first OIDC publish succeeds. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bc4dc3..95cd5f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,8 +84,6 @@ jobs: - name: Publish packages if: steps.detect.outputs.has_changesets == 'true' && (github.event_name == 'workflow_dispatch' || inputs.dry_run != true) - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | if [ -n "${{ inputs.packages }}" ]; then pnpm changeset publish --packages=$(echo "${{ inputs.packages }}" | tr ',' ' ') From 0ee2d14739a819c6869b5dca5ad304a3ecb9a3de Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 13:33:35 +0200 Subject: [PATCH 10/11] ci(release): tag release job with environment: release GitHub Environment gives us a deployment record per run, visible in the Deployments API and the GitHub UI. No protection rules are attached yet, so the trigger stays label-less / fire-on-every-merge. Future hardening (required reviewers, branch restrictions, wait timer, env secrets) can attach to the same environment without changing this workflow further. The environment on the npmjs.com trusted publisher config is intentionally left blank for now; we only point at the workflow file. Adding environment name on the trusted publisher side would require the env on the workflow to exist first, which it now does. Adds a changeset to pass the new ci.yml lint. --- .changeset/add-release-environment.md | 5 +++++ .github/workflows/release.yml | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/add-release-environment.md diff --git a/.changeset/add-release-environment.md b/.changeset/add-release-environment.md new file mode 100644 index 0000000..96fd689 --- /dev/null +++ b/.changeset/add-release-environment.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Tag the release job with `environment: release` so the run is recorded as a deployment to the `release` GitHub environment. Future hardening (required reviewers, branch restrictions, wait timer) can attach to the same environment without further workflow changes. Provenance and trusted publishing are unaffected. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95cd5f8..4ebd652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,7 @@ jobs: release: name: Release runs-on: ubuntu-latest + environment: release # Run when a PR is merged into main OR manually triggered if: | github.event_name == 'workflow_dispatch' || From 5f55ce116060957c406a5e311804fe8d6147b105 Mon Sep 17 00:00:00 2001 From: martyy-code Date: Mon, 3 Aug 2026 13:38:11 +0200 Subject: [PATCH 11/11] docs(release): document post-plan additions (trusted publishing, environment) Adds Section 7 (Trusted publishing & environment) to the plan and a matching Appendix C (Post-plan decision log). Two items are added to the Definition of done: - At least one release has been published via npm trusted publishing (OIDC), per Section 7.1. - The GitHub 'release' environment exists with at least one deployment record, per Section 7.2. Plan Status also moves from 'Proposed' to 'Approved and partially implemented on staging' because Phases 3, 4, 5 plus Section 7 are now merged on the staging branch (PRs #40, #41, #42). --- .changeset/docs-plan-trusted-publishing.md | 5 ++ .../engineering/plans/release-system.md | 90 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 .changeset/docs-plan-trusted-publishing.md diff --git a/.changeset/docs-plan-trusted-publishing.md b/.changeset/docs-plan-trusted-publishing.md new file mode 100644 index 0000000..dbf33c0 --- /dev/null +++ b/.changeset/docs-plan-trusted-publishing.md @@ -0,0 +1,5 @@ +--- +"@deessejs/errors": patch +--- + +Update `docs/internal/engineering/plans/release-system.md` to reflect the post-plan additions: `Section 7 — Trusted publishing & environment` documents npm OIDC trusted publishing and the `release` GitHub environment. `Appendix C — Post-plan decision log` captures the new decisions. `Definition of done` adds two new items for OIDC publish and env record. `Status` moves from "Proposed" to "Approved and partially implemented on `staging`". diff --git a/docs/internal/engineering/plans/release-system.md b/docs/internal/engineering/plans/release-system.md index 1d9fc86..c77e6a6 100644 --- a/docs/internal/engineering/plans/release-system.md +++ b/docs/internal/engineering/plans/release-system.md @@ -2,7 +2,7 @@ ## Status -🎯 **Proposed** — awaiting release engineer approval. +✅ **Approved and partially implemented on `staging`** — Phases 3, 4, 5 + Sections 7.1–7.2 are merged. Awaiting cherry-pick to `main` and the first clean release (Phase 2). ## Background @@ -383,6 +383,8 @@ All four open questions are resolved: - [ ] At least one release under the new system has been verified on npm and on GitHub. - [ ] The release runbook (Section 6) is filled in with concrete values (who, when, where). - [ ] All four open questions are resolved (see Section "Open questions"). +- [ ] At least one release has been published via npm trusted publishing (OIDC) — see Section 7.1. +- [ ] The GitHub `release` environment exists with at least one deployment record — see Section 7.2. ## Appendix A — Inventory checklist (Phase 0) @@ -418,3 +420,89 @@ Confirmed at the time of the inventory (commit `569c96d` on `main`): | TBD | The CI lint does NOT run on PRs to `main` (those are cherry-picks or hotfixes) | Avoid false positives on rebase commits | | TBD | Archive `dev` | `dev` is unused in the current flow | | TBD | Update `CLAUDE.md` and `CONTRIBUTING.md` to describe the actual branching model | End the documentation drift | + +## 7. Trusted publishing & environment (post-plan additions) + +This section was added after the initial plan was validated. It captures two security and observability improvements that build on the foundation laid by Phases 3–6. + +### 7.1 — npm trusted publishing (OIDC) + +The release workflow no longer relies on `secrets.NPM_TOKEN`. Publishing uses **npm trusted publishing**, an OIDC-based trust relationship between npm and GitHub Actions. The job's existing `id-token: write` permission is the only requirement on the workflow side. + +**What npmjs.com requires:** + +On https://www.npmjs.com/package/@deessejs/errors → Settings → Trusted publishing → Add GitHub publisher: + +- Organization or user: `deessejs` +- Repository: `errors` +- Workflow filename: `release.yml` +- Environment name: left blank for now. The trusted publisher binds to the workflow file only. +- Allowed actions: `npm publish` + +**What the workflow changed:** + +The `Publish packages` step no longer passes `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`. `pnpm changeset publish` uses the OIDC token minted by GitHub automatically. The actual diff: + +```yaml +# before +- name: Publish packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm changeset publish + +# after +- name: Publish packages + run: pnpm changeset publish +``` + +`permissions: id-token: write` was already declared on the job, so nothing else needed to change. + +**Effects:** + +- Long-lived secret removed from GitHub. `secrets.NPM_TOKEN` can be revoked after the first OIDC publish succeeds. +- npm automatically generates provenance attestations on every publish from a public repo. No `--provenance` flag needed. The package page on npmjs.com displays a provenance badge. +- Publishing is now scoped to _this_ workflow file in _this_ repo. A leaked workflow file or token from elsewhere cannot publish. + +**Recommended hardening (post-OIDC-validated):** + +1. On npmjs.com: Settings → Publishing access → **"Require two-factor authentication and disallow tokens"**. This revokes any remaining token-based publish access. The OIDC trusted publisher is unaffected. +2. On GitHub: repository Settings → Secrets → remove `NPM_TOKEN`. + +### 7.2 — GitHub environment `release` + +The release job is tagged with `environment: release`. Every run creates a deployment record, visible in the GitHub Deployments API and the GitHub UI's environment timeline. + +```yaml +jobs: + release: + environment: release +``` + +No protection rules are attached yet — the trigger stays label-less / fire-on-every-merge. The environment is the natural place to harden later without changing this workflow: + +| Lever | Effect | Trade-off | +| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Required reviewers | Job waits for human approval before publishing | Adds a gate; incompatible with fire-on-every-merge automation | +| Wait timer | Cooling-off period (e.g. 5 min) before publishing | Lets a release engineer `git push --delete` the tag if they realize they were wrong | +| Deployment branches | Only `main` can trigger the env | Static guard against accidental publishes from another branch | +| Environment secrets / vars | Scoped secrets, only available to jobs that target the env | Last-line defense if a leaked secret still exists | + +Decisions on these levers are deferred until the team grows or until we have a concrete safety incident. The environment exists today and is ready. + +### 7.3 — What this added to the PR sequence + +| PR | Title | Adds | +| --- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| #39 | `docs(release): add release system plan` | The plan itself (Phases 0–6) | +| #40 | `ci(release): add changesets lint on PRs to staging` (and the workflow rewrite and the docs update, in a stacked commit) | Phase 4 (lint), Phase 3 (workflow rewrite), Phase 5 (docs) | +| #41 | `ci(release): remove 'version bump' label gate, fire on every merge to main` | Section 3 (label-less trigger) | +| #42 | `ci(release): switch publish step to npm trusted publishing (OIDC)` and tag with `environment: release` | Section 7 (this section) | + +## Appendix C — Post-plan decision log + +| Date | Decision | Rationale | +| ---- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| TBD | Drop `NODE_AUTH_TOKEN` from release.yml; rely on npm trusted publishing via `id-token: write` | Removes a long-lived secret from GitHub; automatic provenance; binding to a specific workflow | +| TBD | Tag the release job with `environment: release` (no protection rules yet) | Establishes an audit trail via the Deployments API and a place to hang future hardening | +| TBD | Trusted publisher on npmjs.com binds to workflow filename only, not environment name | Keeps the contract minimal; environment name can be added later if we want it in the trust match | +| TBD | Do not enable `disallow tokens` on npmjs.com until one OIDC release is validated end-to-end | Reduces risk during the migration window |