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
4 changes: 2 additions & 2 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ versioning (`pnpm version -r`). One file per change, authored with:
pnpm change --bump <none|patch|minor|major> --summary "<changelog entry>" [<pkg>...]
```

- A PR that changes anything under `packages/` MUST ship with an intent here.
Root tooling is outside the verdict.
- A PR that changes anything under an application or package path MUST ship with
an intent here. Root tooling is outside the verdict.
- `--bump none` records a change that needs no release. A `none` on a
behavior-visible change is the same silent non-release the gate exists to
catch.
Expand Down
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ updates:
patterns:
- "typescript"
update-types: ["minor", "patch"]
types-node-minor-patch:
patterns:
- "@types/node"
update-types: ["minor", "patch"]
types-node-major:
patterns:
- "@types/node"
update-types: ["major"]
other-minor-patch:
patterns: ["*"]
exclude-patterns:
Expand All @@ -75,6 +83,7 @@ updates:
- "@effect/*"
- "@commitlint/*"
- "typescript"
- "@types/node"
update-types: ["minor", "patch"]
major-updates:
patterns: ["*"]
Expand All @@ -83,6 +92,7 @@ updates:
- "@oxlint/*"
- "@commitlint/*"
- "typescript"
- "@types/node"
update-types: ["major"]

- package-ecosystem: "nix"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ jobs:
uses: actions/cache@v6
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-checks-${{ github.sha }}
key: turbo-${{ runner.os }}-dist-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-checks-
turbo-${{ runner.os }}-dist-
turbo-${{ runner.os }}-
# Root package.json `check:ci` is the one definition. Never re-enumerate its steps here.
- name: Gate
Expand Down
16 changes: 12 additions & 4 deletions .github/workflows/force-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ name: Force Release
on:
workflow_dispatch:
inputs:
package:
description: "Package to force release for (TODO: your package name)"
required: true
default: "@TODO/starter"
type: string
bump:
description: "Release bump for @TODO/starter (TODO: your package name)"
description: "Release bump (patch, minor, major)"
required: true
default: patch
type: choice
Expand All @@ -28,13 +33,16 @@ jobs:
with:
fetch-depth: 0
- name: Write dummy changeset
env:
PACKAGE: ${{ inputs.package }}
BUMP: ${{ inputs.bump }}
run: |
cat > ".changeset/force-${{ github.run_id }}-${{ inputs.bump }}.md" <<EOF
cat > ".changeset/force-${{ github.run_id }}-$BUMP.md" <<EOF
---
"@TODO/starter": ${{ inputs.bump }}
"$PACKAGE": $BUMP
---

chore(release): force ${{ inputs.bump }} release via workflow dispatch
chore(release): force $BUMP release for $PACKAGE via workflow dispatch
EOF
- name: Commit, push, and open PR
env:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ reports/
.mcp.json
.turbo/
.direnv/
result
result-*
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Starter template for TypeScript / Effect libraries and tools.
| `START-3` | All test suites pass | `pnpm test` |
| `START-4` | Full CI validation passes before completion | `pnpm check:ci` |

Workspace roots: `packages/` holds libraries, `apps/` holds publishable
applications — both are workspace globs in `pnpm-workspace.yaml`. Turbo declares
`dist/**` as each package's build output; `pnpm gate:dist` runs that build.

## End of Session

Commit changes using conventional commits (`<type>(<scope>): <subject>`). Ensure the working tree is clean and `pnpm check:ci` passes.
50 changes: 50 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Contributing

Contributions are welcome. Follow these instructions to set up the development environment, run tests, and open pull requests.

## Prerequisites

- [Node.js](https://nodejs.org/) `>=24`
- [pnpm](https://pnpm.io/) `>=11.21.0` (the `packageManager` field pins the exact version; Corepack resolves it)

## Setup

Clone the repository and install dependencies:

```bash
git clone <your-repo-url>
cd <your-repo>
pnpm install
```

## Workflows and Commands

The project uses [Turbo](https://turbo.build/) to orchestrate tasks across workspaces:

```bash
# Build all packages
pnpm build

# Run unit and integration tests
pnpm test

# Typecheck workspace packages
pnpm typecheck

# Check code formatting with dprint
pnpm format:check

# Format files with dprint
pnpm format

# Run linter across packages
pnpm lint

# Run all CI gates locally
pnpm check:ci
```

## Pull Requests & Commits

- We follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat(cli): ...`, `fix(core): ...`).
- Ensure all CI gates (`pnpm check:ci`) pass locally before opening a pull request.
87 changes: 87 additions & 0 deletions docs/solutions/tooling-decisions/pnpm-owns-the-changeset-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
title: pnpm owns the change-intent ledger, so an intent file is not a pending release
date: 2026-09-12
category: tooling-decisions
module: release-pipeline
problem_type: tooling_decision
component: tooling
severity: medium
applies_when:
- "Changing how the release phase is derived from .changeset state"
- "Changing the pending-intent count or the ledger parser"
- "Debugging a version-packages pull request that opened with nothing to release"
tags: [change-intent, ledger, release, pnpm, versioning, phantom-pr]
---

# pnpm owns the change-intent ledger, so an intent file is not a pending release

## Context

The release planner derives a phase from two numbers: the size of the release set — workspace versions the registry does not yet serve — and how many change intents are pending. The phase decides which job runs: publish, version, or nothing. `plan-release` (invoked as `./scripts/plan-release.ts`) prints it; the Release workflow routes on it.

The trap is the second number. `pnpm version -r` **retains** the intent files after it consumes them, so counting `.changeset/*.md` answers "how many intent files exist", never "how many are pending". Read that way, every release leaves the pipeline in the version phase forever, and each push to the default branch opens a `version-packages` pull request that deletes files the previous run already consumed.

## Guidance

Treat `.changeset/ledger.yaml` as the record of consumption, and count only intents whose stem is absent from it (`countPendingIntents`). Two properties of the ledger decide whether that count is right:

- **pnpm writes it.** `pnpm change` authors the intent; `pnpm version -r` consumes it and renders the ledger (`render_ledger` / `readLedger` in pnpm). No production code in this repository writes the file — the parser's own tests write throwaway copies in a temp directory — so a change that adds a ledger reader adds no writer.
- **A bare, null-parsing key means an empty intent list.** A ledger entry's intents appear as a mapping (`dir:` plus `intents: [...]`), a sequence, or a key whose value parses as YAML null. Null is a release that consumed nothing — not an entry the parser failed to read. Contributing no stems is exactly what "empty" means; treating it as unparsed invents intents.

`isPublished` owns the registry probe that sizes the release set. `openReleasePr` — the release-PR shell entry point — holds the second line of defence: it refuses to open a release PR when no `apps/**/package.json` or `packages/**/package.json` differs from the base branch, so a miscount cannot by itself produce an empty release PR.

## Why This Matters

The ledger makes consumption a fact recorded beside the intent, so an intent file's presence stops implying a release is owed. Inverting that — treating the surviving file as evidence — reintroduces a phantom release PR on every cycle, and the failure is quiet: the pipeline reports a normal phase and opens a normal-looking PR.

## Architectural Invariants

**Release-set membership is a registry fact, not a version-control fact.** A package leaves the release set when its version is published, never when a branch advances or a tag is written. Tags are written downstream of the publish that would prove them, so a detector reading tag absence cannot make its own precondition true.

**A failed probe is a third outcome, never a "no".** The registry probe has three results — published, unpublished, and cannot-tell. Folding cannot-tell into unpublished reclassifies a published package as owed a release. `isPublished` therefore returns false only on an explicit 404 and throws on any other non-OK response.

**A parse failure must degrade toward "nothing consumed".** An unreadable ledger yields zero consumed stems, so more intents look pending than are. That errs toward the version phase, which the version-bump guard catches. The opposite default errs toward phase `none`, which silently skips a release — never choose it. Any new ledger shape must sit on the conservative side of this line.

**Two independent guards, not one.** The pending count and the version-bump guard answer different questions — "is an intent unrecorded?" and "did a version actually change?" A change may not weaken either on the assumption that the other covers it; the phantom PR returns if both are argued from the same signal.

## When to Apply

- Changing the phase expression in the release planner, or the pending count feeding it.
- Adding a ledger shape to the parser. A shape that fails to parse must degrade to "nothing consumed", never to "consumed".
- Explaining why `.changeset/` still holds intent files after a release landed.

## Examples

Counting files, which never reaches zero:

```ts
let pending = 0
for await (const entry of expandGlob('.changeset/*.md')) {
if (basename(entry.path) !== 'README.md') pending++
}
```

Counting intents the ledger does not record as consumed:

```ts
const pending = await countPendingIntents('.changeset')
```

A ledger the parse must distinguish: one consumed intent, one still pending.

```yaml
"@scope/pkg@1.0.1":
dir: packages/pkg
intents:
- twenty-vans-prove
```

## Prevention

- Keep a parser test for every ledger shape the reader tolerates, including the bare/null entry that means "empty".
- Keep the phase derivation testable without a registry: feed a stubbed release set and a stubbed pending count, and assert the phase.
- Keep the version-bump guard in the release-PR entry point. It is what makes a miscount survivable.

## Related

- `pnpm/pnpm#13125` — the bare-`intents:` null form, and why both pnpm stacks accept it on read.
37 changes: 37 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@
url = "github:systemfsoftware/comment-checker";
inputs.nixpkgs.follows = "nixpkgs";
};
# Hashless pnpm store: each lockfile integrity is the fetch hash.
# fetchPnpmDeps needs a second store-wide hash that Dependabot cannot update.
# A package built from this workspace takes this overlay to get
# `importPnpmLock` and `iplConfigHook` into its `callPackage` arguments.
importPnpmLock = {
url = "github:Scrumplex/importPnpmLock.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};

outputs = { self, nixpkgs, comment-checker }:
outputs = { self, nixpkgs, comment-checker, importPnpmLock }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
in
{
packages = forEachSystem (pkgs:
let
pkgs' = pkgs.extend importPnpmLock.overlays.default;
dprint = pkgs.callPackage ./nix/dprint.nix { };
cc = comment-checker.packages.${pkgs.system}.comment-checker;
comment-checker-bwrap = pkgs.callPackage ./nix/comment-checker-bwrap.nix { comment-checker = cc; };
Expand Down
23 changes: 11 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
{
"devDependencies": {
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@commitlint/types": "^21.2.0",
"@systemfsoftware/all": "^1.1.3",
"@systemfsoftware/tsconfig": "^1.3.3",
"@types/node": "^24",
"husky": "^9.1.7",
"lint-staged": "^17.1.0",
"oxlint": "^1.77.0",
"oxlint-tsgolint": "7.0.2001",
"turbo": "^2.10.12",
"typescript": "^7"
"@commitlint/cli": "catalog:",
"@commitlint/config-conventional": "catalog:",
"@commitlint/types": "catalog:",
"@systemfsoftware/tsconfig": "catalog:",
"@types/node": "catalog:",
"husky": "catalog:",
"lint-staged": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:"
},
"license": "Apache-2.0",
"name": "starter",
Expand Down
28 changes: 15 additions & 13 deletions packages/starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
"author": "TODO <todo@example.com>",
"description": "TODO: one-line description",
"devDependencies": {
"@systemfsoftware/effect-gherkin-spec": "^4.0.1",
"@systemfsoftware/stryker-js-cli": "^6.0.0",
"@systemfsoftware/stryker-js-typescript-checker": "^5.0.0",
"@systemfsoftware/stryker-js-vitest-runner": "^4.0.0",
"@systemfsoftware/stryker-plugins": "^3.0.0",
"@systemfsoftware/stryker-test-contribution": "^2.0.0",
"@systemfsoftware/tsconfig": "^1.3.3",
"@types/node": "^24",
"effect": "^4.0.0-rc.112",
"rimraf": "^6.1.3",
"tsdown": "^0.23.0",
"typescript": "^7",
"vitest": "^4"
"@systemfsoftware/all": "catalog:",
"@systemfsoftware/effect-gherkin-spec": "catalog:",
"@systemfsoftware/stryker-js-cli": "catalog:",
"@systemfsoftware/stryker-js-typescript-checker": "catalog:",
"@systemfsoftware/stryker-js-vitest-runner": "catalog:",
"@systemfsoftware/stryker-plugins": "catalog:",
"@systemfsoftware/stryker-test-contribution": "catalog:",
"@systemfsoftware/tsconfig": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"oxlint": "catalog:",
"rimraf": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"exports": {
".": { "@systemfsoftware/source": "./src/index.ts", "default": "./dist/index.js" },
Expand Down
Loading