diff --git a/.changeset/README.md b/.changeset/README.md index b824f58..989f03b 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -7,8 +7,8 @@ versioning (`pnpm version -r`). One file per change, authored with: pnpm change --bump --summary "" [...] ``` -- 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 319cd6b..9955fbc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: @@ -75,6 +83,7 @@ updates: - "@effect/*" - "@commitlint/*" - "typescript" + - "@types/node" update-types: ["minor", "patch"] major-updates: patterns: ["*"] @@ -83,6 +92,7 @@ updates: - "@oxlint/*" - "@commitlint/*" - "typescript" + - "@types/node" update-types: ["major"] - package-ecosystem: "nix" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145de97..c973932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/force-release.yml b/.github/workflows/force-release.yml index 1279a87..984c882 100644 --- a/.github/workflows/force-release.yml +++ b/.github/workflows/force-release.yml @@ -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 @@ -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" < ".changeset/force-${{ github.run_id }}-$BUMP.md" <(): `). Ensure the working tree is clean and `pnpm check:ci` passes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d198a16 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 +cd +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. diff --git a/docs/solutions/tooling-decisions/pnpm-owns-the-changeset-ledger.md b/docs/solutions/tooling-decisions/pnpm-owns-the-changeset-ledger.md new file mode 100644 index 0000000..6968a7f --- /dev/null +++ b/docs/solutions/tooling-decisions/pnpm-owns-the-changeset-ledger.md @@ -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. diff --git a/flake.lock b/flake.lock index 6e3e8a9..915614b 100644 --- a/flake.lock +++ b/flake.lock @@ -21,6 +21,27 @@ "type": "github" } }, + "importPnpmLock": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1786037097, + "narHash": "sha256-k3Z/oMLgS4OdCRJlakczcM1Rq2O+l67g0EChInZOYWk=", + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "rev": "4bd9cc54e6a5431930b4d09898e1ef49cb2ed241", + "type": "github" + }, + "original": { + "owner": "Scrumplex", + "repo": "importPnpmLock.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1788881743, @@ -40,6 +61,7 @@ "root": { "inputs": { "comment-checker": "comment-checker", + "importPnpmLock": "importPnpmLock", "nixpkgs": "nixpkgs" } }, @@ -63,6 +85,21 @@ "repo": "rust-overlay", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 6ec0c81..f466999 100644 --- a/flake.nix +++ b/flake.nix @@ -7,9 +7,17 @@ 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}); @@ -17,6 +25,7 @@ { 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; }; diff --git a/package.json b/package.json index 75c13b1..e3834fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/starter/package.json b/packages/starter/package.json index f867b62..b272de8 100644 --- a/packages/starter/package.json +++ b/packages/starter/package.json @@ -2,19 +2,21 @@ "author": "TODO ", "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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24069f7..4d8ec16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,87 +4,159 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@commitlint/cli': + specifier: ^21.2.1 + version: 21.2.2 + '@commitlint/config-conventional': + specifier: ^21.2.0 + version: 21.2.2 + '@commitlint/types': + specifier: ^21.2.0 + version: 21.2.0 + '@systemfsoftware/all': + specifier: ^1.1.3 + version: 1.1.3 + '@systemfsoftware/effect-gherkin-spec': + specifier: ^4.0.1 + version: 4.0.1 + '@systemfsoftware/stryker-js-cli': + specifier: ^6.0.0 + version: 6.0.0 + '@systemfsoftware/stryker-js-typescript-checker': + specifier: ^5.0.0 + version: 5.0.0 + '@systemfsoftware/stryker-js-vitest-runner': + specifier: ^4.0.0 + version: 4.0.0 + '@systemfsoftware/stryker-plugins': + specifier: ^3.0.0 + version: 3.0.0 + '@systemfsoftware/stryker-test-contribution': + specifier: ^2.0.0 + version: 2.0.0 + '@systemfsoftware/tsconfig': + specifier: ^1.3.3 + version: 1.3.3 + '@types/node': + specifier: ^24 + version: 24.13.3 + effect: + specifier: ^4.0.0-rc.112 + version: 4.0.0-rc.112 + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^17.1.0 + version: 17.5.0 + oxlint: + specifier: ^1.77.0 + version: 1.82.0 + oxlint-tsgolint: + specifier: 7.0.2001 + version: 7.0.2001 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + tsdown: + specifier: ^0.23.0 + version: 0.23.0 + turbo: + specifier: ^2.10.12 + version: 2.10.12 + typescript: + specifier: ^7 + version: 7.0.2 + vitest: + specifier: ^4 + version: 4.1.11 + importers: .: devDependencies: '@commitlint/cli': - specifier: ^21.2.1 + specifier: 'catalog:' version: 21.2.2(@types/node@24.13.3)(conventional-commits-parser@7.1.2)(typescript@7.0.2) '@commitlint/config-conventional': - specifier: ^21.2.0 + specifier: 'catalog:' version: 21.2.2 '@commitlint/types': - specifier: ^21.2.0 + specifier: 'catalog:' version: 21.2.0 - '@systemfsoftware/all': - specifier: ^1.1.3 - version: 1.1.3(effect@4.0.0-rc.112)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) '@systemfsoftware/tsconfig': - specifier: ^1.3.3 + specifier: 'catalog:' version: 1.3.3 '@types/node': - specifier: ^24 + specifier: 'catalog:' version: 24.13.3 husky: - specifier: ^9.1.7 + specifier: 'catalog:' version: 9.1.7 lint-staged: - specifier: ^17.1.0 + specifier: 'catalog:' version: 17.5.0 oxlint: - specifier: ^1.77.0 + specifier: 'catalog:' version: 1.82.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: - specifier: 7.0.2001 + specifier: 'catalog:' version: 7.0.2001 turbo: - specifier: ^2.10.12 + specifier: 'catalog:' version: 2.10.12 typescript: - specifier: ^7 + specifier: 'catalog:' version: 7.0.2 packages/starter: devDependencies: + '@systemfsoftware/all': + specifier: 'catalog:' + version: 1.1.3(effect@4.0.0-rc.112)(oxlint-tsgolint@7.0.2001)(oxlint@1.82.0(oxlint-tsgolint@7.0.2001))(typescript@7.0.2) '@systemfsoftware/effect-gherkin-spec': - specifier: ^4.0.1 + specifier: 'catalog:' version: 4.0.1(@effect/vitest@4.0.0-rc.112(effect@4.0.0-rc.112)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.6.1)(yaml@2.9.0))))(effect@4.0.0-rc.112)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.6.1)(yaml@2.9.0))) '@systemfsoftware/stryker-js-cli': - specifier: ^6.0.0 + specifier: 'catalog:' version: 6.0.0(redis@6.2.1) '@systemfsoftware/stryker-js-typescript-checker': - specifier: ^5.0.0 + specifier: 'catalog:' version: 5.0.0 '@systemfsoftware/stryker-js-vitest-runner': - specifier: ^4.0.0 + specifier: 'catalog:' version: 4.0.0(effect@4.0.0-rc.112)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.6.1)(yaml@2.9.0))) '@systemfsoftware/stryker-plugins': - specifier: ^3.0.0 + specifier: 'catalog:' version: 3.0.0(@systemfsoftware/stryker-js@2.0.0)(effect@4.0.0-rc.112)(typescript@7.0.2) '@systemfsoftware/stryker-test-contribution': - specifier: ^2.0.0 + specifier: 'catalog:' version: 2.0.0(effect@4.0.0-rc.112) '@systemfsoftware/tsconfig': - specifier: ^1.3.3 + specifier: 'catalog:' version: 1.3.3 '@types/node': - specifier: ^24 + specifier: 'catalog:' version: 24.13.3 effect: - specifier: ^4.0.0-rc.112 + specifier: 'catalog:' version: 4.0.0-rc.112 + oxlint: + specifier: 'catalog:' + version: 1.82.0(oxlint-tsgolint@7.0.2001) rimraf: - specifier: ^6.1.3 + specifier: 'catalog:' version: 6.1.3 tsdown: - specifier: ^0.23.0 + specifier: 'catalog:' version: 0.23.0(typescript@7.0.2) typescript: - specifier: ^7 + specifier: 'catalog:' version: 7.0.2 vitest: - specifier: ^4 + specifier: 'catalog:' version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.6.1)(yaml@2.9.0)) packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 959afb5..c13efe3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,30 @@ allowBuilds: onnxruntime-node: false protobufjs: false sharp: false +catalog: + "@commitlint/cli": ^21.2.1 + "@commitlint/config-conventional": ^21.2.0 + "@commitlint/types": ^21.2.0 + "@systemfsoftware/all": ^1.1.3 + "@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 + husky: ^9.1.7 + lint-staged: ^17.1.0 + oxlint: ^1.77.0 + oxlint-tsgolint: 7.0.2001 + rimraf: ^6.1.3 + tsdown: ^0.23.0 + turbo: ^2.10.12 + typescript: ^7 + vitest: ^4 +catalogMode: prefer packages: + - apps/* - packages/* diff --git a/scripts/check-changeset.ts b/scripts/check-changeset.ts index a69ac2e..0975762 100755 --- a/scripts/check-changeset.ts +++ b/scripts/check-changeset.ts @@ -17,7 +17,7 @@ const intentPackages = (markdown: string) => { const publicPackages = async () => { const names: string[] = [] - for await (const file of expandGlob('packages/*/package.json')) { + for await (const file of expandGlob('{apps,packages}/*/package.json')) { const pkg = JSON.parse(await Deno.readTextFile(file.path)) as { name?: string version?: string @@ -44,7 +44,8 @@ if (!baseSha) { } const changed = (await run('git', ['diff', '--name-only', `${baseSha}...HEAD`])).split('\n').filter(Boolean) -const touched = changed.some((file) => file === 'packages' || file.startsWith('packages/')) +const WORKSPACE_ROOTS = ['apps', 'packages'] as const +const touched = changed.some((file) => WORKSPACE_ROOTS.some((root) => file === root || file.startsWith(`${root}/`))) ? await publicPackages() : [] const missing = withoutAll(touched, await namedIntents()) diff --git a/scripts/deno.json b/scripts/deno.json index baadff9..1b8c3dd 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -9,6 +9,7 @@ "@std/front-matter": "jsr:@std/front-matter@1", "@std/fs/expand-glob": "jsr:@std/fs@1/expand-glob", "@std/path": "jsr:@std/path@1", + "@std/yaml": "jsr:@std/yaml@1", "octokit": "npm:octokit@^4" } } diff --git a/scripts/deno.lock b/scripts/deno.lock index 5fb7206..dac5be2 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -10,6 +10,7 @@ "jsr:@std/path@1": "1.1.6", "jsr:@std/path@^1.1.5": "1.1.6", "jsr:@std/toml@^1.0.3": "1.0.11", + "jsr:@std/yaml@1": "1.2.0", "jsr:@std/yaml@^1.0.5": "1.2.0", "npm:octokit@4": "4.1.4" }, @@ -24,7 +25,7 @@ "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", "dependencies": [ "jsr:@std/toml", - "jsr:@std/yaml" + "jsr:@std/yaml@^1.0.5" ] }, "@std/fs@1.0.24": { @@ -291,6 +292,7 @@ "jsr:@std/front-matter@1", "jsr:@std/fs@1", "jsr:@std/path@1", + "jsr:@std/yaml@1", "npm:octokit@4" ] } diff --git a/scripts/lib/cycle.ts b/scripts/lib/cycle.ts index f043bcf..b00eee1 100644 --- a/scripts/lib/cycle.ts +++ b/scripts/lib/cycle.ts @@ -14,41 +14,37 @@ type Pkg = { private?: boolean } -export const loadWorkspaceCycle = async (): Promise => { +type Released = { name: string; version: string } + +const publicPackages = async (): Promise => { const pkgs = JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[] - const remote = new Set( - (await run('git', ['ls-remote', '--tags', 'origin'])) - .split('\n') - .filter(Boolean) - .map((line) => line.replace(/.*refs\/tags\//, '').replace(/\^\{\}$/, '')), - ) - const cycle: CycleEntry[] = [] - for (const pkg of pkgs) { - if (!pkg.name || !pkg.version || pkg.private) continue - const tag = `${pkg.name}@v${pkg.version}` - if (remote.has(tag)) continue - cycle.push({ - name: pkg.name, - version: pkg.version, - tag, - changelog: join('.changeset', 'changelogs', `${pkg.name.replace('/', '!')}@${pkg.version}.md`), - }) - } - return cycle + return pkgs + .filter((pkg): pkg is Pkg & Released => Boolean(pkg.name && pkg.version) && !pkg.private) + .map(({ name, version }) => ({ name, version })) +} + +const isPublished = async (name: string, version: string): Promise => { + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`) + if (res.status === 404) return false + if (!res.ok) throw new Error(`registry returned ${res.status} for ${name}@${version}`) + return true +} + +export const unpublishedOf = async (items: T[]): Promise => { + const published = await Promise.all(items.map(({ name, version }) => isPublished(name, version))) + return items.filter((_, i) => !published[i]) } +export const loadWorkspaceCycle = async (): Promise => + (await unpublishedOf(await publicPackages())).map(({ name, version }) => ({ + name, + version, + tag: `${name}@v${version}`, + changelog: join('.changeset', 'changelogs', `${name.replace('/', '!')}@${version}.md`), + })) + export const loadCaptured = async (path: string): Promise => { const raw: unknown = JSON.parse(await Deno.readTextFile(path)) if (!Array.isArray(raw)) throw new Error('captured file must be a JSON array') return raw as CycleEntry[] } - -export const unpublishedOf = async (cycle: CycleEntry[]) => { - const published = await Promise.all( - cycle.map(async (entry) => { - const res = await fetch(`https://registry.npmjs.org/${entry.name.replace('/', '%2F')}/${entry.version}`) - return res.ok - }), - ) - return cycle.filter((_, i) => !published[i]) -} diff --git a/scripts/lib/pending-intents.ts b/scripts/lib/pending-intents.ts new file mode 100644 index 0000000..972c644 --- /dev/null +++ b/scripts/lib/pending-intents.ts @@ -0,0 +1,35 @@ +import { expandGlob } from '@std/fs/expand-glob' +import { basename, join } from '@std/path' +import { parse } from '@std/yaml' + +const consumedIntentStems = (ledgerYaml: string): Set => { + const parsed = parse(ledgerYaml) + const stems = new Set() + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return stems + for (const value of Object.values(parsed)) { + let intents: unknown + if (Array.isArray(value)) intents = value + else if (value !== null && typeof value === 'object' && 'intents' in value) intents = value.intents + else continue + if (!Array.isArray(intents)) continue + for (const intent of intents) { + if (typeof intent === 'string') stems.add(intent) + } + } + return stems +} + +export const countPendingIntents = async (changesetDir: string): Promise => { + let consumed = new Set() + try { + consumed = consumedIntentStems(await Deno.readTextFile(join(changesetDir, 'ledger.yaml'))) + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error + } + let pending = 0 + for await (const entry of expandGlob(join(changesetDir, '*.md'))) { + const stem = basename(entry.path, '.md') + if (stem !== 'README' && !consumed.has(stem)) pending++ + } + return pending +} diff --git a/scripts/open-release-pr.sh b/scripts/open-release-pr.sh index 8300e9c..b2ebe9c 100755 --- a/scripts/open-release-pr.sh +++ b/scripts/open-release-pr.sh @@ -7,18 +7,30 @@ set -euo pipefail existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') -if [ -z "$(git status --porcelain)" ]; then - echo "no pending change intents — nothing to release" +git fetch --quiet origin "$BASE" + +close_if_open() { if [ -n "$existing" ]; then - gh pr close "$existing" --delete-branch --comment "No pending change intents remain." + gh pr close "$existing" --delete-branch --comment "$1" fi +} + +if [ -z "$(git status --porcelain)" ]; then + echo "no pending change intents — nothing to release" + close_if_open "No pending change intents remain." + exit 0 +fi + +if [ -z "$(git diff --name-only "origin/$BASE" -- 'apps/**/package.json' 'packages/**/package.json')" ]; then + echo "no package.json version bumps against origin/$BASE — not opening a release PR" + close_if_open "No package version bumps against $BASE." exit 0 fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git switch --force-create "$BRANCH" -git add -A -- packages .changeset pnpm-lock.yaml +git add -A -- apps packages .changeset pnpm-lock.yaml git commit -m 'chore(release): version packages' git push --force origin "$BRANCH" diff --git a/scripts/plan-release.ts b/scripts/plan-release.ts index ecf83ec..a836c04 100755 --- a/scripts/plan-release.ts +++ b/scripts/plan-release.ts @@ -1,14 +1,10 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git,pnpm --allow-import --allow-net=jsr.io +#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git,pnpm --allow-import --allow-net=jsr.io,registry.npmjs.org import { parseArgs } from '@std/cli/parse-args' -import { expandGlob } from '@std/fs/expand-glob' -import { basename } from '@std/path' import { loadWorkspaceCycle } from './lib/cycle.ts' +import { countPendingIntents } from './lib/pending-intents.ts' -let pending = 0 -for await (const entry of expandGlob('.changeset/*.md')) { - if (basename(entry.path) !== 'README.md') pending++ -} +const pending = await countPendingIntents('.changeset') const owed = (await loadWorkspaceCycle()).length const phase = owed > 0 ? 'publish' : pending > 0 ? 'version' : 'none' diff --git a/scripts/tag-released-packages.ts b/scripts/tag-released-packages.ts index d4a5e37..082447d 100755 --- a/scripts/tag-released-packages.ts +++ b/scripts/tag-released-packages.ts @@ -10,7 +10,7 @@ const flags = parseArgs(Deno.args, { }) const loaded = flags.captured ? await loadCaptured(flags.captured) : await loadWorkspaceCycle() -const cycle = flags.unpublished ? await unpublishedOf(loaded) : loaded +const cycle = flags.captured && flags.unpublished ? await unpublishedOf(loaded) : loaded if (flags.publish) { if (cycle.length === 0) { diff --git a/turbo.json b/turbo.json index 2cc9447..f01a6eb 100644 --- a/turbo.json +++ b/turbo.json @@ -103,7 +103,8 @@ ], "outputs": [], "dependsOn": [ - "^build" + "^build", + "build" ], "env": [ "NODE_ENV",