diff --git a/.changeset/README.md b/.changeset/README.md
index 96998f7..8b166c7 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -2,13 +2,18 @@
> Release intent store and automation contract for `@systemfsoftware/claude-code-comment-checker`.
-This directory holds change-intent files consumed by the release pipeline on pushes to `master`. Every consumer-observable change must record its intent here so the automated release pipeline can bump package versions, generate changelogs, and publish platform binaries.
+This directory holds change-intent files consumed by the shared release
+toolchain ([systemfsoftware/pnpm-release-management](https://github.com/systemfsoftware/pnpm-release-management))
+on pushes to `master`. Every consumer-observable change must record its intent
+here so the automation can bump the version surfaces, generate changelogs, tag
+the release, and cut its GitHub Release. `release.jsonc` at the repository root
+configures the toolchain.
```mermaid
flowchart TD
- Push[Push to master] --> Plan[plan-release.ts]
- Plan -->|Pending .changeset/*.md| Version[phase: version
release-version.ts updates package.json + CHANGELOG.md
Opens changeset-release/master PR]
- Plan -->|Untagged manifest version| Publish[phase: publish
Builds platform binaries & publishes npm package
Tags Git release vX.Y.Z]
+ Push[Push to master] --> Plan[release plan]
+ Plan -->|Pending .changeset/*.md| Version[phase: version
bump every version surface + write changelogs
Open changeset-release/master PR]
+ Plan -->|Untagged manifest version| Release[phase: release
Tag vX.Y.Z + cut GitHub Release]
Plan -->|No intents & version already tagged| None[phase: none
No-op]
```
@@ -32,21 +37,22 @@ Single paragraph in consumer voice explaining what is now observable or fixed.
## Intent Rules
-- **Scope includes Rust core changes:** A PR that touches the Rust binary (`crates/comment-checker`) **must** include an intent. The crate compiles into the binary executed by the published npm launcher package; a change in the crate is directly observable by the package consumer.
+- **Scope includes Rust core changes:** A PR that touches the Rust binary (`crates/comment-checker`) **must** include an intent. The crate compiles into the binary the launcher spawns; a change in the crate is directly observable by the consumer.
- **Consumer voice:** Describe what the user of the hook or package observes. Never cite internal file paths, pull request numbers, or test names.
-- **Single paragraph body:** The release script ([`scripts/tools/release-version.ts`](../scripts/tools/release-version.ts)) joins all lines in the summary body with spaces into a single changelog bullet item. Do not use multi-paragraph text or markdown sub-bullets.
+- **Single paragraph body:** The toolchain joins all lines in the summary body with spaces into a single changelog bullet. Do not use multi-paragraph text or markdown sub-bullets.
- **`--bump none` for internal maintenance:** Use `none` only when no observable behavior changed (e.g., devDependency bumps, script edits, workflow refactoring).
## Release Pipeline Contract
-Release automation is state-driven and runs on push to `master`:
+Release automation is state-driven and runs on push to `master`; the phase is
+derived from repository state, not from a pull-request ref:
-1. **`phase: version`** — When pending intents exist in `.changeset/`, [`.github/workflows/release.yml`](../.github/workflows/release.yml) executes [`scripts/tools/release-version.ts`](../scripts/tools/release-version.ts), deletes the consumed intents, updates [`npm/packages/comment-checker/package.json`](../npm/packages/comment-checker/package.json) and [`npm/packages/comment-checker/CHANGELOG.md`](../npm/packages/comment-checker/CHANGELOG.md), and creates/updates a release pull request (`changeset-release/master`).
-2. **`phase: publish`** — Merging the release PR updates `package.json` on `master` with an untagged version. The subsequent push to `master` enters the publish phase: `release.yml` builds cross-platform artifacts, attaches provenance attestations, publishes to npm, and creates the GitHub tag `vX.Y.Z`.
+1. **`phase: version`** — When pending intents exist in `.changeset/`, the toolchain bumps every version surface declared in `release.jsonc`, writes the changelogs, deletes the consumed intents, and creates or updates the release pull request (`changeset-release/master`).
+2. **`phase: release`** — Merging the release PR lands an untagged version on `master`. The next push tags `vX.Y.Z` and creates its GitHub Release from the authored changelog. Distribution is this repository's Nix flake (built from source) at the tag — nothing is published to a registry.
3. **`phase: none`** — When all intents are consumed and the current manifest version is already tagged, the pipeline exits clean with nothing to do.
> [!WARNING]
-> Merging a pull request without an intent means `plan-release.ts` sees `phase: none`. The changes land on `master` but will never be published to npm or tagged as a release.
+> Merging a pull request without an intent leaves the plan at `phase: none`. The changes land on `master` but are never tagged or released.
## Contributing
diff --git a/.changeset/unify-release-tooling.md b/.changeset/unify-release-tooling.md
new file mode 100644
index 0000000..70827ca
--- /dev/null
+++ b/.changeset/unify-release-tooling.md
@@ -0,0 +1,5 @@
+---
+"@systemfsoftware/claude-code-comment-checker": none
+---
+
+Move releases onto the shared toolchain and drop npm-registry publishing; no change to the package's observable behavior.
diff --git a/.github/actions/setup-pnpm-node/action.yml b/.github/actions/setup-pnpm-node/action.yml
index 1fc3ce4..eb41b1d 100644
--- a/.github/actions/setup-pnpm-node/action.yml
+++ b/.github/actions/setup-pnpm-node/action.yml
@@ -8,10 +8,6 @@ inputs:
description: Node version
required: false
default: '24'
- registry-url:
- description: npm registry URL for setup-node; omit when empty
- required: false
- default: ''
runs:
using: composite
@@ -21,14 +17,6 @@ runs:
version: 11.21.0
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- if: inputs.registry-url == ''
- with:
- node-version: ${{ inputs.node-version }}
- cache: pnpm
-
- - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
- if: inputs.registry-url != ''
with:
node-version: ${{ inputs.node-version }}
- registry-url: ${{ inputs.registry-url }}
cache: pnpm
diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml
new file mode 100644
index 0000000..1e9b57e
--- /dev/null
+++ b/.github/workflows/changeset-check.yml
@@ -0,0 +1,18 @@
+# Thin caller. A pull request that changes a publishable package must carry a
+# .changeset intent naming it; the shared toolchain
+# (systemfsoftware/pnpm-release-management) enforces that against release.jsonc.
+name: Changeset Check
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ check:
+ uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@prm/toolchain
+ with:
+ tools-ref: prm/toolchain
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a974254..8a3add2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,168 +1,20 @@
-# Release pipeline. Both phases hang off pushes to master and are selected by
-# repository state, never by a pull-request ref.
-#
-# The publish used to trigger on `pull_request: closed` for the release PR.
-# Merging that PR with branch deletion destroys refs/pull//merge, so GitHub
-# cancelled the queued run with zero jobs and nothing published -- the trigger
-# was destroyed by the act of merging, silently. plan-release.ts reads durable
-# state instead: pending .changeset intents mean "version", an untagged
-# manifest version means "publish". A half-finished release resumes on the next
-# push because the missing tag still says "publish".
-#
-# Platform build/stage/bundle lives in platform.yml. This file only decides
-# when to call it.
+# Thin caller. The release lifecycle lives in the shared toolchain
+# (systemfsoftware/pnpm-release-management); this file supplies only the trigger
+# and the permissions. The phase decision, the version bump, the tagging and
+# the GitHub Releases all run inside the reusable workflow against release.jsonc.
+# tools-ref pins the toolchain revision the release runs from.
name: Release
on:
push:
branches: [master]
-concurrency:
- group: release-${{ github.ref }}
- cancel-in-progress: false
-
-permissions: {}
+permissions:
+ contents: write
+ pull-requests: write
jobs:
- plan:
- name: plan
- runs-on: ubuntu-latest
- permissions:
- contents: read
- outputs:
- phase: ${{ steps.plan.outputs.phase }}
- version: ${{ steps.plan.outputs.version }}
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
- fetch-tags: true
-
- - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
-
- - id: plan
- name: Decide release phase from repository state
- run: ./scripts/tools/plan-release.ts >> "$GITHUB_OUTPUT"
-
- version:
- needs: plan
- if: needs.plan.outputs.phase == 'version'
- name: version · open release PR
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: write
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
- # checkout v5 defaults persist-credentials to false; the release PR
- # branch is pushed with plain git, which needs the stored credential.
- persist-credentials: true
-
- - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
-
- - name: Consume pending change intents
- run: ./scripts/tools/release-version.ts
-
- - name: Open or update the Release PR
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: ./scripts/tools/create-or-update-release-pr.ts
-
- rust-gate:
- needs: plan
- if: needs.plan.outputs.phase == 'publish'
- uses: ./.github/workflows/rust-gate.yml
- permissions:
- contents: read
-
- js-gate:
- needs: plan
- if: needs.plan.outputs.phase == 'publish'
- uses: ./.github/workflows/js-gate.yml
- permissions:
- contents: read
-
- tools:
- needs: plan
- if: needs.plan.outputs.phase == 'publish'
- uses: ./.github/workflows/tools.yml
- permissions:
- contents: read
-
- mutation:
- needs: plan
- if: needs.plan.outputs.phase == 'publish'
- uses: ./.github/workflows/mutation.yml
- permissions:
- contents: read
-
release:
- needs: [plan, rust-gate, js-gate, tools, mutation]
- if: needs.plan.outputs.phase == 'publish'
- uses: ./.github/workflows/platform.yml
+ uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@prm/toolchain
with:
- mode: release
- permissions:
- contents: read
-
- publish:
- needs: [plan, release, rust-gate, js-gate, tools, mutation]
- if: needs.plan.outputs.phase == 'publish'
- name: publish · oidc · tag
- runs-on: ubuntu-latest
- permissions:
- contents: write
- pull-requests: write
- id-token: write
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
- # checkout v5 defaults persist-credentials to false; tag-released-packages
- # pushes tags with plain git, which needs the stored credential.
- persist-credentials: true
-
- - uses: ./.github/actions/setup-pnpm-node
- with:
- registry-url: https://registry.npmjs.org
-
- - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2
-
- - name: Preflight — every package must already exist on npm
- run: ./scripts/tools/check-publish.ts --preflight
-
- - name: Build launcher
- run: |
- pnpm install --frozen-lockfile --registry https://registry.npmjs.org
- pnpm -r build
-
- - name: Sync root version
- run: ./scripts/tools/sync-root-version.ts
-
- - name: Publish root launcher
- run: pnpm --filter @systemfsoftware/claude-code-comment-checker publish --provenance --access public --no-git-checks
-
- - name: Download staged platform packages
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- pattern: platform-stage-*
- path: stages
-
- - name: Publish platform packages
- run: ./scripts/tools/publish-platform-stages.ts
-
- - name: Tag released packages
- run: ./scripts/tools/tag-released-packages.ts
-
- - name: Download platform artifacts
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- pattern: release-*
- path: release-assets
-
- - name: Create GitHub release with binaries and changelog
- run: ./scripts/tools/create-github-release.ts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ tools-ref: prm/toolchain
diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml
index 7ff40c5..8779d4c 100644
--- a/.github/workflows/tools.yml
+++ b/.github/workflows/tools.yml
@@ -11,10 +11,6 @@ jobs:
runs-on: ${{ matrix.os }}
permissions:
contents: read
- env:
- # gh does not read GITHUB_TOKEN; check-versions.ts downloads release
- # assets via the gh CLI and needs an explicit GH_TOKEN.
- GH_TOKEN: ${{ github.token }}
strategy:
fail-fast: false
matrix:
@@ -31,18 +27,5 @@ jobs:
shell: bash
run: |
set -euo pipefail
- out="$(./scripts/tools/plan-release.ts)"
- printf '%s\n' "$out"
- grep -q '^phase=' <<<"$out" || {
- echo "shebang invocation produced no output on ${{ matrix.os }}" >&2
- exit 1
- }
- # Same cwd and argv shape as release.yml publish (minus --dry-run).
- sync="$(./scripts/tools/sync-root-version.ts --dry-run)"
- printf '%s\n' "$sync"
- grep -q '^+++' <<<"$sync" || {
- echo "sync-root-version produced no unified patch on ${{ matrix.os }}" >&2
- exit 1
- }
./scripts/tools/check-versions.ts
./scripts/tools/check-matrix.ts
diff --git a/AGENTS.md b/AGENTS.md
index 53ef4cd..462bfd8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,8 +1,8 @@
# AGENTS.md
-A high-quality, mutation-tested Rust implementation of a Claude Code `PostToolUse` hook that classifies code comments as justified or unnecessary. SOTA engineering: 100% mutation on the core classifier, property-based tests, constitution-aligned, with GitHub releases and npm distribution.
+A high-quality, mutation-tested Rust implementation of a Claude Code `PostToolUse` hook that classifies code comments as justified or unnecessary. SOTA engineering: 100% mutation on the core classifier, property-based tests, constitution-aligned, distributed via GitHub Releases and this repository's Nix flake.
-The npm distribution layer uses Effect v4 RC. Never install, import, or pin `effect@3.*` in the JS side.
+The JS launcher layer uses Effect v4 RC. Never install, import, or pin `effect@3.*` in the JS side.
## Directory map
@@ -45,7 +45,7 @@ Treat repo files as one of four surfaces; read any, mutate only the assigned cla
| **Locked** | This file, evaluation scripts, merge policy, release workflows | Read and propose changes, never edit to make verification pass. |
| **Editable** | Project code (`crates/`, `tests/`), config, Cargo.toml, npm wrapper | Edit freely within the active task. |
| **Append-only** | `THREAD.md`, experiment logs, rejected ideas, `mutants.out*` artifacts (when tracked) | Append only; never rewrite or delete entries. |
-| **Human-controlled** | Main-branch merge, production deploy, credentials, destructive ops, publishing to npm/GitHub under systemfsoftware | Ask the user before acting. |
+| **Human-controlled** | Main-branch merge, production deploy, credentials, destructive ops, releasing (tags/GitHub Releases) under systemfsoftware | Ask the user before acting. |
## Definition of Done
@@ -119,7 +119,7 @@ Before adding any rule anywhere, run the placement escalation order: (1) delete
| Directory | Leaf | Why |
|-----------|------|-----|
| `crates/` | no | Rust core governed by root rules and tests |
-| `npm/` | no (governed by root) | npm distribution layer (can contain multiple packages/apps under packages/ or apps/) — simple wrapper today |
+| `npm/` | no (governed by root) | JS launcher layer (can contain multiple packages/apps under packages/ or apps/) — simple wrapper today |
| `tests/` | no | test harness governed by root verification |
## Git and Branch Discipline (Project Specific)
diff --git a/CONCEPTS.md b/CONCEPTS.md
index b50d27a..9069067 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -42,10 +42,10 @@ A per-kind/precision-recall gate on the corpus that trips when a kind's
classifier weakens — including a single-case kind that goes wrong — so a
weakness in one kind cannot hide inside an aggregate F1 score.
-## npm distribution
+## Distribution
### Launcher
-The root npm package (`@systemfsoftware/claude-code-comment-checker`) whose
+The root package (`@systemfsoftware/claude-code-comment-checker`) whose
`bin` is the `comment-checker` shim. It resolves the host platform package by
identity at runtime and spawns the binary — the only package that declares a
bin.
@@ -57,14 +57,17 @@ manifest (`os`/`cpu`/`libc` fields, no `bin`). The launcher's
`optionalDependencies` pins all five to the release version.
The committed launcher manifest never lists these packages as
-`optionalDependencies` — pnpm cannot lock unpublished platform packages
-(pnpm#3960), so the pins are injected from the targets table at publish
-time; absence in-tree is expected, not a defect.
-
-### Release lane
-A matrix row in the release workflow: one platform/arch build, gate, smoke,
-and publish run on its native runner. Platforms publish before the launcher,
-and the release cannot proceed if any lane fails.
+`optionalDependencies` — pnpm cannot lock the platform packages
+(pnpm#3960), so the pins are injected from the targets table when the launcher
+is packaged; absence in-tree is expected, not a defect.
+
+### Release
+Releases run through the shared toolchain
+(`systemfsoftware/pnpm-release-management`), configured by `release.jsonc`: a
+version with no `vX.Y.Z` git tag is owed a tag and a GitHub Release, so the
+phase is derived from repository state, not a pull-request ref. Consumers take
+the package from this repository's own Nix flake (built from source) at the
+tag; nothing is published to a registry.
## Mutation gate
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8bedd7d..becd81b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,4 +1,10 @@
# Contributing
See [AGENTS.md](AGENTS.md) for development rules, branch discipline, and verification gates.
-For one-time npm OIDC bootstrap and trust configuration, run `cd scripts && deno task publish:unpublished`.
+
+Releases run through the shared toolchain
+([systemfsoftware/pnpm-release-management](https://github.com/systemfsoftware/pnpm-release-management)):
+a `.changeset` intent on a pull request, then on merge the toolchain opens a
+release PR, and merging that tags the version and cuts its GitHub Release.
+`release.jsonc` configures it. Distribution is this repository's Nix flake at
+the tag — nothing is published to a registry.
diff --git a/README.md b/README.md
index ce9f6f1..46a7953 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,11 @@
| Workspace / Package | Description |
|---|---|
-| [`npm/packages/comment-checker`](npm/packages/comment-checker/README.md) | Node/npm distribution launcher package (`@systemfsoftware/claude-code-comment-checker`) |
+| [`npm/packages/comment-checker`](npm/packages/comment-checker/README.md) | Node launcher package (`@systemfsoftware/claude-code-comment-checker`) that resolves and spawns the platform binary |
| [`crates/comment-checker`](crates/comment-checker) | Rust core classifier engine, parser rules, and native CLI executable |
| [`.claude/skills/comment-checker-setup`](.claude/skills/comment-checker-setup/SKILL.md) | Harness setup skill and automated diagnostic doctor script |
| [`tests/`](tests) / [`eval/corpus.json`](eval/corpus.json) | 60-case multi-language classification test suite (F1 ≥ 0.85) |
-| [`.github/workflows/`](.github/workflows) | Multi-platform build matrix, binary packaging, and npm release pipeline |
+| [`.github/workflows/`](.github/workflows) | Multi-platform build matrix, binary packaging, and the git-tag + GitHub Release pipeline (shared release toolchain) |
## Documentation & Contributing
diff --git a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md
deleted file mode 100644
index 35f2973..0000000
--- a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md
+++ /dev/null
@@ -1,225 +0,0 @@
----
-title: Distributing a compiled Rust CLI as per-platform npm packages
-date: 2026-08-17
-category: architecture-patterns
-module: npm distribution (npm/packages/comment-checker + scripts/lib,tools + .github/workflows/release.yml)
-problem_type: architecture_pattern
-component: tooling
-severity: medium
-applies_when:
- - Distributing a CLI compiled from Rust (or another compiled language) as npm packages to Linux, macOS, and Windows consumers
- - The binary must arrive as a plain dependency with no postinstall build or download step
- - Consumers or CI install with a frozen lockfile (pnpm) while the platform packages are unpublished until tag time
- - The npm org supports trusted publishing so release credentials can be OIDC-only
- - Native runners are available in CI for each platform/arch lane
-tags:
- - npm-distribution
- - optional-dependencies
- - platform-packages
- - rust-cli
- - oidc-provenance
- - github-actions
- - pnpm
- - release-pipeline
----
-
-# Distributing a compiled Rust CLI as per-platform npm packages
-
-## Context
-
-comment-checker is a Rust CLI shipped as a Claude Code hook; the npm
-distribution must drop a working binary on every consumer's machine with
-`npm i -g` / `npx` — no postinstall build, no download step. One package cannot
-serve linux (x64 + arm64, glibc), darwin (x64 + arm64), and win32 x64 from a
-single artifact, so the release surface is six packages: a root launcher plus
-five per-platform binary packages. That shape creates two hard constraints:
-
-1. **pnpm cannot lock unresolvable optional deps (pnpm#3960).** The platform
- packages do not exist in the registry until publish time, so a committed
- launcher manifest that names them in `optionalDependencies` breaks
- `pnpm install --frozen-lockfile` for every developer and CI run. The
- committed manifest must stay clean; the pins are injected at publish time
- (`scripts/lib/sync-root-version.ts:18-22`).
-2. **Six packages by hand is exactly what a human gets wrong.** The pipeline
- must be tag-triggered (version = tag), run the same build → gate → smoke →
- publish sequence on every tag, publish platforms before the root, and fail
- loudly instead of shipping an absent or wrong-arch binary.
-
-## Guidance
-
-1. **Launcher resolves its platform package by identity at runtime.**
- `npm/packages/comment-checker/src/platform.ts` defines two pure helpers:
- `optionalDepName(platform, arch)` returns
- `--`; `binaryFileName(platform)` returns
- `comment-checker.exe` on win32, else `comment-checker`. The launcher
- (`npm/packages/comment-checker/src/index.ts`) resolves the platform
- package's own `package.json` via `createRequire` and joins the binary name
- to its directory. Missing package surfaces as a
- typed `BinaryNotFound` naming the package; a spawn-time ENOENT would be a
- corrupt install npm would not have produced.
-2. **One canonical targets table.** `scripts/lib/targets.json` is the
- single source of truth: five entries, each `{target, suffix, os, cpu,
- libc?, bin}`. Everything else consumes the table instead of re-deriving
- the platform set — the workflow resolves the per-lane binary name with
- `jq` rather than duplicating the win32→`.exe` rule,
- `generate-platform-manifest.ts` rejects unknown suffixes against the table,
- and `check-matrix.ts` builds the agreement tests from it.
-3. **Platform manifests are generated, cheap, and carry no `bin`.**
- `generate-platform-manifest.ts` renders each platform `package.json`: name
- = launcher name + `-`, `os`/`cpu`/`libc` from the table,
- `files: [entry.bin]`, and **no `bin` field** — a platform-level bin would
- create a top-level `comment-checker` shim colliding with the launcher's own
- (esbuild precedent, comment at lines 60-62). `binarySha256` is recorded
- into the manifest when the caller passes it.
-4. **The committed launcher manifest carries NO `optionalDependencies`.**
- pnpm cannot record unresolvable optional deps in a lockfile, so listing
- unpublished platform packages breaks frozen installs. `sync-root-version.ts`
- validates `VERSION` (strict semver regex, before any write), then injects
- `version` plus the five pins from `targets.json`, preserving the manifest's
- own formatting so an unchanged sync is byte-identical; `--dry-run` prints an
- LCS diff.
-5. **Gate the matrix, not the script.** `check-matrix.ts` names the product
- platform set (`EXPECTED_SUFFIXES`, five entries — the known set, not a copy
- of the table), then checks three agreements: the table names exactly that
- set; the launcher manifest pins match the table exactly when present; and
- the workflow matrix rows match the table triples in both directions —
- missing, extra, and swapped `target`/`suffix` pairs are all failures.
-6. **Release pipeline: one lane per platform, platforms before root.**
- `.github/workflows/release.yml` triggers only on `push: tags: v*` with
- `permissions: {}` at the top. Five matrix lanes, `fail-fast: false`, each:
- build → `check-matrix` gate → binary-exists gate → in-lane smoke (exit 0
- for a clean payload, 2 for a flagged one) → stage the platform package
- outside the workspace in `$RUNNER_TEMP` plus a binary sha256 sidecar →
- `pnpm publish --provenance` (OIDC, no `NODE_AUTH_TOKEN`, npm ≥ 11.5.1) →
- upload tarball + sha sidecar. The root job `publish-npm-main` needs all
- lanes, re-derives `VERSION` from the tag, requires the tag commit to be an
- ancestor of the default branch, verifies every published platform
- package's `version`/`os`/`cpu`/`libc` against the table, cross-checks the
- published tarballs' binary sha against the recorded sidecars, builds
- frozen, runs `sync-root-version.ts` with `VERSION` from the environment,
- publishes the root, and verifies the root's five pins are exact version
- pins. A final job attaches the tarballs to the GitHub release.
-7. **Humans own one-time trust setup only.** OIDC trusted publishing is the
- no-token story: the npm trusted-publisher record binds workflow filename +
- environment (the npm form has no tag-pattern field), so the `tags: v*`
- filter is the tag gate and `pull_request_target` is deliberately unused.
- `docs/publishing/first-release-checklist.md` covers the six trusted-
- publisher records and post-publish manual spot checks.
-
-## Why This Matters
-
-- **The pnpm failure mode is a landmine, not an annoyance.** The moment
- someone adds `optionalDependencies` naming the platform packages to the
- committed manifest, every `pnpm install --frozen-lockfile` — developers and
- CI alike — breaks because the packages don't exist yet (pnpm#3960). It is
- caught by `check-matrix.ts`'s absence-is-expected branch and by the
- `pnpm install --frozen-lockfile` step of `docs/publishing/first-release-checklist.md`.
-- **Version skew is structurally impossible at the consumer.** The root pins
- each platform package to the exact tag version (verified against the
- registry at release time). Because the root is published after the
- platforms, a consumer's install either gets the pinned, gated binary or
- fails to resolve — no in-between state.
-- **The silent gate failure modes were observed, so the gates are shaped
- against them.** (a) `jq` libc shape: `npm view` reports `libc` as an array
- (`["glibc"]`) while the table stores a bare string, and darwin/win32 rows
- have no `libc` at all — a naive compare is always-true or always-false, so
- the workflow normalizes both sides before deep equality. (b) Cross-arch
- smoke: the smoke only proves anything on the lane's own native runner;
- each matrix row maps target→runner (arm64 lanes use an ARM runner). (c)
- `--allow-env`: `VERSION` arrives via the environment, and `deno run` is
- deny-by-default, so a dropped flag fails at tag time, not at PR time.
-- **No static token exists anywhere.** Publishing is OIDC-only.
-
-## When to Apply
-
-- **Apply:** any compiled CLI (Rust, Go, C) distributed as an npm `bin` to
- heterogeneous consumers — especially when you want `npm i -g` / `npx` to
- just work, you have native CI runners per platform, and the npm org supports
- trusted publishing.
-- **Avoid when:** single-platform or single-arch tooling (one package with
- `files`, no matrix); N-API addons (in-process bindings via `process.dlopen`
- are a different architecture — no launcher spawn, no platform shim); a
- binary that must be compiled on the consumer machine (postinstall builds
- are their own failure mode).
-- **Trust prerequisites:** OIDC trusted publishing is a hard dependency of
- the no-token story, and the npm org needs one trusted-publisher record per
- package name; brand-new names may require a seed publish before the record
- can be configured (`docs/publishing/first-release-checklist.md`).
-
-## Examples
-
-`npm/packages/comment-checker/src/platform.ts` — the platform surface is two
-pure helpers:
-
-```ts
-export const binaryFileName = (platform: string): string =>
- platform === "win32" ? "comment-checker.exe" : "comment-checker"
-
-export const optionalDepName = (platform: string, arch: string): string =>
- `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}`
-```
-
-`scripts/lib/targets.json` — the table is the platform contract; every
-entry carries `os`/`cpu`/`libc` consumed by manifest generation, the
-workflow's binary-name resolution, and the registry gate:
-
-```json
-{
- "target": "x86_64-unknown-linux-gnu",
- "suffix": "linux-x64",
- "os": "linux",
- "cpu": "x64",
- "libc": "glibc",
- "bin": "comment-checker"
-}
-```
-
-`scripts/lib/sync-root-version.ts` — the inject-at-publish move that
-keeps the committed manifest frozen-install-clean while the published root is
-fully pinned:
-
-```ts
-manifest.optionalDependencies = Object.fromEntries(
- targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]),
-)
-```
-
-`scripts/tools/check-matrix.ts` — the product policy the table must name:
-
-```ts
-const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64']
-```
-
-`.github/workflows/release.yml` — version comes only from the tag, and the
-tag commit must be an ancestor of the default branch before anything
-publishes:
-
-```yaml
-- name: "Tag gate: derive VERSION from tag"
- run: |
- VERSION="${GITHUB_REF#refs/tags/v}"
- if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then
- echo "invalid tag semver: '$VERSION'" >&2
- exit 1
- fi
- echo "VERSION=$VERSION" >> "$GITHUB_ENV"
-```
-
-The binary-sha cross-check records a sha256 sidecar per lane at build time,
-then re-packs the published tarball from the registry and recomputes the
-digest — the gate that catches a wrong or swapped binary being published.
-
-## Related
-
-- Pipeline: `.github/workflows/release.yml`
-- Platform table: `scripts/lib/targets.json`
-- Scripts: `scripts/tools/generate-platform-manifest.ts`,
- `scripts/lib/sync-root-version.ts`, `scripts/tools/check-matrix.ts`
-- Human gate: `docs/publishing/first-release-checklist.md`
-- pnpm#3960 — the constraint that makes listing optional deps a
- frozen-lockfile landmine
-- Residual advisories (open GitHub issues on this repo): #3 force-pushed tag
- gate; #4 platform peerDependencies cross-link; #5 concurrency group vs
- force-moved tags; #6 smoke exit-code contract; #7 sha sidecar self-trust;
- #8 check-matrix regex-scrape fragility; #9 no actionlint / workflow YAML
- validation in CI
\ No newline at end of file
diff --git a/nix/release-hashes.json b/nix/release-hashes.json
deleted file mode 100644
index d28976a..0000000
--- a/nix/release-hashes.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "version": "0.3.6",
- "assets": {
- "x86_64-unknown-linux-gnu": "bc6a446d963536296596c8694c1f2b6ddc700b4bc7ce671eb676dd6d57263dc2",
- "aarch64-unknown-linux-gnu": "b01d001acfd7fbcd7cfa09239a8edeae5a13d0da1add55f0ec432777b9241fbe",
- "x86_64-apple-darwin": "77623d2ba08e227eefea049fad0ee3fed4a8fcf530f45bc2dde47e921ca1e0b3",
- "aarch64-apple-darwin": "0360c6fb46a49e3300d077a9155cc7b9ce7b984e9485d959be6d9f4df88f9f9e",
- "x86_64-pc-windows-msvc": "a1a59bc83b18d165bf81f5dabd7c1d60945cd7faf7d5f3e38637ffbc4535f4ff"
- },
- "systems": {
- "x86_64-linux": "x86_64-unknown-linux-gnu",
- "aarch64-linux": "aarch64-unknown-linux-gnu",
- "x86_64-darwin": "x86_64-apple-darwin",
- "aarch64-darwin": "aarch64-apple-darwin"
- }
-}
diff --git a/release.jsonc b/release.jsonc
new file mode 100644
index 0000000..3ab1dd2
--- /dev/null
+++ b/release.jsonc
@@ -0,0 +1,78 @@
+{
+ // Release configuration for the shared toolchain
+ // (systemfsoftware/pnpm-release-management). The reusable
+ // .github/workflows/release.yml runs that toolchain's apps against this file
+ // on every push to master: pending .changeset intents mean "version" (open
+ // the release PR), an untagged manifest version means "release" (tag it and
+ // cut its GitHub Release), otherwise "none". No registry publish happens —
+ // the package is taken from this repository's own Nix flake at a tag or
+ // revision.
+ "base": "master",
+ "branch": "changeset-release/master",
+ "versioning": {
+ // The version lives in several surfaces; the root package.json owns it and
+ // every other surface is rewritten to match on each bump.
+ "strategy": "surfaces",
+ "manifest": "package.json",
+ "changelog": "CHANGELOG.md",
+ "surfaces": [
+ { "kind": "toml", "header": "[workspace.package]", "path": "Cargo.toml" },
+ { "kind": "json", "path": "npm/packages/comment-checker/package.json" },
+ { "kind": "nix", "path": "flake.nix" },
+ ],
+ },
+ "gate": { "strategy": "turbo", "task": "build" },
+ "distribution": {
+ // The os-cpu platform packages the launcher resolves at runtime. Kept so
+ // the plan and the changeset gate know the full publishable set.
+ "launcherManifest": "npm/packages/comment-checker/package.json",
+ "targets": [
+ {
+ "target": "x86_64-unknown-linux-gnu",
+ "suffix": "linux-x64",
+ "os": "linux",
+ "cpu": "x64",
+ "libc": "glibc",
+ "runner": "ubuntu-latest",
+ "bin": "comment-checker",
+ },
+ {
+ "target": "aarch64-unknown-linux-gnu",
+ "suffix": "linux-arm64",
+ "os": "linux",
+ "cpu": "arm64",
+ "libc": "glibc",
+ "runner": "ubuntu-24.04-arm",
+ "bin": "comment-checker",
+ },
+ {
+ "target": "x86_64-apple-darwin",
+ "suffix": "darwin-x64",
+ "os": "darwin",
+ "cpu": "x64",
+ "runner": "macos-14",
+ "bin": "comment-checker",
+ },
+ {
+ "target": "aarch64-apple-darwin",
+ "suffix": "darwin-arm64",
+ "os": "darwin",
+ "cpu": "arm64",
+ "runner": "macos-14",
+ "bin": "comment-checker",
+ },
+ {
+ "target": "x86_64-pc-windows-msvc",
+ "suffix": "win32-x64",
+ "os": "win32",
+ "cpu": "x64",
+ "runner": "windows-2022",
+ "bin": "comment-checker.exe",
+ },
+ ],
+ },
+ "pr": {
+ "title": "chore(release): version packages",
+ "body": "Consumes the pending `.changeset/` intents.\n\nMerging tags the released versions and creates GitHub Releases. Distribution is this repository's Nix flake at the tag; nothing is published to a registry.",
+ },
+}
diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc
index f085907..7190bd7 100644
--- a/scripts/deno.jsonc
+++ b/scripts/deno.jsonc
@@ -11,13 +11,9 @@
},
"tasks": {
"manifest:generate": "./tools/generate-platform-manifest.ts",
- "manifest:sync-root": "./tools/sync-root-version.ts",
"check-matrix": "./tools/check-matrix.ts",
- "check:publish": "./tools/check-publish.ts",
- "publish:unpublished": "./tools/publish-and-setup-npm-trust.ts",
"lint": "deno lint --config ./deno.jsonc .",
- "lint:workflows": "./tools/lint-workflows.ts",
- "plan:release": "./tools/plan-release.ts"
+ "lint:workflows": "./tools/lint-workflows.ts"
},
"fmt": {
"lineWidth": 100,
diff --git a/scripts/lib/distribution-set.ts b/scripts/lib/distribution-set.ts
deleted file mode 100644
index 464daa0..0000000
--- a/scripts/lib/distribution-set.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- LAUNCHER_MANIFEST_PATH,
- type LauncherManifest,
- type Target,
- TARGETS_PATH,
-} from './shared.ts'
-
-export interface PackageTarget {
- name: string
- kind: 'launcher' | 'platform'
- suffix?: string
- target?: Target
-}
-
-export interface RegistrySnapshot {
- name: string
- status: number
- unpublished: boolean
- latest?: string
- attested?: boolean
-}
-
-export async function readDistributionSet(): Promise<{
- launcher: LauncherManifest
- targets: Target[]
- packages: PackageTarget[]
-}> {
- const launcher: LauncherManifest = JSON.parse(await Deno.readTextFile(LAUNCHER_MANIFEST_PATH))
- const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
- const packages: PackageTarget[] = [
- { name: launcher.name, kind: 'launcher' },
- ...targets.map((target) => ({
- name: `${launcher.name}-${target.suffix}`,
- kind: 'platform' as const,
- suffix: target.suffix,
- target,
- })),
- ]
- return { launcher, targets, packages }
-}
-
-export async function remoteSlugFromRepo(repoRoot: string): Promise {
- const cmd = new Deno.Command('git', {
- args: ['-C', repoRoot, 'remote', 'get-url', 'origin'],
- stdout: 'piped',
- stderr: 'piped',
- })
- const res = await cmd.output()
- if (!res.success) {
- const err = new TextDecoder().decode(res.stderr).trim()
- throw new Error(`cannot read origin remote: ${err}`)
- }
- const text = new TextDecoder().decode(res.stdout).trim()
- for (
- const re of [
- /^[^:]+:([^/]+)\/([^/]+?)(\.git)?$/m,
- /^https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(\.git)?$/m,
- ]
- ) {
- const m = text.match(re)
- if (m) return `${m[1]}/${m[2]}`
- }
- throw new Error(`cannot parse origin remote: ${text}`)
-}
-
-export async function queryRegistry(name: string, registry: string): Promise {
- const url = `${registry}/${encodeURIComponent(name)}`
- try {
- const res = await fetch(url, {
- headers: { Accept: 'application/json' },
- })
- if (res.status === 404) {
- return { name, status: 404, unpublished: true }
- }
- if (!res.ok) {
- return { name, status: res.status, unpublished: false }
- }
- const body = await res.json() as {
- 'dist-tags'?: Record
- versions?: Record
- error?: string
- }
- if (body.error === 'Not found') {
- return { name, status: 404, unpublished: true }
- }
- const distTags = body['dist-tags']
- const latest = typeof distTags?.latest === 'string'
- ? distTags.latest
- : (typeof distTags?.next === 'string' ? distTags.next : undefined)
- const attested = latest !== undefined && body.versions?.[latest]?.dist?.attestations != null
- return { name, status: res.status, unpublished: false, latest, attested }
- } catch {
- return { name, status: 0, unpublished: false }
- }
-}
diff --git a/scripts/tools/check-matrix.ts b/scripts/tools/check-matrix.ts
index ed59ed2..e31016c 100755
--- a/scripts/tools/check-matrix.ts
+++ b/scripts/tools/check-matrix.ts
@@ -7,7 +7,6 @@ import {
LAUNCHER_MANIFEST_PATH,
type LauncherManifest,
PLATFORM_WORKFLOW_PATH,
- RELEASE_WORKFLOW_PATH,
type Target,
TARGETS_PATH,
} from '../lib/shared.ts'
@@ -101,7 +100,7 @@ function checkManifest(manifest: LauncherManifest, targets: Target[]) {
const declaredNames = Object.keys(manifest.optionalDependencies ?? {})
if (declaredNames.length === 0) {
note(
- 'launcher manifest carries no optionalDependencies (pre-publish); sync-root-version.ts injects the five platform pins from targets.json',
+ 'launcher manifest carries no optionalDependencies in-tree; the shared release toolchain injects the five platform pins from targets.json when the launcher is packaged',
)
} else {
const missingNames = expectedNames.filter((name) => !declaredNames.includes(name))
@@ -196,7 +195,6 @@ const rawManifest = await readJsonOrExit(manifestPath, 'launcher manifest')
checkManifest(rawManifest as LauncherManifest, rawTargets as Target[])
await checkWorkflow(workflowPath, rawTargets as Target[])
await checkCallerUsesPlatform(CI_WORKFLOW_PATH)
-await checkCallerUsesPlatform(RELEASE_WORKFLOW_PATH)
if (failures.length > 0) {
for (const reason of failures) {
diff --git a/scripts/tools/check-publish.ts b/scripts/tools/check-publish.ts
deleted file mode 100755
index 3a79fcf..0000000
--- a/scripts/tools/check-publish.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-#!/usr/bin/env -S deno run --allow-read --allow-env=NPM_REGISTRY --allow-net=registry.npmjs.org
-import { parseCliArgs } from '../lib/cli.ts'
-import { queryRegistry, readDistributionSet } from '../lib/distribution-set.ts'
-
-const flags = parseCliArgs({
- boolean: ['check', 'json', 'preflight'],
- string: [],
-})
-
-const checkMode = flags.check === true
-const jsonMode = flags.json === true
-const preflightMode = flags.preflight === true
-
-const registry = Deno.env.get('NPM_REGISTRY') ?? 'https://registry.npmjs.org'
-
-const { launcher, packages } = await readDistributionSet()
-
-interface PackageEvaluation {
- name: string
- kind: 'launcher' | 'platform'
- localVersion: string
- npmLatest: string
- status: 'published' | 'unpublished' | 'error'
- attested: boolean
- classification: 'unpublished' | 'no-oidc' | 'stuck' | 'ok' | 'error'
-}
-
-const evaluations: PackageEvaluation[] = []
-
-for (const pkg of packages) {
- const snapshot = await queryRegistry(pkg.name, registry)
- const localVersion = pkg.kind === 'launcher' ? launcher.version : '—'
- const npmLatest = snapshot.latest ?? (snapshot.unpublished ? '—' : '?')
- const attested = snapshot.attested === true
-
- let classification: PackageEvaluation['classification']
- let status: PackageEvaluation['status']
-
- if (snapshot.unpublished) {
- status = 'unpublished'
- classification = 'unpublished'
- } else if (snapshot.status === 0 || snapshot.latest === undefined) {
- status = 'error'
- classification = 'error'
- } else {
- status = 'published'
- if (!attested) {
- classification = 'no-oidc'
- } else if (pkg.kind === 'launcher' && localVersion !== npmLatest) {
- classification = 'stuck'
- } else {
- classification = 'ok'
- }
- }
-
- evaluations.push({
- name: pkg.name,
- kind: pkg.kind,
- localVersion,
- npmLatest,
- status,
- attested,
- classification,
- })
-}
-
-if (jsonMode) {
- for (const item of evaluations) {
- console.log(
- JSON.stringify({
- name: item.name,
- kind: item.kind,
- local_version: item.localVersion,
- npm_latest: item.npmLatest,
- class: item.classification,
- attested: item.attested ? 'yes' : 'no',
- }),
- )
- }
-} else {
- const count = (cls: PackageEvaluation['classification']) =>
- evaluations.filter((e) => e.classification === cls).length
-
- const unpublishedCount = count('unpublished')
- const noOidcCount = count('no-oidc')
- const stuckCount = count('stuck')
- const okCount = count('ok')
- const errorCount = count('error')
-
- const lines: string[] = [
- `npm publish status — ${new Date().toISOString()} — registry: ${registry}`,
- `distribution packages: ${evaluations.length}`,
- '',
- '== UNPUBLISHED (404 on npm) ==',
- ]
-
- for (const item of evaluations.filter((e) => e.classification === 'unpublished')) {
- lines.push(
- ` ${item.name.padEnd(60)} local ${item.localVersion.padEnd(8)} npm ${item.npmLatest}`,
- )
- }
-
- lines.push(
- '',
- '== PUBLISHED, NO OIDC ATTESTATION ==',
- )
- for (const item of evaluations.filter((e) => e.classification === 'no-oidc')) {
- lines.push(
- ` ${item.name.padEnd(60)} local ${item.localVersion.padEnd(8)} npm ${item.npmLatest}`,
- )
- }
-
- lines.push(
- '',
- '== PUBLISHED + ATTESTED, BUT LOCAL AHEAD ==',
- )
- for (const item of evaluations.filter((e) => e.classification === 'stuck')) {
- lines.push(
- ` ${item.name.padEnd(60)} local ${item.localVersion.padEnd(8)} npm ${item.npmLatest}`,
- )
- }
-
- lines.push(
- '',
- '== PUBLISHED + ATTESTED, CURRENT ==',
- )
- for (const item of evaluations.filter((e) => e.classification === 'ok')) {
- lines.push(
- ` ${item.name.padEnd(60)} local ${item.localVersion.padEnd(8)} npm ${item.npmLatest}`,
- )
- }
-
- lines.push(
- '',
- '== summary ==',
- ` unpublished: ${unpublishedCount}`,
- ` no-oidc: ${noOidcCount}`,
- ` stuck: ${stuckCount}`,
- ` ok: ${okCount}`,
- )
- if (errorCount > 0) {
- lines.push(` error: ${errorCount}`)
- }
-
- console.log(lines.join('\n'))
-}
-
-const unpublishedTotal = evaluations.filter((e) => e.classification === 'unpublished').length
-const errorTotal = evaluations.filter((e) => e.classification === 'error').length
-const noOidcTotal = evaluations.filter((e) => e.classification === 'no-oidc').length
-
-if (preflightMode) {
- if (unpublishedTotal === 0 && errorTotal === 0) {
- console.log('\nPREFLIGHT OK: every distribution package exists on the registry.\n')
- } else {
- console.error(
- `\n::error::preflight failed — ${unpublishedTotal} package(s) have never been published, ${errorTotal} unqueryable. OIDC cannot debut a package; bootstrap each one from a maintainer machine, then re-run.\n`,
- )
- Deno.exit(1)
- }
-}
-
-if (checkMode) {
- if (unpublishedTotal > 0 || noOidcTotal > 0 || errorTotal > 0) {
- console.error(
- `\nFAIL: ${unpublishedTotal} unpublished, ${noOidcTotal} without OIDC attestation, ${errorTotal} unqueryable\n`,
- )
- Deno.exit(1)
- }
- console.log('\nOK: every package is published and carries provenance attestations.\n')
-}
diff --git a/scripts/tools/create-github-release.ts b/scripts/tools/create-github-release.ts
deleted file mode 100755
index de9f415..0000000
--- a/scripts/tools/create-github-release.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run=git,gh,tar --allow-read --allow-write --allow-env
-
-import { type Target, TARGETS_PATH } from '../lib/shared.ts'
-
-const MANIFEST = 'npm/packages/comment-checker/package.json'
-const CHANGELOG = 'npm/packages/comment-checker/CHANGELOG.md'
-
-async function exec(cmd: string, args: string[]): Promise {
- const out = await new Deno.Command(cmd, {
- args,
- stdout: 'piped',
- stderr: 'inherit',
- }).output()
- if (!out.success) throw new Error(`${cmd} ${args.join(' ')} failed`)
- return new TextDecoder().decode(out.stdout)
-}
-
-async function walk(dir: string, out: string[] = []): Promise {
- try {
- for await (const e of Deno.readDir(dir)) {
- const p = `${dir}/${e.name}`
- if (e.isDirectory) await walk(p, out)
- else if (e.isFile) out.push(p)
- }
- } catch { /* dir missing */ }
- return out
-}
-
-const launcherManifest = JSON.parse(await Deno.readTextFile(MANIFEST))
-const version = launcherManifest.version as string
-const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
-
-let releaseNotes = `Release v${version}`
-try {
- const text = await Deno.readTextFile(CHANGELOG)
- const sec = text.split(new RegExp(`##\\s+${version.replace(/\./g, '\\.')}`))?.[1]
- const body = sec?.split(/\n##\s+/)?.[0]?.trim()
- if (body) releaseNotes = body
-} catch { /* no changelog */ }
-
-const tarballs: { target: Target; tarball: string }[] = []
-const missing: string[] = []
-for (const t of targets) {
- const p = `release-assets/release-${t.suffix}/comment-checker-${t.target}.tar.gz`
- try {
- if ((await Deno.stat(p)).isFile) tarballs.push({ target: t, tarball: p })
- else {
- console.error(`create-github-release: missing tarball for ${t.target} at ${p}`)
- missing.push(p)
- }
- } catch {
- console.error(`create-github-release: missing tarball for ${t.target} at ${p}`)
- missing.push(p)
- }
-}
-
-if (missing.length > 0) {
- console.error(`create-github-release: expected ${targets.length} tarballs, found ${tarballs.length}`)
- const tree = await walk('release-assets')
- if (tree.length > 0) {
- console.error('release-assets tree:')
- for (const f of tree.sort()) console.error(` ${f}`)
- } else {
- console.error('release-assets is empty or missing')
- }
- Deno.exit(1)
-}
-
-await Deno.mkdir('release-assets/binaries', { recursive: true })
-
-const binaries = await Promise.all(tarballs.map(async ({ target, tarball }) => {
- const tmp = `release-assets/binaries/.tmp-${target.suffix}`
- await Deno.mkdir(tmp, { recursive: true })
- const res = await new Deno.Command('tar', { args: ['-xzf', tarball, '-C', tmp] }).output()
- if (!res.success) throw new Error(`tar -xzf ${tarball} failed with ${res.code}`)
- const exe = target.bin.endsWith('.exe')
- const outName = `comment-checker-${target.target}${exe ? '.exe' : ''}`
- const outPath = `release-assets/binaries/${outName}`
- await Deno.rename(`${tmp}/${target.bin}`, outPath)
- await Deno.remove(tmp, { recursive: true })
- return outPath
-}))
-
-const tag = `v${version}`
-await exec('gh', ['release', 'create', tag, ...binaries, '--title', tag, '--notes', releaseNotes])
-console.log(`created GitHub release ${tag} with ${binaries.length} binaries`)
-
-const digests: Record = {}
-for (const t of targets) {
- const exe = t.bin.endsWith('.exe')
- const shipped = `release-assets/binaries/comment-checker-${t.target}${exe ? '.exe' : ''}`
- const bytes = await Deno.readFile(shipped)
- const buf = await crypto.subtle.digest('SHA-256', bytes)
- const hex = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
- const staged = `stages/platform-stage-${t.suffix}/binarySha256`
- const expected = (await Deno.readTextFile(staged)).trim()
- if (hex !== expected) {
- console.error(`create-github-release: ${t.target} shipped ${hex} but staged ${expected}`)
- Deno.exit(1)
- }
- digests[t.target] = hex
-}
-
-const NIX_CPU: Record = { x64: 'x86_64', arm64: 'aarch64' }
-const systems: Record = {}
-for (const t of targets) {
- if (t.os === 'win32') continue
- systems[`${NIX_CPU[t.cpu] ?? t.cpu}-${t.os}`] = t.target
-}
-
-const manifestPath = 'nix/release-hashes.json'
-const branch = `nix-release-hashes-v${version}`
-
-await Deno.mkdir('nix', { recursive: true })
-await Deno.writeTextFile(
- manifestPath,
- JSON.stringify({ version, assets: digests, systems }, null, 2) + '\n',
-)
-
-if ((await exec('git', ['status', '--porcelain', '--', manifestPath])).trim() === '') {
- console.log(`${manifestPath} already pins v${version}`)
-} else {
- await exec('git', ['config', 'user.name', 'github-actions[bot]'])
- await exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'])
- // The husky hooks this job installed must not gate a generated file.
- await exec('git', ['checkout', '-b', branch])
- await exec('git', ['add', '--', manifestPath])
- await exec('git', ['commit', '--no-verify', '-m', `chore(release): pin nix release hashes for v${version}`])
- await exec('git', ['push', '--no-verify', 'origin', `HEAD:refs/heads/${branch}`])
- const pr = (await exec('gh', [
- 'pr',
- 'create',
- '--base',
- 'master',
- '--head',
- branch,
- '--title',
- `chore(release): pin nix release hashes for v${version}`,
- '--body',
- `Generated by the release pipeline from the v${version} assets: \`${manifestPath}\` carries the version and the SHA-256 of every uploaded binary, and the derivation that consumes it names both, so neither can drift from the other.`,
- ])).trim()
- const armed = await exec('gh', ['pr', 'merge', pr, '--squash', '--auto'])
- .then(() => true)
- .catch(() => false)
- console.log(`${pr}${armed ? ' (auto-merge armed)' : ' (merge by hand)'}`)
-}
diff --git a/scripts/tools/create-or-update-release-pr.ts b/scripts/tools/create-or-update-release-pr.ts
deleted file mode 100755
index 21dd914..0000000
--- a/scripts/tools/create-or-update-release-pr.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run=gh,git --allow-read --allow-env
-
-const BRANCH = 'changeset-release/master'
-const BASE = 'master'
-
-async function exec(cmd: string, args: string[], allowFail = false): Promise {
- const out = await new Deno.Command(cmd, {
- args,
- stdout: 'piped',
- stderr: 'inherit',
- }).output()
- if (!out.success && !allowFail) {
- throw new Error(`${cmd} ${args.join(' ')} failed`)
- }
- return new TextDecoder().decode(out.stdout).trim()
-}
-
-const status = await exec('git', ['status', '--porcelain'])
-
-const existingStr = await exec('gh', [
- 'pr',
- 'list',
- '--head',
- BRANCH,
- '--state',
- 'open',
- '--json',
- 'number',
- '--jq',
- '.[0].number // empty',
-])
-const existing = existingStr ? parseInt(existingStr, 10) : null
-
-if (!status) {
- console.log('no pending change intents — nothing to release')
- if (existing) {
- await exec('gh', [
- 'pr',
- 'close',
- String(existing),
- '--delete-branch',
- '--comment',
- 'No pending change intents remain.',
- ])
- }
- Deno.exit(0)
-}
-
-await exec('git', ['config', 'user.name', 'github-actions[bot]'])
-await exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'])
-await exec('git', ['switch', '--force-create', BRANCH])
-await exec('git', ['add', '-A'])
-await exec('git', ['commit', '-m', 'chore(release): version packages'])
-await exec('git', ['push', '--force', 'origin', BRANCH])
-
-const prBody = `Consumes pending \`.changeset/\` intents.
-
-Merging publishes packages with provenance attestations and creates GitHub releases.`
-
-await exec('gh', [
- 'label',
- 'create',
- 'release',
- '--color',
- '0E8A16',
- '--description',
- 'Automated version-packages release PR',
- '--force',
-], true)
-
-if (existing) {
- await exec('gh', [
- 'pr',
- 'edit',
- String(existing),
- '--title',
- 'chore(release): version packages',
- '--body',
- prBody,
- '--add-label',
- 'release',
- ])
- console.log(`updated release PR #${existing}`)
-} else {
- await exec('gh', [
- 'pr',
- 'create',
- '--base',
- BASE,
- '--head',
- BRANCH,
- '--title',
- 'chore(release): version packages',
- '--body',
- prBody,
- '--label',
- 'release',
- ])
- console.log('created release PR')
-}
diff --git a/scripts/tools/plan-release.ts b/scripts/tools/plan-release.ts
deleted file mode 100755
index 08062e1..0000000
--- a/scripts/tools/plan-release.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env -S deno run --allow-read --allow-run=git
-
-// Decide which release phase this push is, from repository state alone.
-//
-// The publish used to hang off `pull_request: closed` for the release PR.
-// Merging that PR with branch deletion destroys `refs/pull//merge`, so
-// GitHub cancelled the queued run with zero jobs and nothing ever published --
-// the trigger was destroyed by the act of merging. State is durable where a
-// PR ref is not: pending intents mean "version", an untagged version means
-// "publish". Re-running any push to master resumes a half-finished release.
-
-import { LAUNCHER_MANIFEST_PATH } from '../lib/shared.ts'
-
-async function gitTagExists(tag: string): Promise {
- const out = await new Deno.Command('git', {
- args: ['tag', '--list', tag],
- stdout: 'piped',
- stderr: 'null',
- }).output()
- return new TextDecoder().decode(out.stdout).trim() !== ''
-}
-
-async function pendingIntents(): Promise {
- const names: string[] = []
- try {
- for await (const entry of Deno.readDir('.changeset')) {
- if (entry.isFile && entry.name.endsWith('.md') && entry.name !== 'README.md') {
- names.push(entry.name)
- }
- }
- } catch {
- // no .changeset directory: nothing pending
- }
- return names
-}
-
-const manifest = JSON.parse(await Deno.readTextFile(LAUNCHER_MANIFEST_PATH))
-const version = manifest.version as string
-const tag = `v${version}`
-
-const pending = await pendingIntents()
-const tagged = await gitTagExists(tag)
-
-const phase = pending.length > 0 ? 'version' : tagged ? 'none' : 'publish'
-
-// Diagnostics on stderr; stdout carries only key=value for GITHUB_OUTPUT.
-console.error(
- `plan-release: version=${version} tag=${tag} tagged=${tagged} pending=${pending.length}` +
- (pending.length > 0 ? ` (${pending.join(', ')})` : '') +
- ` -> phase=${phase}`,
-)
-if (phase === 'none') {
- console.error(`plan-release: ${tag} already released; nothing to do`)
-}
-
-console.log(`phase=${phase}`)
-console.log(`version=${version}`)
diff --git a/scripts/tools/publish-and-setup-npm-trust.ts b/scripts/tools/publish-and-setup-npm-trust.ts
deleted file mode 100755
index 5e9a4cd..0000000
--- a/scripts/tools/publish-and-setup-npm-trust.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run=git,npm,pnpm --allow-read --allow-write --allow-env=NPM_REGISTRY --allow-net=registry.npmjs.org
-import { join } from '@std/path'
-import { parseCliArgs } from '../lib/cli.ts'
-import {
- type PackageTarget,
- queryRegistry,
- readDistributionSet,
- remoteSlugFromRepo,
-} from '../lib/distribution-set.ts'
-import { buildPlatformManifest } from '../lib/platform-manifest.ts'
-import { LAUNCHER_MANIFEST_PATH } from '../lib/shared.ts'
-
-const DUMMY_BOOTSTRAP_VERSION = '0.0.0-dummy-npm'
-
-const flags = parseCliArgs({
- alias: { 'dry-run': 'dryRun', o: 'only' },
- boolean: ['dry-run'],
- string: ['only', 'jobs'],
-})
-
-const dryRun = flags.dryRun === true
-const onlyArg = typeof flags.only === 'string' ? flags.only : ''
-const selectedOnly: Record = {}
-for (const item of onlyArg.split(',').map((s) => s.trim()).filter(Boolean)) {
- selectedOnly[item] = true
-}
-
-const hasOnly = Object.keys(selectedOnly).length > 0
-const registry = Deno.env.get('NPM_REGISTRY') ?? 'https://registry.npmjs.org'
-
-const repoRoot = new TextDecoder().decode(
- (await new Deno.Command('git', { args: ['rev-parse', '--show-toplevel'] }).output()).stdout,
-).trim()
-
-const slug = await remoteSlugFromRepo(repoRoot)
-const { launcher, packages } = await readDistributionSet()
-
-const targetPackages = packages.filter((p) => !hasOnly || selectedOnly[p.name] === true)
-
-function logLine(msg: string) {
- console.log(msg)
-}
-
-function logError(msg: string) {
- console.error(`ERROR: ${msg}`)
-}
-
-async function runInteractive(args: string[], cwd: string): Promise {
- const child = new Deno.Command(args[0], {
- args: args.slice(1),
- cwd,
- stdin: 'inherit',
- stdout: 'inherit',
- stderr: 'inherit',
- }).spawn()
- const status = await child.status
- return status.success
-}
-
-async function stageAndPublish(pkg: PackageTarget): Promise<{ name: string; ok: boolean }> {
- logLine(`\n== ${pkg.name}`)
- const stageDir = await Deno.makeTempDir({ prefix: 'comment-checker-bootstrap-' })
-
- try {
- if (pkg.kind === 'platform' && pkg.target) {
- const manifest = buildPlatformManifest(launcher, pkg.target, DUMMY_BOOTSTRAP_VERSION)
- await Deno.writeTextFile(
- join(stageDir, 'package.json'),
- JSON.stringify(manifest, null, 2) + '\n',
- )
- await Deno.writeTextFile(join(stageDir, pkg.target.bin), '')
- } else {
- const original = JSON.parse(await Deno.readTextFile(LAUNCHER_MANIFEST_PATH))
- original.version = DUMMY_BOOTSTRAP_VERSION
- await Deno.writeTextFile(
- join(stageDir, 'package.json'),
- JSON.stringify(original, null, 2) + '\n',
- )
- await Deno.mkdir(join(stageDir, 'dist'), { recursive: true })
- await Deno.writeTextFile(join(stageDir, 'dist', 'index.mjs'), '')
- }
-
- const publishCmd = [
- 'npm',
- 'publish',
- '--access',
- 'public',
- '--no-provenance',
- '--tag',
- 'next',
- ]
-
- const trustCmd = [
- 'npm',
- 'trust',
- 'github',
- pkg.name,
- '--repo',
- slug,
- '--file',
- 'release.yml',
- '--allow-publish',
- '--yes',
- ]
-
- const listCmd = ['npm', 'trust', 'list', pkg.name]
-
- const steps = [
- { cmd: publishCmd, cwd: stageDir },
- { cmd: trustCmd, cwd: repoRoot },
- { cmd: listCmd, cwd: repoRoot },
- ]
-
- for (const step of steps) {
- logLine(` > ${step.cmd.join(' ')}`)
- if (dryRun) continue
- const ok = await runInteractive(step.cmd, step.cwd)
- if (!ok) {
- logError(`Command failed: ${step.cmd.join(' ')}`)
- return { name: pkg.name, ok: false }
- }
- }
- return { name: pkg.name, ok: true }
- } finally {
- try {
- await Deno.remove(stageDir, { recursive: true })
- } catch {
- // Stage dir cleanup is non-fatal
- }
- }
-}
-
-logLine('Checking registry statuses...')
-const unpublished: PackageTarget[] = []
-
-for (const pkg of targetPackages) {
- const snapshot = await queryRegistry(pkg.name, registry)
- const is404 = snapshot.unpublished || snapshot.status === 404
- logLine(
- ` ${pkg.name.padEnd(60)} … ${
- is404 ? 'unpublished (404)' : `published (HTTP ${snapshot.status}) — skipped`
- }`,
- )
- if (is404) {
- unpublished.push(pkg)
- }
-}
-
-if (unpublished.length === 0) {
- logLine('\nNothing to publish: all packages already exist on the registry.')
- Deno.exit(0)
-}
-
-logLine(`\nBootstrapping and trusting ${unpublished.length} package(s)...`)
-
-const results: { name: string; ok: boolean }[] = []
-for (const pkg of unpublished) {
- results.push(await stageAndPublish(pkg))
-}
-
-const failed = results.filter((r) => !r.ok).map((r) => r.name)
-if (failed.length > 0) {
- logError(`Failed bootstrap for: ${failed.join(', ')}`)
- Deno.exit(1)
-}
-
-logLine('\nDone: All debut packages published and trusted.')
diff --git a/scripts/tools/publish-platform-stages.ts b/scripts/tools/publish-platform-stages.ts
deleted file mode 100755
index 5058e0d..0000000
--- a/scripts/tools/publish-platform-stages.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run --allow-read
-
-const stages: string[] = []
-for await (const entry of Deno.readDir('stages')) {
- if (entry.isDirectory) {
- stages.push(`stages/${entry.name}`)
- }
-}
-
-if (stages.length !== 5) {
- console.error(`expected 5 staged platform packages, found ${stages.length}`)
- Deno.exit(1)
-}
-
-for (const stage of stages) {
- const cmd = new Deno.Command('pnpm', {
- args: ['publish', '--provenance', '--access', 'public', '--no-git-checks'],
- cwd: stage,
- stdout: 'inherit',
- stderr: 'inherit',
- })
- const res = await cmd.output()
- if (!res.success) {
- console.error(`pnpm publish failed for ${stage}`)
- Deno.exit(1)
- }
-}
-
-console.log('all platform packages published')
diff --git a/scripts/tools/release-version.ts b/scripts/tools/release-version.ts
deleted file mode 100755
index 7418685..0000000
--- a/scripts/tools/release-version.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env
-
-import { runMain } from '@effect/platform-deno/DenoRuntime'
-import { layer as DenoPlatform } from '@effect/platform-deno/DenoServices'
-import { Console, Effect, FileSystem } from 'effect'
-import { bumpAllSurfaces } from '../lib/version-files.ts'
-import {
- CHANGELOG,
- CHANGESET_DIR,
- extractJsonVersion,
- MANIFEST,
- nextVersion,
- parseChangeset,
- RANK,
- type ReleaseBump,
-} from '../lib/version-sync.ts'
-
-const program = Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem
- const entries = yield* fs.readDirectory(CHANGESET_DIR)
- const pending = entries.filter((name) => name.endsWith('.md') && name !== 'README.md')
- if (pending.length === 0) {
- yield* Console.log('no change intents; nothing to version')
- return
- }
-
- const intents = yield* Effect.all(
- pending.map((name) =>
- Effect.gen(function* () {
- const body = yield* fs.readFileString(`${CHANGESET_DIR}/${name}`)
- return yield* parseChangeset(body, `${CHANGESET_DIR}/${name}`)
- })
- ),
- )
- const releases = intents.filter((i): i is typeof i & { bump: ReleaseBump } => i.bump !== 'none')
- if (releases.length === 0) {
- for (const i of intents) yield* fs.remove(i.path)
- yield* Console.log('only none intents; consumed without version bump')
- return
- }
-
- const bump = releases.reduce((acc, i) => RANK[i.bump] >= RANK[acc.bump] ? i : acc).bump
- const summary = releases.map((i) => ` - ${i.summary}`).join('\n')
- const manifestText = yield* fs.readFileString(MANIFEST)
- const version = yield* extractJsonVersion(manifestText, MANIFEST)
- const next = yield* nextVersion(version, bump)
-
- const pluginBumped = yield* bumpAllSurfaces(next)
- if (!pluginBumped) {
- yield* Console.log('plugin manifest: none tracked — skipped')
- }
-
- const changelog = yield* fs.exists(CHANGELOG).pipe(
- Effect.flatMap((exists) =>
- exists ? fs.readFileString(CHANGELOG) : Effect.succeed('# Changelog\n')
- ),
- )
- yield* fs.writeFileString(
- CHANGELOG,
- `${changelog.trimEnd()}\n\n## ${next}\n\n${summary}\n`,
- )
-
- for (const i of intents) yield* fs.remove(i.path)
- yield* Console.log(`versioned packages to ${next}`)
-})
-
-runMain(program.pipe(Effect.provide(DenoPlatform)))
diff --git a/scripts/tools/sync-root-version.ts b/scripts/tools/sync-root-version.ts
deleted file mode 100755
index 7aa3098..0000000
--- a/scripts/tools/sync-root-version.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-#!/usr/bin/env -S deno run --allow-env=VERSION --allow-read --allow-write
-import { resolve } from '@std/path'
-import { diff } from '@libs/diff'
-import { parseCliArgs } from '../lib/cli.ts'
-import {
- LAUNCHER_MANIFEST_PATH,
- type LauncherManifest,
- type Target,
- TARGETS_PATH,
-} from '../lib/shared.ts'
-
-const VERSION_RE = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/
-
-const flags = parseCliArgs({
- alias: { 'dry-run': 'dryRun', 'manifest-path': 'manifestPath' },
- boolean: ['dry-run'],
- string: ['manifest-path', 'version'],
-})
-const dryRun = flags.dryRun === true
-const manifestPath = typeof flags.manifestPath === 'string'
- ? resolve(flags.manifestPath)
- : LAUNCHER_MANIFEST_PATH
-
-const original = await Deno.readTextFile(manifestPath)
-const manifest: LauncherManifest = JSON.parse(original)
-
-// Version priority: --version flag -> VERSION env var -> existing manifest.version (bumped by pnpm version)
-const rawVersion = typeof flags.version === 'string'
- ? flags.version
- : (Deno.env.get('VERSION') ?? manifest.version ?? '')
-
-if (!VERSION_RE.test(rawVersion) || rawVersion.includes('\n')) {
- console.error(`sync-root-version: invalid version: ${JSON.stringify(rawVersion)}`)
- Deno.exit(1)
-}
-const version = rawVersion
-
-const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
-if (!Array.isArray(targets) || targets.length !== 5) {
- console.error('sync-root-version: targets.json must declare exactly five platform targets')
- Deno.exit(1)
-}
-
-// The committed manifest carries no optionalDependencies — pnpm cannot lock
-// unpublished platform packages — so inject the pins at publish time, when they exist.
-manifest.optionalDependencies = Object.fromEntries(
- targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]),
-)
-
-// An unchanged sync must stay byte-identical: keep the file's indent and trailing newline.
-const next = JSON.stringify(manifest, null, 2) + (original.endsWith('\n') ? '\n' : '')
-
-if (dryRun) {
- // @libs/diff (patience algorithm) produces a real unified patch.
- console.log(diff(original, next))
-} else {
- await Deno.writeTextFile(manifestPath, next)
-}
diff --git a/scripts/tools/tag-released-packages.ts b/scripts/tools/tag-released-packages.ts
deleted file mode 100755
index f3a95b2..0000000
--- a/scripts/tools/tag-released-packages.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run=git --allow-read
-
-import { type Target, TARGETS_PATH } from '../lib/shared.ts'
-
-const MANIFEST = 'npm/packages/comment-checker/package.json'
-
-async function exec(cmd: string, args: string[]): Promise {
- const out = await new Deno.Command(cmd, {
- args,
- stdout: 'piped',
- stderr: 'inherit',
- }).output()
- if (!out.success) {
- throw new Error(`${cmd} ${args.join(' ')} failed`)
- }
- return new TextDecoder().decode(out.stdout)
-}
-
-const launcherManifest = JSON.parse(await Deno.readTextFile(MANIFEST))
-const version = launcherManifest.version as string
-const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
-
-const remoteTags = new Set(
- (await exec('git', ['ls-remote', '--tags', 'origin']))
- .split('\n')
- .filter(Boolean)
- .map((l) => l.replace(/.*refs\/tags\//, '').replace(/\^\{\}$/, '')),
-)
-
-const tagsToMake: string[] = []
-
-const rootTag = `v${version}`
-if (!remoteTags.has(rootTag)) {
- await exec('git', ['tag', rootTag])
- tagsToMake.push(rootTag)
-}
-
-for (const target of targets) {
- const platformTag = `@systemfsoftware/claude-code-comment-checker-${target.suffix}@v${version}`
- if (!remoteTags.has(platformTag)) {
- await exec('git', ['tag', platformTag])
- tagsToMake.push(platformTag)
- }
-}
-
-if (tagsToMake.length > 0) {
- await exec('git', ['push', 'origin', ...tagsToMake.map((t) => `refs/tags/${t}`)])
- console.log(`pushed ${tagsToMake.length} tag(s): ${tagsToMake.join(', ')}`)
-} else {
- console.log('no new tags to push')
-}
diff --git a/scripts/tools/verify-release-digests.ts b/scripts/tools/verify-release-digests.ts
deleted file mode 100755
index 7637ce5..0000000
--- a/scripts/tools/verify-release-digests.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run --allow-read --allow-write --allow-env
-
-import { join } from '@std/path'
-import { type Target, TARGETS_PATH } from '../lib/shared.ts'
-
-const version = Deno.env.get('VERSION')
-const refName = Deno.env.get('GITHUB_REF_NAME')
-const runnerTemp = Deno.env.get('RUNNER_TEMP') ?? '/tmp'
-
-if (!version || !refName) {
- console.error('VERSION and GITHUB_REF_NAME required')
- Deno.exit(1)
-}
-
-const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
-
-async function computeSha256(filePath: string): Promise {
- const bytes = await Deno.readFile(filePath)
- const digest = await crypto.subtle.digest('SHA-256', bytes)
- return Array.from(new Uint8Array(digest))
- .map((b) => b.toString(16).padStart(2, '0'))
- .join('')
-}
-
-for (const target of targets) {
- const sidecarPath = `sidecars/binary-${target.suffix}.sha256`
- const recordedSha = (await Deno.readTextFile(sidecarPath)).trim()
- if (!recordedSha) {
- console.error(`missing recorded sha for ${target.suffix}`)
- Deno.exit(1)
- }
-
- const tarballName = `comment-checker-${target.target}.tar.gz`
- const ghDl = await new Deno.Command('gh', {
- args: [
- 'release',
- 'download',
- refName,
- '--pattern',
- tarballName,
- '--dir',
- runnerTemp,
- '--clobber',
- ],
- }).output()
- if (!ghDl.success) {
- console.error(`gh release download failed for ${tarballName}`)
- Deno.exit(1)
- }
-
- const releaseUnpack = join(runnerTemp, `release-unpack-${target.suffix}`)
- await Deno.mkdir(releaseUnpack, { recursive: true })
- const tarRel = await new Deno.Command('tar', {
- args: ['-xzf', join(runnerTemp, tarballName), '-C', releaseUnpack],
- }).output()
- if (!tarRel.success) {
- console.error(`failed to unpack release tarball for ${target.suffix}`)
- Deno.exit(1)
- }
-
- const releaseBinSha = await computeSha256(join(releaseUnpack, target.bin))
- if (releaseBinSha !== recordedSha) {
- console.error(`release asset digest mismatch for ${target.suffix}`)
- Deno.exit(1)
- }
-
- const pkgName = `@systemfsoftware/claude-code-comment-checker-${target.suffix}`
- const packOut = await new Deno.Command('npm', {
- args: ['pack', `${pkgName}@${version}`, '--pack-destination', runnerTemp],
- stdout: 'piped',
- }).output()
- if (!packOut.success) {
- console.error(`npm pack failed for ${pkgName}@${version}`)
- Deno.exit(1)
- }
- const packFileName = new TextDecoder().decode(packOut.stdout).trim().split('\n').pop()!
-
- const npmUnpack = join(runnerTemp, `npm-unpack-${target.suffix}`)
- await Deno.mkdir(npmUnpack, { recursive: true })
- const tarNpm = await new Deno.Command('tar', {
- args: ['-xzf', join(runnerTemp, packFileName), '-C', npmUnpack],
- }).output()
- if (!tarNpm.success) {
- console.error(`failed to unpack npm tarball for ${target.suffix}`)
- Deno.exit(1)
- }
-
- const npmBinSha = await computeSha256(join(npmUnpack, 'package', target.bin))
- if (npmBinSha !== recordedSha) {
- console.error(`npm tarball digest mismatch for ${target.suffix}`)
- Deno.exit(1)
- }
-
- console.log(`${target.suffix} digests verified`)
-}
diff --git a/scripts/tools/verify-root-publish.ts b/scripts/tools/verify-root-publish.ts
deleted file mode 100755
index 49f8799..0000000
--- a/scripts/tools/verify-root-publish.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env
-
-import { type Target, TARGETS_PATH } from '../lib/shared.ts'
-
-const version = Deno.env.get('VERSION')
-if (!version) {
- console.error('VERSION environment variable required')
- Deno.exit(1)
-}
-
-const launcherName = '@systemfsoftware/claude-code-comment-checker'
-const targets: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
-
-const cmd = new Deno.Command('npm', {
- args: ['view', `${launcherName}@${version}`, 'version', 'optionalDependencies', '--json'],
- stdout: 'piped',
- stderr: 'piped',
-})
-const res = await cmd.output()
-if (!res.success) {
- console.error(`launcher ${launcherName}@${version} missing from npm`)
- Deno.exit(1)
-}
-
-const meta = JSON.parse(new TextDecoder().decode(res.stdout))
-if (meta.version !== version) {
- console.error(`version mismatch: got ${meta.version}, expected ${version}`)
- Deno.exit(1)
-}
-
-const optDeps = meta.optionalDependencies ?? {}
-for (const target of targets) {
- const pkg = `${launcherName}-${target.suffix}`
- if (optDeps[pkg] !== version) {
- console.error(
- `missing or incorrect optionalDependency pin for ${pkg}: got ${
- optDeps[pkg]
- }, expected ${version}`,
- )
- Deno.exit(1)
- }
- console.log(`${target.suffix} pin ok`)
-}