From 2b336957c45c22423359aa82dedd44cf4bcdded5 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 18:04:04 -0400 Subject: [PATCH 1/4] chore(release): consume shared pnpm-release-management toolchain Delete this repo's local release scripts (plan-release, tag-released-packages, create-github-releases, lib/cycle and their helpers) and the bespoke release.yml body; release.yml and changeset-check.yml become thin callers of systemfsoftware/pnpm-release-management reusable workflows pinned to prm/toolchain, which derive the phase from repository state, open the version PR, tag released versions and cut GitHub Releases npm-registry publishing is gone as a consequence: distribution is Nix flakes consumed from git refs, so a @vX.Y.Z git tag is the durable record a version shipped. Changesets, publishConfig and the Nix flake outputs are untouched. Drop the test:scripts task and the two tooling-decision docs that described the deleted local scripts --- .changeset/README.md | 16 +- .github/workflows/changeset-check.yml | 16 +- .github/workflows/release.yml | 102 +----- CONCEPTS.md | 16 +- ...dgered-intents-are-not-pending-releases.md | 59 ---- ...er-tag-signal-frees-cycle-phase-pinning.md | 177 ---------- package.json | 1 - scripts/check-changeset.ts | 65 ---- scripts/create-github-releases.ts | 120 ------- scripts/deno.json | 16 - scripts/deno.lock | 308 ------------------ scripts/lib/cycle.ts | 55 ---- scripts/lib/pending-intents.property.test.ts | 113 ------- scripts/lib/pending-intents.ts | 36 -- scripts/lib/run.ts | 14 - scripts/open-release-pr.sh | 70 ---- scripts/plan-release.ts | 17 - scripts/tag-released-packages.ts | 62 ---- turbo.json | 4 - 19 files changed, 26 insertions(+), 1241 deletions(-) delete mode 100644 docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md delete mode 100644 docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md delete mode 100755 scripts/check-changeset.ts delete mode 100755 scripts/create-github-releases.ts delete mode 100644 scripts/deno.json delete mode 100644 scripts/deno.lock delete mode 100644 scripts/lib/cycle.ts delete mode 100644 scripts/lib/pending-intents.property.test.ts delete mode 100644 scripts/lib/pending-intents.ts delete mode 100644 scripts/lib/run.ts delete mode 100755 scripts/open-release-pr.sh delete mode 100755 scripts/plan-release.ts delete mode 100755 scripts/tag-released-packages.ts diff --git a/.changeset/README.md b/.changeset/README.md index ac166f5..82e9d48 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -18,7 +18,15 @@ pnpm change --bump --summary "" [ - This README is NOT a changeset: the gate requires a file whose frontmatter parses as `"": `. -Publishing uses npm OIDC trusted publishing from `.github/workflows/release.yml`. -Register `@systemfsoftware/arethetypeswrong-cli` and `@systemfsoftware/arethetypeswrong` as trusted publishers on npmjs.com -pointing at this repository and that workflow filename before the first new -version can ship. OIDC cannot debut a package npm has never seen. +The release pipeline is the shared toolchain in +`systemfsoftware/pnpm-release-management`, consumed as a reusable workflow +(`.github/workflows/release.yml` and `changeset-check.yml` are thin callers +pinned to its `prm/toolchain` ref). On a push to `main` it opens or updates the +version PR when intents are pending, and otherwise tags each released version +`@vX.Y.Z` and cuts a GitHub Release from its authored changelog. A version +with no such tag is what the pipeline treats as owed a release. + +Distribution is Nix flakes consumed from git refs, not an npm registry: the git +tag, pinned downstream by a consumer's `flake.lock` rev + narHash, is the +durable record that a version shipped. There is no registry step to debut a +package. diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 59ab9b6..a664360 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -9,13 +9,9 @@ permissions: jobs: require-changeset: - if: github.head_ref != 'changeset-release/main' - name: a publishable-package change needs a changeset - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: denoland/setup-deno@v2 - - name: Require a changeset for publishable-package changes - run: ./scripts/check-changeset.ts ${{ github.event.pull_request.base.sha }} + uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@prm/toolchain + with: + tools-ref: prm/toolchain + permissions: + contents: read + pull-requests: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15f74be..99c9a2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,104 +13,10 @@ permissions: pull-requests: write jobs: - plan: - name: plan · derive phase - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - phase: ${{ steps.plan.outputs.phase }} - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Preflight intent consumption - run: pnpm version -r --dry-run - - id: plan - name: Decide release phase from repository state - run: ./scripts/plan-release.ts --output "$GITHUB_OUTPUT" - - version: - if: needs.plan.outputs.phase == 'version' - needs: [plan] - name: version · open release PR - runs-on: ubuntu-latest + release: + uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@prm/toolchain + with: + tools-ref: prm/toolchain permissions: contents: write pull-requests: write - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Consume pending change intents - run: pnpm version -r - - name: Capture release set - run: ./scripts/tag-released-packages.ts --dry-run --json --output /tmp/captured.json && cat /tmp/captured.json - - name: Assert release notes - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --assert --captured /tmp/captured.json - - name: Open or update the Release PR - env: - GH_TOKEN: ${{ github.token }} - BRANCH: changeset-release/main - BASE: ${{ github.event.repository.default_branch }} - run: ./scripts/open-release-pr.sh - - publish: - if: needs.plan.outputs.phase == 'publish' - permissions: - contents: write - id-token: write - name: publish · oidc · tag - needs: [plan] - runs-on: ubuntu-latest - env: - HUSKY: "0" - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - uses: denoland/setup-deno@v2 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Capture release set - run: ./scripts/tag-released-packages.ts --dry-run --json --output /tmp/captured.json - - name: Assert release notes - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --assert --captured /tmp/captured.json - - name: Build - run: pnpm build - - name: Publish unpublished versions via OIDC - run: ./scripts/tag-released-packages.ts --unpublished --captured /tmp/captured.json --publish - - name: Tag released versions - run: ./scripts/tag-released-packages.ts --captured /tmp/captured.json - - name: GitHub Releases from authored changelogs - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/create-github-releases.ts --captured /tmp/captured.json diff --git a/CONCEPTS.md b/CONCEPTS.md index 92e8313..ba9387b 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -16,26 +16,18 @@ _Avoid:_ changeset — the file format is a changeset, but the concept here is t ### Release set -The workspace packages owed a release in the current run — those whose manifest version is not yet served by the package registry. Membership is a fact about the registry, not about version control: a package leaves the set when its version is published, never when a branch advances or a tag is written. +The workspace packages owed a release in the current run — those whose manifest version carries no `@vX.Y.Z` git tag yet. Membership is a git fact: a package leaves the set when its tag is written (which the shared release pipeline does as it tags the version and cuts its GitHub Release), not when a branch advances. There is no registry to probe — Nix flakes consumed from git refs are the distribution, so the tag is the record that a version shipped. ### Release phase -The stage the release pipeline decides it is in, derived rather than configured. `publish` when the release set is non-empty; `version` when nothing is owed but unconsumed change intents remain; `none` when neither holds. Each phase gates a distinct job, so a phase derived from a wrong signal skips work silently rather than failing. An intent file that still exists after consumption is recorded is not pending. +The stage the release pipeline decides it is in, derived rather than configured. `release` when the release set is non-empty (tag the untagged versions and cut their GitHub Releases); `version` when nothing is owed but unconsumed change intents remain; `none` when neither holds. The shared toolchain derives the phase from repository state on each push to `main`, so a half-finished release resumes on the next push. An intent file that still exists after consumption is recorded is not pending. -### Published version +### Released version -A version the package registry serves for a package. The registry is the authority on this: neither a git tag nor a changelog file establishes it, and both are written downstream of a successful publish. - -### Git tag as release evidence - -Rejected as a release signal. Tags are written _after_ the step that a missing tag would cause to fail, so a detector reading tag absence cannot make its own precondition true. Recorded here because the term still appears in the pipeline's history and in older workflow steps. +A version that carries its `@vX.Y.Z` git tag. The tag is the authority on this: the shared release pipeline writes it as it tags the version and cuts the GitHub Release, and a consumer pins it downstream through `flake.lock` (rev + narHash). An untagged manifest version is owed a release; a tagged one is done. Tagging and the GitHub Release are both idempotent on tag existence, so a half-finished release resumes safely on the next push to main. ## Registry resolution -### Registry probe - -A query against the package registry asking whether a given package version is published. It has three outcomes, not two: published, unpublished, and _cannot tell_. The last is a failure, never a "no" — folding it into unpublished reclassifies a published package as owed a release. - ### Scoped name A package name carrying a scope, written `@scope/name`. A scoped name is a single path segment in a registry URL, so the scope separator must be percent-encoded rather than left literal. diff --git a/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md b/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md deleted file mode 100644 index 05c95a5..0000000 --- a/docs/solutions/tooling-decisions/ledgered-intents-are-not-pending-releases.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Ledgered leftover intents are not a pending release -date: 2026-09-11 -category: tooling-decisions -module: arethetypeswrong -problem_type: logic_error -component: tooling -severity: high -symptoms: - - "GitHub Actions opens chore(release): version packages PRs that only delete leftover .changeset files" - - "the PR three-dot-diffs files that no longer exist on main" - - "plan-release prints pending_intents>0 this_cycle=0 -> phase=version after a published cycle" -root_cause: logic_error -resolution_type: code_fix -related_components: - - scripts/plan-release.ts - - scripts/lib/pending-intents.ts - - scripts/open-release-pr.sh -tags: [release, changeset, ledger, phantom-pr, github-actions] ---- - -# Ledgered leftover intents are not a pending release - -## Problem - -After a real version PR publishes, leftover `.changeset/*.md` intent files (retained per README, recorded in `ledger.yaml`) retrigger `phase=version`. The version job then opens a PR that only deletes those files and the authored changelogs. A queued run on a stale SHA reopens the same empty tree as a new PR. - -## Symptoms - -- `plan-release: pending_intents=3 this_cycle=0 -> phase=version` on a tree whose intents are already in `ledger.yaml` -- Version job logs `No pending changes. Record one with "pnpm change"` then still commits deletions -- GitHub three-dot diff lists files already absent from `main`; two-dot vs `main` is empty - -## What Didn't Work - -- Closing the phantom PR. The next `push` to `main` with leftover files, or a queued Release run (`cancel-in-progress: false`), opened another -- Merging the deletion PR (#21). That removed the files from `main` but a stale job checked out the parent SHA and recreated `changeset-release/main` - -## Solution - -Count pending intents as `.changeset/*.md` files whose stem is **not** listed in `ledger.yaml`. Parse the ledger as YAML so quoted stems and `intents:` arrays match the on-disk filenames. - -Refuse to open a version PR unless `apps/**/package.json` or `packages/**/package.json` actually differs from `origin/$BASE`. - -## Why This Works - -`.changeset/README.md` already states a present intent file never implies a pending release. `plan-release` was counting files, not unconsumed files. After a published cycle (`this_cycle=0`), leftover files must yield `phase=none`. The package.json guard stops a stale SHA from republishing an already-squash-merged tree even if plan-release regresses. - -## Prevention - -- Gate `phase=version` on unconsumed intents, not on glob presence -- Gate the Release PR on a real version bump against latest `main`, not on a dirty working tree -- Keep a Deno test that a ledgered leftover file is not pending - -## Related Issues - -- Related: #22 (closed phantom PR) -- Related: #21 (merged deletion-only version PR) -- `docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md` — the sister signal that made `this_cycle=0` possible and unmasked this counter diff --git a/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md b/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md deleted file mode 100644 index f09cdc7..0000000 --- a/docs/solutions/tooling-decisions/release-registry-over-tag-signal-frees-cycle-phase-pinning.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: Use the registry as the released-cycle signal; the tag is downstream of it -date: 2026-09-11 -category: tooling-decisions -module: arethetypeswrong -problem_type: workflow_issue -component: tooling -severity: high -symptoms: - - "the Release assert step fails with: Missing changelog for @ — expected .changeset/changelogs/!@.md" - - "the version · open release PR job is skipped on every push to main" - - "origin ends up with zero tags for a workspace whose package versions are already on npm" - - "the cycle phase is pinned to publish forever, so pending changeset intents are never consumed" -root_cause: missing_workflow_step -resolution_type: code_fix -related_components: - - scripts/lib/cycle.ts - - scripts/plan-release.ts - - scripts/tag-released-packages.ts - - scripts/create-github-releases.ts - - .github/workflows/release.yml -tags: [release, github-actions, registry-signal, changeset, cycle-pinning, deno-permissions] ---- - -# Use the registry as the released-cycle signal; the tag is downstream of it - -## Problem - -`loadWorkspaceCycle` decided "released this cycle" from _the absence of a git -tag_, so the set of pending releases could only be emptied by a tag that the -workflow writes after the step the pending set was failing. `origin` had zero -tags, and the two public packages were already served by npm at their manifest -versions (npm registry, not repo paths), so the set stayed permanently -non-empty. - -Boundary: the defect is reachable on any push to `main`, in CI only, and it is -self-locking — the failing gate is the same gate that would have made the signal -true. It is not reproducible from a clean local checkout, because the observed -state depends on remote tag absence. - -## Symptoms - -- The publish job fails at its assert step with - `Missing changelog for @systemfsoftware/arethetypeswrong@7.0.0: expected - .changeset/changelogs/@systemfsoftware!arethetypeswrong@7.0.0.md`, for a - version npm already serves. -- The `version · open release PR` job reports **skipped** on every run, because - `plan-release` prints `this_cycle=2 -> phase=publish` and that job is gated on - `phase == 'version'`. -- `git ls-remote --tags origin` returns nothing. - -## What Didn't Work - -1. **Reading a git tag as evidence of release.** The tag is written _after_ the - gate the missing tag was failing, so the predicate could never become true. - Any detector anchored on a downstream artifact inherits this circularity. -2. **Treating the empty tag list as the anomaly.** Nothing was broken about it: - no tag was ever meant to exist until a publish succeeded, and none had. -3. **Collapsing every non-2xx into "unpublished".** This moved the deadlock - behind an HTTP error — a 429 or 5xx reclassified an already-published package - as in-cycle and re-pinned the phase. -4. (session history) Rebase-after-squash is established _branch_ hygiene here - (PR #7 was rebased onto `origin/main` after its base merged). Applied to - `main` itself it silently discarded this fix once. - -## Solution - -```ts -export 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 loadWorkspaceCycle = async (): Promise => { - const pkgs = await publicPackages() - const published = await Promise.all(pkgs.map(({ name, version }) => isPublished(name, version))) - return pkgs - .filter((_, i) => !published[i]) - .map(({ name, version }) => ({ - name, - version, - tag: `${name}@v${version}`, - changelog: join('.changeset', 'changelogs', `${name.replace('/', '!')}@${version}.md`), - })) -} -``` - -`publicPackages` keeps only manifests that declare a name and version and are not -private. `unpublishedOf` routes through the same `isPublished`, so the boolean -and the error envelope cannot drift between call sites. - -`plan-release` also needs `registry.npmjs.org` in its `--allow-net`: it reaches -`fetch` transitively through this module, and Deno throws `NotCapable` without -the grant — the phase step cannot run at all. `tag-released-packages` and -`create-github-releases` already carried it, because both already queried the -registry before this change. - -## Architectural Invariant - -**Authoritative Truth Source, With a Three-State Probe.** The predicate that -answers "has this manifest version shipped?" must read the authority that owns -the fact, and must not fold its inability to read it into an answer. - -$$\text{isPublished}(n, v) = \begin{cases} -\text{true} & \texttt{GET registry//} \to 200 \\ -\text{false} & \texttt{GET registry//} \to 404 \\ -\text{throw} & \text{otherwise — 429, 5xx, network} -\end{cases}$$ - -Release status is owned by the registry, not by git: the tag is a _consequence_ -of a successful publish, so it can never be the evidence that the publish should -happen. The registry has no such circularity — a version either is served or is -not, independent of whether this pipeline succeeded. - -The three-state split is the second half. A two-state probe must answer _no_ -when it means _I could not tell_, and that is exactly how a published package -re-enters the cycle. `404` is the registry's only "no"; every other outcome is -"cannot tell", and cannot-tell must abort the run rather than feed the phase -machine a false negative. - -The changelog requirement follows from the same split. `pnpm version -r` bumps -each manifest it is consuming an intent for and writes one changelog per bumped -version — observed directly in a scratch clone: given intents for both packages, -it emitted exactly one file per bumped version and none for versions it left -alone. A version already at its manifest value is never bumped, so no changelog -for it can exist, and a detector that lists it as pending has created an -unsatisfiable requirement. - -**Anti-pattern (grep-able):** a release predicate that returns a boolean from a -response whose non-2xx branch is a single `return false` — - -``` -if (!res.ok) return false // ← conflates 404 with 429/5xx -``` - -The compliant shape branches on the status it can interpret and throws on the -rest. This repo already used that shape in `AttwExecutor` against the same -registry, so the fix follows existing convention rather than inventing one. - -## Prevention - -- **Never let a release detector read a downstream artifact.** Tags, GitHub - Releases, and changelog files are written after the gate that consumes them. - Detect from the authority; treat the rest as outputs. -- **Never fold an error envelope into an answer.** Throw on non-404 so a - registry incident fails the run instead of reclassifying. -- **Grant `--allow-net` to every Deno entrypoint that reaches the registry**, - including one-shot scripts: `plan-release` looks harmless but calls - `loadWorkspaceCycle`, and without the grant the phase step cannot run at all. - A script that already queries the registry keeps its grant; a script that - _newly_ gains a transitive registry call needs one added. -- **Confirm a fix is on `origin/main` before claiming it shipped.** A merged PR - is not proof — this fix was squash-merged, then orphaned when `main` was reset - and rebased onto an earlier commit, leaving the PR reading MERGED while the - tree lost the change. Use `git log origin/main`. -- **Test the three-way split, not the truth table.** Stub `fetch` for 200, 404, - 429, 500, 503: `true`, `false`, then throw. Covering only 200 and 404 leaves - the transient path silently reclassifying. -- **Beware `pnpm version -r` deleting a `none` intent.** When another intent - releases a package, `pnpm version -r` garbage-collects a sibling intent that - declared that package `none` — reproducible in a scratch clone: running it - once removed a `none`-for-all-packages intent while leaving the consuming - intents in place. `.changeset/README.md` states intent files are retained, so - the recorded contract and the observed behavior disagree. Treat a `none` file - as at risk if a later intent releases one of the packages it names. - -## Related Issues - -- PR #15 — attempted this fix; squash-merged, then orphaned by a `main` reset - and rebase. The working fix is on branch `fix/release-cycle-registry-v2`. -- `docs/solutions/tooling-decisions/registry-consumption-severs-relocation-build-cycles.md` - — the sister invariant: the registry is also the authority that breaks build - cycles, there to keep the task graph acyclic rather than to detect releases. -- `AGENTS.md` — the boundary table keeps `.github/workflows/**` read-only, which - is why this fix lives in the `scripts/` that drive the workflow. diff --git a/package.json b/package.json index d265d41..6949a53 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "prepare": "husky && effect-tsgo patch --oxlint --no-typescript --skip-missing", "test": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test", "test:e2e": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test:e2e", - "test:scripts": "deno test --allow-read --allow-write --allow-run --config scripts/deno.json scripts/", "typecheck": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue typecheck typecheck:node", "typecheck:node": "tsc -p tsconfig.node.json --noEmit" }, diff --git a/scripts/check-changeset.ts b/scripts/check-changeset.ts deleted file mode 100755 index 0975762..0000000 --- a/scripts/check-changeset.ts +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-run=git --allow-import --allow-net=jsr.io - -import { withoutAll } from '@std/collections/without-all' -import { extractYaml, test } from '@std/front-matter' -import { expandGlob } from '@std/fs/expand-glob' -import { basename } from '@std/path' -import { run } from './lib/run.ts' - -const BUMP: Record = { none: true, patch: true, minor: true, major: true } - -const intentPackages = (markdown: string) => { - if (!test(markdown)) return [] - return Object.entries(extractYaml>(markdown).attrs) - .filter(([, bump]) => typeof bump === 'string' && BUMP[bump]) - .map(([name]) => name) -} - -const publicPackages = async () => { - const names: string[] = [] - for await (const file of expandGlob('{apps,packages}/*/package.json')) { - const pkg = JSON.parse(await Deno.readTextFile(file.path)) as { - name?: string - version?: string - private?: boolean - } - if (pkg.name && pkg.version && !pkg.private) names.push(pkg.name) - } - return names -} - -const namedIntents = async () => { - const named: string[] = [] - for await (const file of expandGlob('.changeset/*.md')) { - if (basename(file.path) === 'README.md') continue - named.push(...intentPackages(await Deno.readTextFile(file.path))) - } - return named -} - -const baseSha = Deno.args[0] -if (!baseSha) { - console.error('usage: ./scripts/check-changeset.ts ') - Deno.exit(2) -} - -const changed = (await run('git', ['diff', '--name-only', `${baseSha}...HEAD`])).split('\n').filter(Boolean) -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()) - -if (missing.length === 0) { - console.log( - touched.length === 0 ? 'no publishable-package paths in the diff' : `changeset covers: ${touched.join(', ')}`, - ) - Deno.exit(0) -} - -console.error( - `::error::publishable package(s) changed with no changeset intent: ${ - missing.join(', ') - }. Author one with \`pnpm change --bump --summary "" ${missing[0]}\`.`, -) -Deno.exit(1) diff --git a/scripts/create-github-releases.ts b/scripts/create-github-releases.ts deleted file mode 100755 index 7cc5654..0000000 --- a/scripts/create-github-releases.ts +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git --allow-net=api.github.com,registry.npmjs.org --allow-env=GH_TOKEN,GITHUB_TOKEN,GITHUB_REPOSITORY --allow-import - -import { parseArgs } from '@std/cli/parse-args' -import { Octokit, RequestError } from 'octokit' -import { type CycleEntry, loadCaptured, loadWorkspaceCycle } from './lib/cycle.ts' -import { run } from './lib/run.ts' - -const flags = parseArgs(Deno.args, { - boolean: ['dry-run', 'assert'], - string: ['captured'], -}) - -const cycle: CycleEntry[] = flags.captured ? await loadCaptured(flags.captured) : await loadWorkspaceCycle() - -if (cycle.length === 0) { - console.log('no this-cycle releases — empty captured set') - Deno.exit(0) -} - -const pending: { entry: CycleEntry; body: string }[] = [] -for (const entry of cycle) { - const { name, version, changelog } = entry - let raw: string | null = null - try { - raw = await Deno.readTextFile(changelog) - } catch { - raw = null - } - if (raw === null || raw.trim().length === 0) { - const state = raw === null ? 'Missing' : 'Empty' - console.error( - `::error::${state} changelog for ${name}@${version}: expected ${changelog} — body must be the pnpm-generated changelog.`, - ) - Deno.exit(1) - } - pending.push({ entry, body: raw.trim() }) -} - -if (flags.assert) { - console.log(`assert ok: ${cycle.length} changelog(s) present`) - Deno.exit(0) -} - -if (flags['dry-run']) { - for (const { entry } of pending) { - console.log(`would create release ${entry.tag} from ${entry.changelog}`) - } - console.log(`dry run: ${pending.length} release(s)`) - Deno.exit(0) -} - -const slug = Deno.env.get('GITHUB_REPOSITORY') ?? (await run('git', ['remote', 'get-url', 'origin'])) - .trim() - .replace(/^git@github\.com:/, 'https://github.com/') - .replace(/^https?:\/\/github\.com\//, '') - .replace(/\.git$/, '') -const [owner, repo] = slug.split('/') -const octokit = new Octokit({ auth: Deno.env.get('GITHUB_TOKEN') ?? Deno.env.get('GH_TOKEN') ?? undefined }) - -const created: { tag: string; id: number }[] = [] -let loopError: Error | null = null -for (const { entry, body } of pending) { - const { tag } = entry - let exists = false - try { - await octokit.rest.repos.getReleaseByTag({ owner, repo, tag }) - exists = true - } catch (error) { - if (!(error instanceof RequestError) || error.status !== 404) { - loopError = new Error( - `looking up ${tag} in ${owner}/${repo} failed: ${error instanceof Error ? error.message : String(error)}`, - ) - break - } - } - if (exists) { - console.log(`skip ${tag} — release exists`) - continue - } - try { - const res = await octokit.rest.repos.createRelease({ - owner, - repo, - tag_name: tag, - body, - prerelease: false, - make_latest: 'false', - }) - console.log(`created release ${tag}`) - created.push({ tag, id: res.data.id }) - } catch (error) { - if (error instanceof RequestError && error.status === 409) { - console.log(`skip ${tag} — release exists`) - continue - } - loopError = new Error( - `creating release ${tag} failed: ${error instanceof Error ? error.message : String(error)}`, - ) - break - } -} - -if (created.length > 0 && !loopError) { - try { - await octokit.rest.repos.updateRelease({ owner, repo, release_id: created[0].id, make_latest: 'true' }) - console.log(`reconciled make_latest true on ${created[0].tag}`) - } catch (error) { - const msg = `reconciling make_latest for ${created[0].tag} failed: ${ - error instanceof Error ? error.message : String(error) - }` - console.error(`::error::${msg}`) - loopError = new Error(msg) - } -} - -if (loopError) { - console.error(`::error::${loopError.message}`) - Deno.exit(1) -} -console.log(`created ${created.length} release(s), skipped ${cycle.length - created.length}`) diff --git a/scripts/deno.json b/scripts/deno.json deleted file mode 100644 index 4e2bbf9..0000000 --- a/scripts/deno.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "noImplicitOverride": true - }, - "imports": { - "@std/cli/parse-args": "jsr:@std/cli@1/parse-args", - "@std/collections/without-all": "jsr:@std/collections@1/without-all", - "@std/front-matter": "jsr:@std/front-matter@1", - "@std/fs/expand-glob": "jsr:@std/fs@1/expand-glob", - "@std/assert/equals": "jsr:@std/assert@1/equals", - "@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 deleted file mode 100644 index 8efb95e..0000000 --- a/scripts/deno.lock +++ /dev/null @@ -1,308 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.19", - "jsr:@std/cli@1": "1.0.32", - "jsr:@std/collections@1": "1.3.0", - "jsr:@std/collections@^1.1.3": "1.3.0", - "jsr:@std/front-matter@1": "1.0.9", - "jsr:@std/fs@1": "1.0.24", - "jsr:@std/internal@^1.0.12": "1.0.14", - "jsr:@std/internal@^1.0.14": "1.0.14", - "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" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/cli@1.0.32": { - "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66" - }, - "@std/collections@1.3.0": { - "integrity": "eb36b43d784477ea0b476483ac034a14bdd182aff921c812ecf662a1fcef9498" - }, - "@std/front-matter@1.0.9": { - "integrity": "ee6201d06674cbef137dda2252f62477450b48249e7d8d9ab57a30f85ff6f051", - "dependencies": [ - "jsr:@std/toml", - "jsr:@std/yaml@^1.0.5" - ] - }, - "@std/fs@1.0.24": { - "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", - "dependencies": [ - "jsr:@std/internal@^1.0.14", - "jsr:@std/path@^1.1.5" - ] - }, - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - }, - "@std/path@1.1.6": { - "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", - "dependencies": [ - "jsr:@std/internal@^1.0.14" - ] - }, - "@std/toml@1.0.11": { - "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", - "dependencies": [ - "jsr:@std/collections@^1.1.3" - ] - }, - "@std/yaml@1.2.0": { - "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" - } - }, - "npm": { - "@octokit/app@15.1.6": { - "integrity": "sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw==", - "dependencies": [ - "@octokit/auth-app", - "@octokit/auth-unauthenticated", - "@octokit/core", - "@octokit/oauth-app", - "@octokit/plugin-paginate-rest", - "@octokit/types", - "@octokit/webhooks" - ] - }, - "@octokit/auth-app@7.2.2": { - "integrity": "sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw==", - "dependencies": [ - "@octokit/auth-oauth-app", - "@octokit/auth-oauth-user", - "@octokit/request", - "@octokit/request-error", - "@octokit/types", - "toad-cache", - "universal-github-app-jwt", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-app@8.1.4": { - "integrity": "sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ==", - "dependencies": [ - "@octokit/auth-oauth-device", - "@octokit/auth-oauth-user", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-device@7.1.5": { - "integrity": "sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw==", - "dependencies": [ - "@octokit/oauth-methods", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-oauth-user@5.1.6": { - "integrity": "sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw==", - "dependencies": [ - "@octokit/auth-oauth-device", - "@octokit/oauth-methods", - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/auth-token@5.1.2": { - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==" - }, - "@octokit/auth-unauthenticated@6.1.3": { - "integrity": "sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA==", - "dependencies": [ - "@octokit/request-error", - "@octokit/types" - ] - }, - "@octokit/core@6.1.6": { - "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", - "dependencies": [ - "@octokit/auth-token", - "@octokit/graphql", - "@octokit/request", - "@octokit/request-error", - "@octokit/types", - "before-after-hook", - "universal-user-agent" - ] - }, - "@octokit/endpoint@10.1.4": { - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", - "dependencies": [ - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/graphql@8.2.2": { - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", - "dependencies": [ - "@octokit/request", - "@octokit/types", - "universal-user-agent" - ] - }, - "@octokit/oauth-app@7.1.6": { - "integrity": "sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA==", - "dependencies": [ - "@octokit/auth-oauth-app", - "@octokit/auth-oauth-user", - "@octokit/auth-unauthenticated", - "@octokit/core", - "@octokit/oauth-authorization-url", - "@octokit/oauth-methods", - "@types/aws-lambda", - "universal-user-agent" - ] - }, - "@octokit/oauth-authorization-url@7.1.1": { - "integrity": "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA==" - }, - "@octokit/oauth-methods@5.1.5": { - "integrity": "sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw==", - "dependencies": [ - "@octokit/oauth-authorization-url", - "@octokit/request", - "@octokit/request-error", - "@octokit/types" - ] - }, - "@octokit/openapi-types@25.1.0": { - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==" - }, - "@octokit/openapi-webhooks-types@11.0.0": { - "integrity": "sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA==" - }, - "@octokit/plugin-paginate-graphql@5.2.4_@octokit+core@6.1.6": { - "integrity": "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA==", - "dependencies": [ - "@octokit/core" - ] - }, - "@octokit/plugin-paginate-rest@12.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-MPd6WK1VtZ52lFrgZ0R2FlaoiWllzgqFHaSZxvp72NmoDeZ0m8GeJdg4oB6ctqMTYyrnDYp592Xma21mrgiyDA==", - "dependencies": [ - "@octokit/core", - "@octokit/types" - ] - }, - "@octokit/plugin-rest-endpoint-methods@14.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-iQt6ovem4b7zZYZQtdv+PwgbL5VPq37th1m2x2TdkgimIDJpsi2A6Q/OI/23i/hR6z5mL0EgisNR4dcbmckSZQ==", - "dependencies": [ - "@octokit/core", - "@octokit/types" - ] - }, - "@octokit/plugin-retry@7.2.1_@octokit+core@6.1.6": { - "integrity": "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A==", - "dependencies": [ - "@octokit/core", - "@octokit/request-error", - "@octokit/types", - "bottleneck" - ] - }, - "@octokit/plugin-throttling@10.0.0_@octokit+core@6.1.6": { - "integrity": "sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg==", - "dependencies": [ - "@octokit/core", - "@octokit/types", - "bottleneck" - ] - }, - "@octokit/request-error@6.1.8": { - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "dependencies": [ - "@octokit/types" - ] - }, - "@octokit/request@9.2.4": { - "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", - "dependencies": [ - "@octokit/endpoint", - "@octokit/request-error", - "@octokit/types", - "fast-content-type-parse", - "universal-user-agent" - ] - }, - "@octokit/types@14.1.0": { - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", - "dependencies": [ - "@octokit/openapi-types" - ] - }, - "@octokit/webhooks-methods@5.1.1": { - "integrity": "sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg==" - }, - "@octokit/webhooks@13.9.1": { - "integrity": "sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==", - "dependencies": [ - "@octokit/openapi-webhooks-types", - "@octokit/request-error", - "@octokit/webhooks-methods" - ] - }, - "@types/aws-lambda@8.10.163": { - "integrity": "sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==" - }, - "before-after-hook@3.0.2": { - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" - }, - "bottleneck@2.19.5": { - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" - }, - "fast-content-type-parse@2.0.1": { - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==" - }, - "octokit@4.1.4": { - "integrity": "sha512-cRvxRte6FU3vAHRC9+PMSY3D+mRAs2Rd9emMoqp70UGRvJRM3sbAoim2IXRZNNsf8wVfn4sGxVBHRAP+JBVX/g==", - "dependencies": [ - "@octokit/app", - "@octokit/core", - "@octokit/oauth-app", - "@octokit/plugin-paginate-graphql", - "@octokit/plugin-paginate-rest", - "@octokit/plugin-rest-endpoint-methods", - "@octokit/plugin-retry", - "@octokit/plugin-throttling", - "@octokit/request-error", - "@octokit/types", - "@octokit/webhooks" - ] - }, - "toad-cache@3.7.4": { - "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==" - }, - "universal-github-app-jwt@2.2.2": { - "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==" - }, - "universal-user-agent@7.0.3": { - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1", - "jsr:@std/cli@1", - "jsr:@std/collections@1", - "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 deleted file mode 100644 index 0067d19..0000000 --- a/scripts/lib/cycle.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { join } from '@std/path' -import { run } from './run.ts' - -export type CycleEntry = { - name: string - version: string - tag: string - changelog: string -} - -type Pkg = { - name?: string - version?: string - private?: boolean -} - -type Released = { name: string; version: string } - -const publicPackages = async (): Promise => { - const pkgs = JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[] - return pkgs - .filter((pkg): pkg is Pkg & Released => Boolean(pkg.name && pkg.version) && !pkg.private) - .map(({ name, version }) => ({ name, version })) -} - -export 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 loadWorkspaceCycle = async (): Promise => { - const pkgs = await publicPackages() - const published = await Promise.all(pkgs.map(({ name, version }) => isPublished(name, version))) - return pkgs - .filter((_, i) => !published[i]) - .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(({ name, version }) => isPublished(name, version))) - return cycle.filter((_, i) => !published[i]) -} diff --git a/scripts/lib/pending-intents.property.test.ts b/scripts/lib/pending-intents.property.test.ts deleted file mode 100644 index 103e977..0000000 --- a/scripts/lib/pending-intents.property.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { assertEquals } from '@std/assert/equals' -import { join } from '@std/path' - -import { countPendingIntents } from './pending-intents.ts' - -const readmeStem = 'README' -const intentStems = ['alpha', 'beta', 'gamma'] as const - -type IntentStem = typeof intentStems[number] - -const ledgerShapes = [ - 'absent', - 'emptyObject', - 'topSequence', - 'scalarValue', - 'arrayIntents', - 'nestedIntents', - 'quotedIntents', - 'numericIntents', - 'mixedIntents', -] as const - -type LedgerShape = typeof ledgerShapes[number] - -interface LedgerState { - readonly stems: readonly string[] - readonly shape: LedgerShape - readonly consumed: readonly IntentStem[] -} - -const powerset = (items: readonly T[]): readonly (readonly T[])[] => { - let subsets: readonly (readonly T[])[] = [[]] - for (const item of items) { - subsets = [...subsets, ...subsets.map((subset) => [...subset, item])] - } - return subsets -} - -const fileStemSets = powerset([readmeStem, ...intentStems]) -const consumedStemSets = powerset(intentStems) - -const intentList = (consumed: readonly IntentStem[]): string => consumed.map((stem) => `"${stem}"`).join(', ') - -const consumedLines = (consumed: readonly IntentStem[]): string => consumed.map((stem) => ` - "${stem}"\n`).join('') - -const ledgerYaml = (state: LedgerState): string | undefined => { - const consumed = state.consumed - const shape = state.shape - if (shape === 'absent') return undefined - if (shape === 'emptyObject') return '{}\n' - if (shape === 'topSequence') return consumed.map((stem) => `- ${stem}\n`).join('') - if (shape === 'scalarValue') return '"pkg@1.0.0": 3\n' - if (shape === 'arrayIntents') return `"pkg@1.0.0": [${intentList(consumed)}]\n` - if (shape === 'nestedIntents') { - return `"pkg@1.0.0":\n dir: packages/pkg\n intents: [${intentList(consumed)}]\n` - } - if (shape === 'quotedIntents') return `"pkg@1.0.0":\n intents:\n${consumedLines(consumed)}` - if (shape === 'numericIntents') return '"pkg@1.0.0":\n intents:\n - 1\n - 2\n' - return `"pkg@1.0.0":\n intents:\n${consumedLines(consumed)} - 3\n` -} - -const recordsConsumedIntents = (shape: LedgerShape): boolean => - shape === 'arrayIntents' || shape === 'nestedIntents' || shape === 'quotedIntents' || shape === 'mixedIntents' - -const authoredConsumed = (state: LedgerState): readonly string[] => - recordsConsumedIntents(state.shape) ? state.consumed : [] - -const authoredPending = (state: LedgerState): number => - state.stems.filter((stem) => stem !== readmeStem && !authoredConsumed(state).includes(stem)).length - -const describeState = (state: LedgerState): string => JSON.stringify(state) - -Deno.test('a recorded release excuses exactly the intents it consumed, and no other changeset', async () => { - const root = await Deno.makeTempDir() - try { - let index = 0 - for (const stems of fileStemSets) { - for (const consumed of consumedStemSets) { - for (const shape of ledgerShapes) { - const state: LedgerState = { stems, shape, consumed } - const dir = join(root, `state-${index}`) - index++ - await Deno.mkdir(dir) - for (const stem of stems) { - await Deno.writeTextFile(join(dir, `${stem}.md`), '---\n"pkg": patch\n---\n\nsummary\n') - } - const ledger = ledgerYaml(state) - if (ledger !== undefined) await Deno.writeTextFile(join(dir, 'ledger.yaml'), ledger) - assertEquals(await countPendingIntents(dir), authoredPending(state), describeState(state)) - } - } - } - } finally { - await Deno.remove(root, { recursive: true }) - } -}) - -Deno.test('an unreadable ledger surfaces the parse failure instead of counting silently', async () => { - const dir = await Deno.makeTempDir() - try { - await Deno.writeTextFile(join(dir, `${intentStems[0]}.md`), '---\n"pkg": patch\n---\n\nsummary\n') - await Deno.writeTextFile(join(dir, 'ledger.yaml'), '"pkg@1.0.0": [unclosed\n') - let parseFailed = false - try { - await countPendingIntents(dir) - } catch { - parseFailed = true - } - assertEquals(parseFailed, true) - } finally { - await Deno.remove(dir, { recursive: true }) - } -}) diff --git a/scripts/lib/pending-intents.ts b/scripts/lib/pending-intents.ts deleted file mode 100644 index 47a7294..0000000 --- a/scripts/lib/pending-intents.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 - const ledger = parsed as Record - for (const value of Object.values(ledger)) { - 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/lib/run.ts b/scripts/lib/run.ts deleted file mode 100644 index e7295fa..0000000 --- a/scripts/lib/run.ts +++ /dev/null @@ -1,14 +0,0 @@ -const dec = new TextDecoder() -const enc = new TextEncoder() - -export const run = async (cmd: string, args: string[]) => { - const out = await new Deno.Command(cmd, { args, stdout: 'piped', stderr: 'inherit' }).output() - const stdout = dec.decode(out.stdout) - if (!out.success) { - if (stdout.length > 0) { - Deno.stderr.writeSync(enc.encode(stdout.endsWith('\n') ? stdout : `${stdout}\n`)) - } - throw new Error(`${cmd} ${args.join(' ')} failed (exit ${out.code})\n${stdout}`) - } - return stdout -} diff --git a/scripts/open-release-pr.sh b/scripts/open-release-pr.sh deleted file mode 100755 index b2ebe9c..0000000 --- a/scripts/open-release-pr.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${BRANCH:?}" -: "${BASE:?}" -: "${GH_TOKEN:?}" - -existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') - -git fetch --quiet origin "$BASE" - -close_if_open() { - if [ -n "$existing" ]; then - 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 -- apps packages .changeset pnpm-lock.yaml -git commit -m 'chore(release): version packages' -git push --force origin "$BRANCH" - -body=$(mktemp) -trap 'rm -f "$body"' EXIT -cat > "$body" <<'BODY' -Consumes pending `.changeset/` intents via `pnpm version -r`. - -Merging runs the gate, then builds, publishes (OIDC + provenance), -and tags the changed packages. - -Review every consumed `none` intent before merging — a `none` on a -behavior-visible change is a silent non-release. - -Packages not registered as npm trusted publishers fail at publish -with an OIDC auth error; register them at https://www.npmjs.com -against workflow `release.yml` in this repository. -BODY - -gh label create release \ - --color 0E8A16 \ - --description 'Automated version-packages release PR' \ - --force - -if [ -n "$existing" ]; then - gh pr edit "$existing" \ - --title 'chore(release): version packages' \ - --body-file "$body" \ - --add-label release -else - gh pr create \ - --base "$BASE" \ - --head "$BRANCH" \ - --title 'chore(release): version packages' \ - --body-file "$body" \ - --label release -fi diff --git a/scripts/plan-release.ts b/scripts/plan-release.ts deleted file mode 100755 index a836c04..0000000 --- a/scripts/plan-release.ts +++ /dev/null @@ -1,17 +0,0 @@ -#!/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 { loadWorkspaceCycle } from './lib/cycle.ts' -import { countPendingIntents } from './lib/pending-intents.ts' - -const pending = await countPendingIntents('.changeset') - -const owed = (await loadWorkspaceCycle()).length -const phase = owed > 0 ? 'publish' : pending > 0 ? 'version' : 'none' -const outputs = [`phase=${phase}`, `pending_intents=${pending}`, `this_cycle=${owed}`].join('\n') - -console.error(`plan-release: pending_intents=${pending} this_cycle=${owed} -> phase=${phase}`) - -const { output } = parseArgs(Deno.args, { string: ['output'] }) -if (output) await Deno.writeTextFile(output, `${outputs}\n`, { append: true }) -else console.log(outputs) diff --git a/scripts/tag-released-packages.ts b/scripts/tag-released-packages.ts deleted file mode 100755 index 082447d..0000000 --- a/scripts/tag-released-packages.ts +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-run=git,pnpm --allow-net=jsr.io,registry.npmjs.org --allow-import - -import { parseArgs } from '@std/cli/parse-args' -import { loadCaptured, loadWorkspaceCycle, unpublishedOf } from './lib/cycle.ts' -import { run } from './lib/run.ts' - -const flags = parseArgs(Deno.args, { - boolean: ['dry-run', 'json', 'unpublished', 'publish'], - string: ['output', 'captured'], -}) - -const loaded = flags.captured ? await loadCaptured(flags.captured) : await loadWorkspaceCycle() -const cycle = flags.captured && flags.unpublished ? await unpublishedOf(loaded) : loaded - -if (flags.publish) { - if (cycle.length === 0) { - console.log('every captured version is already on npm — tagging only') - Deno.exit(0) - } - console.log(`publishing ${cycle.map((entry) => `${entry.name}@${entry.version}`).join(', ')}`) - const published = await new Deno.Command('pnpm', { - args: ['publish', '-r', '--provenance', '--access', 'public', '--no-git-checks'], - stdout: 'inherit', - stderr: 'inherit', - }).output() - if (!published.success) { - console.error( - `::error::pnpm publish -r --provenance --access public --no-git-checks failed (exit ${published.code})`, - ) - Deno.exit(published.code || 1) - } - Deno.exit(0) -} - -if (flags.output) { - await Deno.writeTextFile(flags.output, JSON.stringify(cycle, null, 2)) - console.error(`wrote ${cycle.length} captured package(s) to ${flags.output}`) -} - -if (flags.json) { - console.log(JSON.stringify(cycle)) - Deno.exit(0) -} - -if (flags['dry-run'] || flags.output) { - for (const { tag } of cycle) console.log(`would tag ${tag}`) - console.log(`dry run: ${cycle.length} tag(s)`) - Deno.exit(0) -} - -if (cycle.length === 0) { - console.log('no new tags to push') - Deno.exit(0) -} - -const made: string[] = [] -for (const { tag } of cycle) { - await run('git', ['tag', tag]) - made.push(tag) -} -await run('git', ['push', 'origin', ...made.map((t) => `refs/tags/${t}`)]) -console.log(`pushed ${made.length} tag(s): ${made.join(', ')}`) diff --git a/turbo.json b/turbo.json index bae9d99..605bb24 100644 --- a/turbo.json +++ b/turbo.json @@ -133,10 +133,6 @@ "cache": false, "outputLogs": "new-only" }, - "//#test:scripts": { - "cache": false, - "outputLogs": "new-only" - }, "clean": { "cache": false } From c8ba2582fa465ba2b12f8906622e5a4b8853ba54 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 18:11:33 -0400 Subject: [PATCH 2/4] chore(release): add none-bump intent for the tooling-only change The shared changeset gate marks every publishable package touched when root turbo.json and package.json change; a none-bump intent naming both public packages records a touch that releases nothing, which is the gate's canonical script/tooling-only class --- .changeset/unify-release-tooling.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/unify-release-tooling.md diff --git a/.changeset/unify-release-tooling.md b/.changeset/unify-release-tooling.md new file mode 100644 index 0000000..1779081 --- /dev/null +++ b/.changeset/unify-release-tooling.md @@ -0,0 +1,6 @@ +--- +"@systemfsoftware/arethetypeswrong-cli": none +"@systemfsoftware/arethetypeswrong": none +--- + +Release tooling moves to the shared `systemfsoftware/pnpm-release-management` toolchain (CI workflows and root config only); no package code changes and nothing is released From 58916d7d2120d4970e3e80c6d68542dc2a1336f7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 18:18:02 -0400 Subject: [PATCH 3/4] chore(release): add release.jsonc for the shared toolchain The shared pnpm-release-management apps (plan, gate, bump, tag, release) read release.jsonc as their only repo-local input; without it the changeset-check gate and the release workflow both abort with "cannot read config release.jsonc". pnpm versioning for the two independently-versioned public packages, turbo build gate, trunk main, release PR branch changeset-release/main --- release.jsonc | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 release.jsonc diff --git a/release.jsonc b/release.jsonc new file mode 100644 index 0000000..c6c9f61 --- /dev/null +++ b/release.jsonc @@ -0,0 +1,20 @@ +{ + // The release pipeline is the shared systemfsoftware/pnpm-release-management + // toolchain, consumed as a reusable workflow (see .github/workflows/release.yml + // and changeset-check.yml, pinned to prm/toolchain). This config is the only + // repo-local input it reads. Distribution is Nix flakes consumed from git + // refs: a @vX.Y.Z git tag is the durable record a version shipped, so + // there is no npm-registry publish step. + "base": "main", + "branch": "changeset-release/main", + "changesetDir": ".changeset", + "changelogDir": ".changeset/changelogs", + // pnpm-native versioning: the two public packages carry independent versions, + // bumped per package by `pnpm version -r` as it consumes the intents. + "versioning": { "strategy": "pnpm" }, + "gate": { "strategy": "turbo", "task": "build" }, + "pr": { + "title": "chore(release): version packages", + "body": "Consumes the pending .changeset intents.\n\nMerging tags the released versions and creates GitHub releases.", + }, +} From 6df1d99357d4d9aa804948553ac62f283f6055bc Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 18:21:59 -0400 Subject: [PATCH 4/4] chore(release): use the paths gate strategy The shared changeset-check reusable workflow checks out and builds only the release tools, never running pnpm install in the consuming repo, so a turbo-strategy gate aborts with "turbo pin unusable: installed turbo undefined". The paths strategy derives change evidence from the git diff alone and attributes each changed file to the package whose directory contains it, which needs no workspace install --- release.jsonc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/release.jsonc b/release.jsonc index c6c9f61..11bd481 100644 --- a/release.jsonc +++ b/release.jsonc @@ -12,7 +12,12 @@ // pnpm-native versioning: the two public packages carry independent versions, // bumped per package by `pnpm version -r` as it consumes the intents. "versioning": { "strategy": "pnpm" }, - "gate": { "strategy": "turbo", "task": "build" }, + // paths gate: the shared changeset-check workflow does not install this repo's + // dependencies, so a turbo-strategy gate has no turbo binary to pin against. + // Paths evidence reads the git diff and attributes a change to the package + // whose directory contains it — the strategy that works without a workspace + // install. + "gate": { "strategy": "paths" }, "pr": { "title": "chore(release): version packages", "body": "Consumes the pending .changeset intents.\n\nMerging tags the released versions and creates GitHub releases.",