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`) -}