From 2030bc9247bfe57e796370dfaeed6add2a273a51 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:13:43 +0100 Subject: [PATCH 1/5] build: add semantic-release-workspace for per-package releases The orchestrator is an ExaDev package, so it joins minimumReleaseAgeExclude alongside the other ExaDev packages. --- package.json | 1 + pnpm-lock.yaml | 79 +++++++++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + 3 files changed, 81 insertions(+) diff --git a/package.json b/package.json index a8488d0..d6706c5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@eslint/json": "2.1.0", "@eslint/markdown": "8.0.3", "@exadev/eslint-config": "2.36.3", + "@exadev/semantic-release-workspace": "3.1.29", "@semantic-release/changelog": "7.0.0", "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/git": "11.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c21d1c0..0443905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: '@exadev/eslint-config': specifier: 2.36.3 version: 2.36.3(@eslint/json@2.1.0)(@eslint/markdown@8.0.3(supports-color@7.2.0))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) + '@exadev/semantic-release-workspace': + specifier: 3.1.29 + version: 3.1.29(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/git@11.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/github@12.0.10(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/npm@13.2.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)(typescript@6.0.3) '@semantic-release/changelog': specifier: 7.0.0 version: 7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) @@ -893,6 +896,23 @@ packages: eslint-plugin-turbo: optional: true + '@exadev/release-gate@1.0.2': + resolution: {integrity: sha512-ps8sYs4Ky+8QTtsDvafQpvvjcyAgXeWFmjxRWL6zaoMsrfX2ifliIKhLfDf0Xd3td5c6L9DJGAZJOtHRzIDy2Q==} + engines: {node: '>=20'} + + '@exadev/semantic-release-workspace@3.1.29': + resolution: {integrity: sha512-aE5vDOoUFI4HRgoxoUJ7cZSMM1Ggs3qL/u9NzBOXha64lCqPtWTCUe2fuA+pXsqo4JXZ962ZjlyA65m0jTbbng==} + engines: {node: ^22.18 || >=24} + hasBin: true + peerDependencies: + '@semantic-release/changelog': ^7.0.0 + '@semantic-release/commit-analyzer': ^13.0.1 + '@semantic-release/git': ^11.0.1 + '@semantic-release/github': ^12.0.9 + '@semantic-release/npm': ^13.1.5 + '@semantic-release/release-notes-generator': ^14.1.1 + semantic-release: ^25.0.9 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2066,6 +2086,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -2137,6 +2161,10 @@ packages: cosmiconfig: '>=9' typescript: '>=5' + cosmiconfig@10.0.1: + resolution: {integrity: sha512-/zvGoYn0y27QTBmK+0B+oPdEPC4fKGUmJsMTQbDjqph00TrkqfVFW/M+C6ya54sYCgHC6QCwK/mI6djt8cEGHQ==} + engines: {node: ^22.18 || >= 24} + cosmiconfig@9.0.2: resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} engines: {node: '>=14'} @@ -2769,6 +2797,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + hasBin: true + jsdoc-type-pratt-parser@9.2.2: resolution: {integrity: sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==} engines: {node: ^22.22.2 || >=24.15.0} @@ -4007,6 +4039,10 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validate-npm-package-name@8.0.0: + resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -4710,6 +4746,36 @@ snapshots: - oxlint - supports-color + '@exadev/release-gate@1.0.2(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + env-ci: 11.2.0 + semantic-release: 25.0.9(supports-color@7.2.0)(typescript@6.0.3) + semver: 7.8.5 + transitivePeerDependencies: + - kerberos + - supports-color + - typescript + + '@exadev/semantic-release-workspace@3.1.29(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/git@11.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/github@12.0.10(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/npm@13.2.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@exadev/release-gate': 1.0.2(supports-color@7.2.0)(typescript@6.0.3) + '@semantic-release/changelog': 7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) + '@semantic-release/git': 11.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) + '@semantic-release/github': 12.0.10(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) + '@semantic-release/npm': 13.2.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) + commander: 15.0.0 + cosmiconfig: 10.0.1 + semantic-release: 25.0.9(supports-color@7.2.0)(typescript@6.0.3) + tinyglobby: 0.2.17 + validate-npm-package-name: 8.0.0 + yaml: 2.9.1 + transitivePeerDependencies: + - kerberos + - supports-color + - typescript + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5848,6 +5914,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + commander@8.3.0: {} comment-parser@1.4.8: {} @@ -5913,6 +5981,11 @@ snapshots: jiti: 2.6.1 typescript: 6.0.3 + cosmiconfig@10.0.1: + dependencies: + env-paths: 2.2.1 + js-yaml: 5.4.2 + cosmiconfig@9.0.2(typescript@6.0.3): dependencies: env-paths: 2.2.1 @@ -6537,6 +6610,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.4.2: + dependencies: + argparse: 2.0.1 + jsdoc-type-pratt-parser@9.2.2: dependencies: '@types/estree': 1.0.9 @@ -7901,6 +7978,8 @@ snapshots: validate-npm-package-name@5.0.1: {} + validate-npm-package-name@8.0.0: {} + verkit@0.3.2: {} vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a9de00..8bcc59f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,7 @@ minimumReleaseAgeExclude: - "@exadev/eslint-config" - "@exadev/json-compose" - "@exadev/process-graph-*" + - "@exadev/semantic-release-workspace" - process-graph - cosmiconfig-extends - trilean From b242f3468be453656283752b8f18de957f1ed2e0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:16:47 +0100 Subject: [PATCH 2/5] feat(release): configure per-package releases through the workspace orchestrator release-workspace.config.ts replaces release.config.ts and keeps commit-types.ts as the single source for release rules and note sections, now with a test that holds the two in agreement. Release notes sections were previously configured under a `title` key the conventionalcommits preset does not read; they now use `section`, and a type that releases is never hidden. The release script runs the orchestrator, which commits every bump of a run once and pushes it with the tags before anything publishes. --- commit-types.ts | 2 +- knip.config.ts | 5 -- package.json | 2 +- release-workspace.config.ts | 54 ++++++++++++ release-workspace.config.unit.test.ts | 119 ++++++++++++++++++++++++++ release.config.ts | 60 ------------- tsconfig.json | 3 +- vitest.config.ts | 5 +- 8 files changed, 181 insertions(+), 69 deletions(-) create mode 100644 release-workspace.config.ts create mode 100644 release-workspace.config.unit.test.ts delete mode 100644 release.config.ts diff --git a/commit-types.ts b/commit-types.ts index f38f9cc..2c39dad 100644 --- a/commit-types.ts +++ b/commit-types.ts @@ -1,5 +1,5 @@ /** - * The single source of truth for which Conventional Commit types this workspace accepts, and which release level (if any) each one triggers. Both `commitlint.config.ts` and `release.config.ts` import this file so a commit type can never trigger a release without also passing commit-message validation, or the reverse. One definition, not two that could drift apart. + * The single source of truth for which Conventional Commit types this workspace accepts, and which release level (if any) each one triggers. Both `commitlint.config.ts` and `release-workspace.config.ts` import this file so a commit type can never trigger a release without also passing commit-message validation, or the reverse. One definition, not two that could drift apart. */ export interface CommitTypeDefinition { readonly type: string; diff --git a/knip.config.ts b/knip.config.ts index a7e75c8..37306a3 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -26,11 +26,6 @@ const config: KnipConfig = { }, }, ignoreDependencies: [ - // Resolved by semantic-release via the plugin-name strings in release.config.ts's own plugins array, not a static import knip can see. - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - "@semantic-release/npm", - "@semantic-release/github", // Resolved by name via each semantic-release plugin's own `preset: "conventionalcommits"` option string. "conventional-changelog-conventionalcommits", ], diff --git a/package.json b/package.json index d6706c5..62f1510 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:mutation": "turbo run _test:mutation", "deps:lint": "syncpack lint", "deps:fix": "syncpack fix", - "release": "semantic-release", + "release": "semantic-release-workspace release --config release-workspace.config.ts", "prepare": "husky", "_lint": "eslint . --cache --max-warnings 0", "_lint:fix": "eslint . --fix --cache --max-warnings 0", diff --git a/release-workspace.config.ts b/release-workspace.config.ts new file mode 100644 index 0000000..4ff59ac --- /dev/null +++ b/release-workspace.config.ts @@ -0,0 +1,54 @@ +import type { ReleaseWorkspaceOptions } from "@exadev/semantic-release-workspace"; +import { commitTypes } from "./commit-types.ts"; + +/** + * Independent per-package releases through `@exadev/semantic-release-workspace`, invoked as `pnpm release`. Each publishable package gets its own version, `@` tag, CHANGELOG.md and GitHub Release, decided from the commits that touched its own directory, and a package is released after every sibling it depends on. + * + * The commit types and which of them release come from `commit-types.ts`, the same list `commitlint.config.ts` validates against, so a type cannot trigger a release without also passing commit-message validation, or the reverse. + * + * The orchestrator sets the tag format, plugins and analysis options on every per-package run itself, so a `release.config.*` inside a package would be overridden by construction: release configuration lives only in this file. + * + * `commitStrategy: "single"` folds every version bump, changelog and dependency-range rewrite of one run into one commit and pushes it with the tags atomically before anything publishes, so a rejected push leaves nothing half-released. It is why `@semantic-release/git` is absent from the plugin list: the orchestrator makes that commit itself and rejects the plugin in this mode. + */ + +/** + * commit-analyzer only falls back to its default rules, which include "a breaking change is a major release", when no custom rule matches a commit. A breaking `feat` matches `feat -> minor` here, so without this rule it would release as a minor version. + */ +const releaseRules = [ + { breaking: true, release: "major" }, + ...commitTypes + .filter((entry) => entry.release !== false) + .map((entry) => ({ type: entry.type, release: entry.release })), +]; + +/** + * Every commit type gets its section name. A type that earns no release is hidden from the notes, which is the conventionalcommits preset's own default for those types, stated here so that a type earning a release can never be hidden by omission. + */ +const noteTypes = commitTypes.map((entry) => ({ + type: entry.type, + section: entry.section, + hidden: entry.release === false, +})); + +const config: Pick< + ReleaseWorkspaceOptions, + "branches" | "commitStrategy" | "plugins" | "analyzeCommits" | "generateNotes" +> = { + branches: ["main"], + commitStrategy: "single", + plugins: [ + "@semantic-release/changelog", + ["@semantic-release/npm", { npmPublish: true }], + "@semantic-release/github", + ], + analyzeCommits: { + preset: "conventionalcommits", + releaseRules, + }, + generateNotes: { + preset: "conventionalcommits", + presetConfig: { types: noteTypes }, + }, +}; + +export default config; diff --git a/release-workspace.config.unit.test.ts b/release-workspace.config.unit.test.ts new file mode 100644 index 0000000..d01a5be --- /dev/null +++ b/release-workspace.config.unit.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { commitTypes } from "./commit-types.ts"; +import config from "./release-workspace.config.ts"; + +/** + * release-workspace.config.ts derives its release rules and its release-notes sections from `commit-types.ts`, and commitlint.config.ts validates against the same list. These tests make the agreement mechanical. + * + * `analyzeCommits` and `generateNotes` are typed as `Record` in `@exadev/semantic-release-workspace`, because they pass straight through to plugins whose option shapes it does not model, so the runtime narrowing below is load-bearing: TypeScript cannot enforce the agreement. + */ + +interface TypedEntry { + readonly type: string; + readonly [key: string]: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** An entry keyed by commit type. The `{ breaking: true }` release rule has no type and is deliberately not matched: "breaking" is a commit footer, never a type written in a subject line. */ +function isTypedEntry(value: unknown): value is TypedEntry { + return isRecord(value) && typeof value.type === "string"; +} + +function requireRecord(value: unknown, path: string): Record { + if (!isRecord(value)) { + throw new Error( + `release-workspace.config.ts must define ${path} as an object`, + ); + } + + return value; +} + +function requireArray(value: unknown, path: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new Error( + `release-workspace.config.ts must define ${path} as an array`, + ); + } + + return value; +} + +const analyzeCommits = requireRecord(config.analyzeCommits, "analyzeCommits"); +const generateNotes = requireRecord(config.generateNotes, "generateNotes"); +const presetConfig = requireRecord( + generateNotes.presetConfig, + "generateNotes.presetConfig", +); + +const allReleaseRules = requireArray( + analyzeCommits.releaseRules, + "analyzeCommits.releaseRules", +); +const releaseRules = allReleaseRules.filter(isTypedEntry); +const noteEntries = requireArray( + presetConfig.types, + "generateNotes.presetConfig.types", +).filter(isTypedEntry); + +const releasingTypes = commitTypes + .filter((entry) => entry.release !== false) + .map((entry) => entry.type); + +describe("release-workspace.config.ts", () => { + it("analyses and renders commits under the same preset", () => { + // A writer is only guaranteed to render the commit shapes its own preset's parser produces. + expect(generateNotes.preset).toBe(analyzeCommits.preset); + }); + + it("releases exactly the commit types commit-types.ts marks as releasing", () => { + expect(releaseRules.map((rule) => rule.type).sort()).toStrictEqual( + [...releasingTypes].sort(), + ); + }); + + it("releases each type at the level commit-types.ts names", () => { + for (const { type, release } of commitTypes) { + if (release === false) { + continue; + } + expect(releaseRules.find((rule) => rule.type === type)?.release).toBe( + release, + ); + } + }); + + it("makes a breaking change a major release", () => { + expect(allReleaseRules).toContainEqual({ + breaking: true, + release: "major", + }); + }); + + it("names a non-empty section for every commit type", () => { + expect(noteEntries.map((entry) => entry.type).sort()).toStrictEqual( + commitTypes.map((entry) => entry.type).sort(), + ); + for (const { type, section } of commitTypes) { + const noteEntry = noteEntries.find((entry) => entry.type === type); + expect(noteEntry?.section, `type "${type}" needs a section name`).toBe( + section, + ); + expect(section, `type "${type}" needs a section name`).not.toBe(""); + } + }); + + it("hides no type that earns a release from the notes", () => { + // The preset hides docs, style, chore, refactor, test, build and ci by default; a type that releases must never inherit that, or its release would publish empty notes. + for (const type of releasingTypes) { + expect( + noteEntries.find((entry) => entry.type === type)?.hidden, + `type "${type}"`, + ).toBe(false); + } + }); +}); diff --git a/release.config.ts b/release.config.ts deleted file mode 100644 index 465a761..0000000 --- a/release.config.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { commitTypes } from "./commit-types.ts"; - -/** - * Plain, whole-repo semantic-release: one shared version and one shared CHANGELOG.md for the entire repository, not independent per-package releases. `@exadev/semantic-release-workspace` (independent per-package releases) is the documented upgrade path in CONTRIBUTING.md once the packages are ready to be published on their own schedules. - * - * npmPublish is false throughout: publishing to the npm registry needs each package's trusted publisher registered first, which is a registry setting made by a person, so releasing here only versions, tags and writes the changelog until that is done. See CONTRIBUTING.md. - */ -/** - * commit-analyzer only falls back to its default rules, which include "a breaking change is a major release", when no custom rule matches a commit. A breaking `feat` matches `feat -> minor` here, so without this rule it would release as a minor version. - */ -const releaseRules = [ - { breaking: true, release: "major" }, - ...commitTypes - .filter((entry) => entry.release !== false) - .map((entry) => ({ type: entry.type, release: entry.release })), -]; - -const presetChangelogTitles = Object.fromEntries( - commitTypes.map((entry) => [entry.type, { title: entry.section }]), -); - -export default { - branches: ["main"], - plugins: [ - [ - "@semantic-release/commit-analyzer", - { - preset: "conventionalcommits", - releaseRules, - }, - ], - [ - "@semantic-release/release-notes-generator", - { - preset: "conventionalcommits", - presetConfig: { - types: Object.entries(presetChangelogTitles).map(([type, value]) => ({ - type, - ...value, - })), - }, - }, - ], - "@semantic-release/changelog", - [ - "@semantic-release/npm", - { - npmPublish: false, - }, - ], - [ - "@semantic-release/git", - { - assets: ["CHANGELOG.md", "package.json"], - message: "chore(release): ${nextRelease.version}", - }, - ], - "@semantic-release/github", - ], -}; diff --git a/tsconfig.json b/tsconfig.json index 0b5cd16..23ef8eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "include": [ "*.config.ts", "commit-types.ts", - "release.config.ts", + "release-workspace.config.ts", + "release-workspace.config.unit.test.ts", "eslint.shared.ts", "stryker.shared.ts", ".github/scripts/**/*.ts" diff --git a/vitest.config.ts b/vitest.config.ts index 85979f7..f552d25 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,10 @@ export default defineConfig({ extends: true, test: { name: "unit", - include: [".github/scripts/**/*.test.ts"], + include: [ + ".github/scripts/**/*.test.ts", + "release-workspace.config.unit.test.ts", + ], }, }, ], From a7e2ae2b5283f531aec9c0664afbb6dc0a1ce6e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:19:34 +0100 Subject: [PATCH 3/5] ci: release each package independently through the workspace orchestrator The release job runs `pnpm release`, which releases packages in dependency order with name@version tags and rewrites sibling pins. It keeps the GITHUB_TOKEN push credential, drops the ambient npm tokens in favour of OIDC trusted publishing, checks out main by name so pushes are not made from a detached HEAD, and upgrades npm to a release that supports trusted publishing. --- .github/workflows/ci.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adf4fe8..9e4ad25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,30 +177,45 @@ jobs: zizmor, ] if: github.ref == 'refs/heads/main' && github.event_name == 'push' + # Two pushes to main close together must never release at the same time: the orchestrator pushes its release commit and tags to main, and semantic-release refuses to publish the moment the remote holds a commit this checkout does not, so a concurrent run's pushes would make every package after that point report "no release". `cancel-in-progress: false` queues the second run behind the first, because cancelling mid-release would leave published packages without their dependents' bumps. concurrency: group: release-workspace cancel-in-progress: false runs-on: ubuntu-latest timeout-minutes: 20 permissions: - contents: write # to push the release commit and tags, and create a GitHub Release + contents: write # to push the release commit and tags, and create a GitHub Release per package issues: write # to comment on released issues pull-requests: write # to comment on released pull requests + id-token: write # OIDC identity for npm trusted publishing (no NPM_TOKEN) steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - persist-credentials: false + # ref: main, not the bare event SHA: the orchestrator pushes to the current branch by name, so a detached HEAD stops the run with a WorkspaceStateError rather than pushing HEAD:HEAD. fetch-depth: 0 because each package's release range is analysed from its own last matching tag. + ref: main fetch-depth: 0 + # Left at its default: the orchestrator pushes its commit and tags with plain `git push origin`, which needs the checkout's credential (this job's GITHUB_TOKEN) to stay in the git config for the duration of the job. + persist-credentials: true - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: standalone: true + # Deliberately no `registry-url`: setting it writes an .npmrc `_authToken` line that wins over the OIDC exchange, so the input that looks like it configures the registry is the one that breaks trusted publishing. - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .tool-versions cache: pnpm - run: pnpm install --frozen-lockfile - - name: Release the whole workspace as one version - # npmPublish: false throughout release.config.ts. This cuts a single shared version, CHANGELOG.md, git tag, and GitHub Release for the whole repository, and never calls `npm publish`. + - name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1) + # Pinned, not npm@latest: a moved dist-tag must not run new code in a job that goes on to hold a push credential. 11.5.1 is the first release with trusted publishing (OIDC); the pin is the newest release at least a week old when it was chosen, matching the install age gate, and its Node engine range includes the version in .tool-versions. Raise it deliberately. + # zizmor's adhoc-packages audit flags any install outside a lockfile; this one is a single exact version, chosen and raised deliberately as the comment above says, so it is ignored here rather than in the whole repository. + run: npm install -g npm@12.1.0 # zizmor: ignore[adhoc-packages] + - name: Release every package that changed, in dependency order + # One orchestrator run discovers the workspace from pnpm-workspace.yaml, orders the packages topologically and runs semantic-release per package, with commits path-filtered to that package's own directory and tags in `name@version` form. A package whose only change is a sibling's release still gets a patch release, because its published dependency range changed. HUSKY=0 so local git hooks never fire against the automated commits. run: HUSKY=0 pnpm release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Provenance for every package the run publishes, matching each package.json's own publishConfig.provenance. + NPM_CONFIG_PROVENANCE: "true" + # Blanked, not omitted: an inherited NPM_TOKEN or NODE_AUTH_TOKEN from a workflow-level env block, reusable workflow or composite action would otherwise be used in preference to the OIDC exchange. + NPM_TOKEN: "" + NODE_AUTH_TOKEN: "" From 3fc450b1db6a3dbda64e00637e00effc0804fe2e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:20:26 +0100 Subject: [PATCH 4/5] docs: describe per-package releases and bootstrapping a package name README.md and CONTRIBUTING.md replace the whole-repository release description with the per-package model: dependency-ordered releases, name@version tags, sibling range rewrites, OIDC publishing, the one-time npm reservation and trusted publisher registration, and what a new package manifest needs. --- CONTRIBUTING.md | 32 ++++++++++++++++++++++++++++---- README.md | 8 +++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ea6fdd..b8aa942 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,11 +9,35 @@ 5. If it is the first package in its layer, add that layer's workspace entry to `knip.config.ts` (an entry that matches nothing is a knip hint). 6. Write its `README.md`, then `ln -s README.md AGENTS.md && ln -s README.md CLAUDE.md`. 7. Add it to the packages table in the root `README.md`, and to the Dependabot `ignore` list only if its name does not already match `@exadev/process-graph-*`. -8. Verify with the repository scripts: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:smoke`, `pnpm exec knip`, `pnpm run deps:lint`. -9. Commit as `feat: add package`, describing the package's role and any deliberate decision, not the process that produced it. +8. Give the manifest what the release orchestrator needs (see [Release strategy](#release-strategy)): `version` `0.0.0`, `repository.directory` set to the package's path, `publishConfig` with `access`, `provenance` and the registry as in its siblings, and each sibling dependency as the sibling's exact version. +9. Reserve the name on npm and register its trusted publisher before its first releasable commit reaches `main` (see [Bootstrapping a package name](#bootstrapping-a-package-name)). +10. Verify with the repository scripts: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:smoke`, `pnpm exec knip`, `pnpm run deps:lint`. +11. Commit as `feat: add package`, describing the package's role and any deliberate decision, not the process that produced it. ## Release strategy -`release.config.ts` runs plain `semantic-release` on every qualifying push to `main`: one shared version, one shared `CHANGELOG.md`, one shared tag and GitHub Release, derived from the whole repository's history. `npmPublish` is false: nothing is published to the npm registry from here yet. +Every publishable package versions and publishes on its own. `release-workspace.config.ts` configures [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), which the `release` job in `.github/workflows/ci.yml` runs as `pnpm release` on every push to `main` once the other jobs have passed. -Publishing is a separate step that needs each package's trusted publisher registered on npm (a registry setting, made by a person) and a first manual publish where npm has no settings page for a never-published name. Independent per-package versions then come from `@exadev/semantic-release-workspace` replacing `release.config.ts`, which keeps `commit-types.ts` as the single source of commit types. +- The orchestrator discovers the packages from `pnpm-workspace.yaml`, orders them by their dependencies on one another and releases each after every sibling it depends on, so the `process-graph` entry point is released after the building-block packages it re-exports. +- A package's release is decided from the commits that touched its own directory, using the types and release levels in `commit-types.ts`. A commit that touches no package directory releases nothing, and a commit scope has no effect on which package releases. +- Each release is a `@` tag, a package-level `CHANGELOG.md` and a GitHub Release. The whole run's version bumps, changelog entries and dependency-range rewrites are committed once and pushed atomically with the tags before anything is published. +- When a package releases, every dependent's sibling range is rewritten to the new version, the lockfile is regenerated and the dependent releases a patch even if nothing else about it changed, so a published manifest never names a version the repository has moved past. Sibling dependencies are exact versions rather than the `workspace:` protocol for this reason: `npm publish` ships a specifier as written. +- Publishing uses npm trusted publishing (OIDC): `id-token: write` on the job, no `NPM_TOKEN`, and no `registry-url` on `setup-node`, because that writes a token line that wins over the OIDC exchange. + +Release configuration lives only in `release-workspace.config.ts`. The orchestrator sets the tag format, plugins and analysis options on every per-package run, so a `release.config.*` inside a package would be overridden rather than honoured. + +To see what a run would do without tagging, committing, pushing or publishing, run `pnpm release --dry-run` from the root on a clean tree and a pushed branch, with `--branches ` naming it and a `GITHUB_TOKEN` in the environment. + +### Bootstrapping a package name + +npm has no trusted publisher settings page for a name that has never been published, so a new package's name is reserved and its trusted publisher registered by a person before CI publishes it for the first time: + +```sh +npm-reserve --trust ExaDev/process-graph --workflow ci.yml +``` + +A first publish from CI under an unregistered name fails the OIDC exchange. The run has already pushed the tag by then, so that version is skipped rather than retried; see the orchestrator's README for recovering from it. + +### Adding a package to releases + +A package joins releases by being a non-private workspace package: no other registration exists. Its manifest carries the fields named in the steps of [Adding a package](#adding-a-package), its first release is `1.0.0` because it has no earlier tag, and a private package is still versioned and tagged but never published. diff --git a/README.md b/README.md index 8ae4e25..2fb7fbf 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config - **Evergreen content.** Docs and comments state what things are and why, never counts, sizes or today's data shape. - **Commits.** Conventional commits, types from `commit-types.ts` (the single source for commitlint and release rules), bodies wrapped at 100 columns, staged by file name. +## Releases + +Each package versions and publishes independently through [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), configured in `release-workspace.config.ts` and run by the `release` job in CI. A package releases when commits touch its own directory, or when a sibling it depends on releases; packages release in dependency order, so the entry point follows the packages it re-exports. Tags are `@`, and each package keeps its own `CHANGELOG.md`. + +A new package name must be reserved on npm, and its trusted publisher registered, by a person before its first CI publish; the steps are in CONTRIBUTING.md. + ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for adding a package and for the release strategy. +See [CONTRIBUTING.md](CONTRIBUTING.md) for adding a package, the release strategy and bootstrapping a package name. From bfb40674053eaf9f1ba17dccd308ced8206239c6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 11:21:24 +0100 Subject: [PATCH 5/5] chore: drop the root manifest version The root package is private and never released, so a version in its manifest only goes stale once releases are decided per package. --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 62f1510..93b9e9b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "process-graph", "private": true, - "version": "1.4.0", "description": "Workspace root for process-graph: a process and dependency graph model, reasoning, CLI and React UI over multiple task sources. Never published; the unscoped process-graph package is a separate workspace package.", "type": "module", "license": "MIT",