Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
32 changes: 28 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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 <name> 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 `<name>@<version>` 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 <branch>` 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 <name> --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.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>@<version>`, 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.
2 changes: 1 addition & 1 deletion commit-types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
5 changes: 0 additions & 5 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -31,7 +30,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",
Expand All @@ -47,6 +46,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",
Expand Down
Loading