diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 06c7b94..72f24b3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,9 +60,9 @@ jobs:
- name: Resolve binary name from table
shell: bash
run: |
- BINARY_NAME="$(jq -r --arg target "${{ matrix.target }}" '.[] | select(.target == $target) | .bin' scripts/release/targets.json)"
+ BINARY_NAME="$(jq -r --arg target "${{ matrix.target }}" '.[] | select(.target == $target) | .bin' scripts/lib/targets.json)"
test -n "$BINARY_NAME" || {
- echo "target ${{ matrix.target }} missing from scripts/release/targets.json" >&2
+ echo "target ${{ matrix.target }} missing from scripts/lib/targets.json" >&2
exit 1
}
echo "BINARY_NAME=$BINARY_NAME" >> "$GITHUB_ENV"
@@ -116,8 +116,7 @@ jobs:
- name: "Gate: check-matrix (table vs workflow agreement)"
run: |
- deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts
-
+ deno run --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/tools/check-matrix.ts
- name: "Gate: binary exists"
shell: bash
run: |
@@ -152,14 +151,13 @@ jobs:
cp "$BIN" "$STAGE/${BINARY_NAME}"
SHA="$(sha256_of "$STAGE/${BINARY_NAME}")"
deno run \
- --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \
+ --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json \
--allow-write="$STAGE" \
- scripts/release/generate-platform-manifest.ts \
+ scripts/tools/generate-platform-manifest.ts \
--suffix "${{ matrix.suffix }}" \
--version "${GITHUB_REF#refs/tags/v}" \
--binary-sha256 "$SHA" \
--out "$STAGE"
- echo "$SHA" > "$RUNNER_TEMP/binary-${{ matrix.suffix }}.sha256"
echo "STAGE=$STAGE" >> "$GITHUB_ENV"
- name: Upload tar.gz + sha sidecar
@@ -244,7 +242,7 @@ jobs:
# normalization (libc as array, absent when null) makes the deep
# equality meaningful instead of always-true or always-false.
libc_norm='{os, cpu} + (if (.libc // null) != null then {libc} else {} end)'
- SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)"
+ SUFFIXES="$(jq -r '.[].suffix' scripts/lib/targets.json)"
test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; }
for SUFFIX in $SUFFIXES; do
PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}"
@@ -252,8 +250,7 @@ jobs:
echo "platform package $PKG@$VERSION missing" >&2
exit 1
}
- EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu]} + (if (.libc // null) != null then {libc: [.libc]} else {} end)' scripts/release/targets.json)"
- echo "$META" | jq -e -c --arg v "$VERSION" --argjson want "$EXPECTED" \
+ EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu]} + (if (.libc // null) != null then {libc: [.libc]} else {} end)' scripts/lib/targets.json)"
'.version == $v and ('"$libc_norm"') == $want' >/dev/null || {
echo "$PKG@$VERSION mismatch: $(echo "$META" | jq -c '{version, os, cpu, libc}') want $EXPECTED" >&2
exit 1
@@ -274,7 +271,7 @@ jobs:
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
- SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)"
+ SUFFIXES="$(jq -r '.[].suffix' scripts/lib/targets.json)"
test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; }
for SUFFIX in $SUFFIXES; do
SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")"
@@ -315,13 +312,11 @@ jobs:
run: |
# --allow-env required: VERSION arrives via the environment (KTD5:
# the git tag is the single version source). The deno.jsonc
- # manifest:sync-root task declares the identical permission set.
VERSION="$VERSION" deno run \
- --allow-env \
- --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \
+ --allow-env=VERSION \
+ --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json \
--allow-write=npm/packages/comment-checker/package.json \
- scripts/release/sync-root-version.ts
-
+ scripts/tools/sync-root-version.ts
- name: Publish root launcher (OIDC provenance)
shell: bash
run: |
@@ -336,8 +331,7 @@ jobs:
echo "root version mismatch: $(echo "$ROOT_META" | jq -c '.version')" >&2
exit 1
}
- SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)"
- for SUFFIX in $SUFFIXES; do
+ SUFFIXES="$(jq -r '.[].suffix' scripts/lib/targets.json)"
echo "$ROOT_META" | jq -e --arg k "@systemfsoftware/claude-code-comment-checker-${SUFFIX}" --arg v "$VERSION" \
'.optionalDependencies[$k] == $v' >/dev/null || {
echo "optional pin missing for $SUFFIX@$VERSION" >&2
diff --git a/CONCEPTS.md b/CONCEPTS.md
index a5ba0ce..c3e19a2 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -52,7 +52,7 @@ bin.
### Platform package
One per os-cpu pair (`-linux-x64`, `-darwin-arm64`, …), generated from
-`scripts/release/targets.json`: ships only the compiled binary and its
+`scripts/lib/targets.json`: ships only the compiled binary and its
manifest (`os`/`cpu`/`libc` fields, no `bin`). The launcher's
`optionalDependencies` pins all five to the release version.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..8bedd7d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,4 @@
+# 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`.
diff --git a/README.md b/README.md
index 17f5e22..32df561 100644
--- a/README.md
+++ b/README.md
@@ -53,10 +53,8 @@ Releases are tag-triggered: pushing a tag `vX.Y.Z` to `main` runs
all five target binaries, publishes the five platform packages and then the
root launcher — all with npm OIDC trusted publishing and provenance, no static
tokens in CI. The exact step sequence and per-package trusted-publisher bindings are documented
-in the release plan
-(`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`) and the
-first-release checklist (`docs/publishing/first-release-checklist.md`).
-
+in the release plan (`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`)
+and automated via `cd scripts && deno task publish:unpublished`.
To release:
1. Create the six [npm trusted-publisher
diff --git a/docs/publishing/first-publish-bootstrap.md b/docs/publishing/first-publish-bootstrap.md
deleted file mode 100644
index 8a0887c..0000000
--- a/docs/publishing/first-publish-bootstrap.md
+++ /dev/null
@@ -1,122 +0,0 @@
-# First npm Publish — Bootstrap (OIDC cannot precede existence)
-
-Run once, by a human with `systemfsoftware` npm org access, before the first
-tag-triggered release. After this bootstrap, `release.yml` publishes everything
-with OIDC provenance and no static tokens.
-
-## Why this one-time step exists
-
-npm's OIDC trusted publishing is configured **per package** on the package's
-settings page, and `npm trust` has an explicit "package must exist" prerequisite:
-. First-publish via OIDC is
-still not supported upstream (open issue:
-). So the six package names must be
-claimed once with a token, then the trusted-publisher records are configured.
-
-Measured 2026-08-19: all six names are unclaimed (`npm view` returns E404):
-`@systemfsoftware/claude-code-comment-checker` and the five platform packages
-(`-linux-x64`, `-linux-arm64`, `-darwin-x64`, `-darwin-arm64`, `-win32-x64`).
-
-## Prerequisites
-
-- npm account, logged in, 2FA enabled, member of the `systemfsoftware` org
- with publish rights on the `@systemfsoftware` scope.
-- `npm -v` >= 11.15.0 (needed for `npm trust`; `npm i -g npm@latest` if older).
-- This repo checked out; commands run from the repo root.
-
-## Publish six placeholders (dummy version)
-
-Every generated manifest carries `publishConfig.provenance: true` (npm honors
-that setting; on a laptop there is no OIDC token), so every bootstrap publish
-**must** pass `--no-provenance` or npm attempts OIDC and fails.
-
-```bash
-cd /home/ryan/Documents/projects/comment-checker.worktrees/comment-checker-npm
-DUMMY=0.0.0-dummy-npm # lowest semver; the real 0.1.0 becomes "latest"
-
-# 5 platform packages
-for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
- STAGE="/tmp/cc-bootstrap-$SUFFIX"
- rm -rf "$STAGE" && mkdir -p "$STAGE"
- deno run \
- --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \
- --allow-write="$STAGE" \
- scripts/release/generate-platform-manifest.ts \
- --suffix "$SUFFIX" --version "$DUMMY" --out "$STAGE"
- touch "$STAGE/$(jq -r '.files[0]' "$STAGE/package.json")" # placeholder binary in tarball
- # prerelease version (0.0.0-dummy-npm) requires an explicit --tag; "next"
- # keeps the placeholder off the "latest" dist-tag
- (cd "$STAGE" && npm publish --access public --no-provenance --tag next)
-done
-
-# root launcher (staged copy; repo file untouched)
-ROOT_STAGE=/tmp/cc-bootstrap-root
-rm -rf "$ROOT_STAGE" && mkdir -p "$ROOT_STAGE/dist"
-cp npm/packages/comment-checker/package.json "$ROOT_STAGE/package.json"
-touch "$ROOT_STAGE/dist/index.mjs"
-VERSION="$DUMMY" deno run --allow-env \
- --allow-read=scripts/release/targets.json,"$ROOT_STAGE/package.json" \
- --allow-write="$ROOT_STAGE/package.json" \
- scripts/release/sync-root-version.ts --manifest-path "$ROOT_STAGE/package.json"
-(cd "$ROOT_STAGE" && npm publish --access public --no-provenance --tag next)
-```
-
-Two npm gotchas this accounts for:
-
-- A prerelease version (hyphen suffix, e.g. `0.0.0-dummy-npm`) is rejected
- without an explicit `--tag` ("You must specify a tag using --tag when
- publishing a prerelease version"). `--tag next` satisfies it and keeps the
- placeholder off `latest`.
-- npm strips a `bin` entry whose path starts with `./` (`"bin": {…,
- "./dist/index.mjs"}` is silently removed at publish). The committed launcher
- manifest must use a bare relative path (`dist/index.mjs`).
-
-## Configure one trusted publisher per package
-
-CLI (first call prompts 2FA; the "skip 2FA for 5 minutes" option covers the
-rest; `--file` takes the workflow **filename only**, not a path per
-):
-
-```bash
-for PKG in \
- @systemfsoftware/claude-code-comment-checker \
- @systemfsoftware/claude-code-comment-checker-linux-x64 \
- @systemfsoftware/claude-code-comment-checker-linux-arm64 \
- @systemfsoftware/claude-code-comment-checker-darwin-x64 \
- @systemfsoftware/claude-code-comment-checker-darwin-arm64 \
- @systemfsoftware/claude-code-comment-checker-win32-x64; do
- npm trust github "$PKG" --file release.yml --repo systemfsoftware/comment-checker --allow-publish -y
- sleep 2
-done
-
-npm trust list @systemfsoftware/claude-code-comment-checker # sanity check
-```
-
-Web form (equivalent, per package): npmjs.com -> package -> Settings ->
-Trusted publishing -> GitHub Actions -> org `systemfsoftware`, repo
-`comment-checker`, workflow file `release.yml`, allowed action `npm publish`.
-
-## First real release (provenance on all six)
-
-```bash
-git tag v0.1.0 && git push origin v0.1.0
-```
-
-`release.yml` then builds and gates, publishes the five platform packages with
-`--provenance`, cross-checks published sha256 against the recorded sidecars,
-syncs the root version + optionalDependencies pins from the tag, publishes the
-root, and verifies the pins. All auth via OIDC.
-
-## Cleanup and don'ts
-
-- After 0.1.0 lands, the placeholders can be deprecated:
-
- ```bash
- for PKG in @systemfsoftware/claude-code-comment-checker{,-linux-x64,-linux-arm64,-darwin-x64,-darwin-arm64,-win32-x64}; do
- npm deprecate "$PKG@$DUMMY" "placeholder used to bootstrap OIDC trusted publishing"
- done
- ```
-
-- Do **not** `npm unpublish` a placeholder: deleting the only version deletes
- the package and its trusted-publisher config, breaking OIDC. Deprecation
- keeps name, config, and provenance trail intact.
\ No newline at end of file
diff --git a/docs/publishing/first-release-checklist.md b/docs/publishing/first-release-checklist.md
deleted file mode 100644
index 5238979..0000000
--- a/docs/publishing/first-release-checklist.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# First npm Release Checklist
-
-The release pipeline (`.github/workflows/release.yml`) stages everything; this
-checklist is the one-time org-admin setup plus the manual verification steps
-that only a human with `systemfsoftware` access can run (AGENTS.md Human
-Approval Boundaries). Work through it top to bottom.
-
-## 1. One-time org setup (npm + GitHub)
-
-- [ ] Confirm the GitHub repo default workflow permissions are read-only
- (Settings → Actions → General → Workflow permissions → Read repository
- contents and packages permissions). The workflow declares its own
- minimal per-job grants on top.
-- [ ] Create the six npm trusted-publisher entries
- (`npm access` / web form on the npm org):
- `@systemfsoftware/claude-code-comment-checker` plus the five platform
- packages (`-linux-x64`, `-linux-arm64`, `-darwin-x64`, `-darwin-arm64`,
- `-win32-x64`). Every entry binds to:
- - Organization / Repository: `systemfsoftware` / `comment-checker`
- - Workflow Filename: `release.yml` (filename only; npm's
- trusted-publisher form rejects full paths)
- - Environment: `npm-release` (recommended; see note below)
- npm's trusted-publisher form has **no tag-pattern field** — the
- `refs/tags/v*` gate is enforced by the workflow's `on: push: tags`
- filter, never by the registry-side record.
-- [ ] Brand-new package names: npm's trusted-publisher record requires the
- package to already exist (no first-publish via OIDC; see npm/cli#8544).
- Run the one-time token bootstrap in
- `docs/publishing/first-publish-bootstrap.md` (publishes a
- `0.0.0-dummy-npm` placeholder per name, then configures the six records;
- ~2 minutes).
-- [ ] Recommended: create a GitHub Environment named `npm-release` and add
- required reviewers to the publish jobs. Deferrable — if skipped, the
- convention is exact semver tags only. If added, the environment must be
- referenced in `publish-npm-main`'s `environment:` key in release.yml.
-- [ ] Confirm **no PAT** exists in any release job: only the default
- `GITHUB_TOKEN` (for uploading release assets) and `id-token: write` for
- npm OIDC provenance. Pull requests and tags must not carry secrets.
-
-## 2. Before the tag
-
-- [ ] `pnpm lint` and `deno task lint` green (repo gate).
-- [ ] `pnpm install --frozen-lockfile` succeeds from a fresh clone, and
- `pnpm -r build` + `pnpm -r typecheck` are green.
-- [ ] `scripts/release/check-matrix.ts` passes with `.github/workflows/release.yml`
- present: `deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts`
-- [ ] Cargo side (the release workflow runs its own build; there is no local
- build requirement, but the Rust gate is `cargo fmt --check && cargo
- clippy --all-targets -- -D warnings && cargo test --all-targets`).
-
-## 3. Tag and watch
-
-- [ ] Confirm the tag commit is an ancestor of the default branch (the
- workflow enforces this; also true by construction for the merge).
-- [ ] `git tag v0.1.0 && git push origin v0.1.0`.
-- [ ] Watch the release run: five `release-*` matrix jobs (one per target),
- `publish-npm-main`, `upload-gh-release-assets`. Each `release-*` job
- gates on check-matrix, binary existence, smoke (exit 0 clean / exit 2
- flagged), records the binary sha256 sidecar, and publishes its platform
- package with provenance.
-- [ ] The run fails fast if any gate trips; the root is never published when a
- platform package is missing or its published binary sha256 differs.
-
-## 4. Post-publish verification
-
-- [ ] `npm view @systemfsoftware/claude-code-comment-checker@v0.1.0 version` is
- exactly `0.1.0`, and `optionalDependencies` pins all five platform
- packages at `0.1.0` exactly (never a range).
-- [ ] Per suffix: `npm view @systemfsoftware/claude-code-comment-checker-@0.1.0`
- shows `version: 0.1.0` and `os`/`cpu`/`libc` equal to
- `scripts/release/targets.json` (workflow already gates this; re-check by
- hand here).
-- [ ] Provenance visible: `npm view @systemfsoftware/claude-code-comment-checker@0.1.0 provenance` (npmjs.org shows the OIDC origin).
-- [ ] Fresh install on Linux (CI simulates): `pnpm dlx @systemfsoftware/claude-code-comment-checker` or `npm i -g` and run the hook binary with a clean and a flagged payload.
-- [ ] Manual fresh install on macOS and on Windows (record both runs' outputs).
-
-## 5. Sanctions and escape hatches
-
-- [ ] Wrong binary / duplicate version recovery: npm versions are immutable —
- do **not** force-republish. Use `npm deprecate @` and ship
- the fixed binary as the next tag.
-- [ ] Amending an already-published version (rolling back) is not possible;
- the tag-reachability gate prevents stray tags from publishing
- unreviewed commits, but the final guard is the human before `git push`
- of the tag.
-
-## 6. Maintenance duty (recorded, one-off)
-
-- [ ] The workflow pins every third-party action to a full commit SHA. Keep
- the SHA→tag mapping fresh via a dependabot `github-actions` group so
- pins are updated deliberately, never silently.
-
-## 7. After the first release
-
-- [ ] README: remove the "pre-release" status note under Install and keep the
- npm install command as primary.
-- [ ] Record actual download counts vs the cargo-install era as the adoption
- signal (open item; not a release precondition).
-- [ ] Optionally deprecate the direct GitHub tarball path once npm is proven.
\ No newline at end of file
diff --git a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md
index 8608b3c..35f2973 100644
--- a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md
+++ b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md
@@ -2,7 +2,7 @@
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/release + .github/workflows/release.yml)
+module: npm distribution (npm/packages/comment-checker + scripts/lib,tools + .github/workflows/release.yml)
problem_type: architecture_pattern
component: tooling
severity: medium
@@ -39,7 +39,7 @@ five per-platform binary packages. That shape creates two hard constraints:
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/release/sync-root-version.ts:18-22`).
+ (`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
@@ -57,7 +57,7 @@ five per-platform binary packages. That shape creates two hard constraints:
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/release/targets.json` is the
+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
@@ -159,7 +159,7 @@ export const optionalDepName = (platform: string, arch: string): string =>
`@systemfsoftware/claude-code-comment-checker-${platform}-${arch}`
```
-`scripts/release/targets.json` — the table is the platform contract; every
+`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:
@@ -174,7 +174,7 @@ workflow's binary-name resolution, and the registry gate:
}
```
-`scripts/release/sync-root-version.ts` — the inject-at-publish move that
+`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:
@@ -184,7 +184,7 @@ manifest.optionalDependencies = Object.fromEntries(
)
```
-`scripts/release/check-matrix.ts` — the product policy the table must name:
+`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']
@@ -212,9 +212,9 @@ digest — the gate that catches a wrong or swapped binary being published.
## Related
- Pipeline: `.github/workflows/release.yml`
-- Platform table: `scripts/release/targets.json`
-- Scripts: `scripts/release/generate-platform-manifest.ts`,
- `scripts/release/sync-root-version.ts`, `scripts/release/check-matrix.ts`
+- 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
diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc
index 8a38f73..b3db6d3 100644
--- a/scripts/deno.jsonc
+++ b/scripts/deno.jsonc
@@ -6,9 +6,11 @@
"@libs/diff": "jsr:@libs/diff@4.0.0"
},
"tasks": {
- "manifest:generate": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json release/generate-platform-manifest.ts",
- "manifest:sync-root": "deno run --allow-env --allow-read=release/targets.json,../npm/packages/comment-checker/package.json --allow-write=../npm/packages/comment-checker/package.json release/sync-root-version.ts",
- "check-matrix": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json,../.github/workflows/release.yml release/check-matrix.ts",
+ "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 ."
},
"fmt": {
diff --git a/scripts/release/cli.ts b/scripts/lib/cli.ts
similarity index 100%
rename from scripts/release/cli.ts
rename to scripts/lib/cli.ts
diff --git a/scripts/lib/distribution-set.ts b/scripts/lib/distribution-set.ts
new file mode 100644
index 0000000..4a3c146
--- /dev/null
+++ b/scripts/lib/distribution-set.ts
@@ -0,0 +1,90 @@
+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 function remoteSlugFromRepo(repoRoot: string): string {
+ const cmd = new Deno.Command('git', {
+ args: ['-C', repoRoot, 'remote', 'get-url', 'origin'],
+ stdout: 'piped',
+ stderr: 'piped',
+ })
+ const res = cmd.outputSync()
+ 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/lib/platform-manifest.ts b/scripts/lib/platform-manifest.ts
new file mode 100644
index 0000000..e422f43
--- /dev/null
+++ b/scripts/lib/platform-manifest.ts
@@ -0,0 +1,43 @@
+import type { LauncherManifest, Target } from './shared.ts'
+
+export interface PlatformPackageManifest {
+ name: string
+ version: string
+ description: string
+ license: string
+ repository: { type: string; url: string }
+ os: [string]
+ cpu: [string]
+ files: [string]
+ publishConfig: { access: 'public'; provenance: true }
+ libc?: [string]
+ binarySha256?: string
+}
+
+export function buildPlatformManifest(
+ launcher: LauncherManifest,
+ entry: Target,
+ version: string,
+ binarySha256?: string,
+): PlatformPackageManifest {
+ const pkg: PlatformPackageManifest = {
+ name: `${launcher.name}-${entry.suffix}`,
+ version,
+ description: `${launcher.name} ${entry.suffix} platform package`,
+ license: 'Apache-2.0',
+ repository: launcher.repository,
+ os: [entry.os],
+ cpu: [entry.cpu],
+ files: [entry.bin],
+ // No bin field — a platform package's bin would collide with the launcher's
+ // own comment-checker shim.
+ publishConfig: { access: 'public', provenance: true },
+ }
+ if (entry.libc !== undefined) {
+ pkg.libc = [entry.libc]
+ }
+ if (binarySha256 !== undefined) {
+ pkg.binarySha256 = binarySha256
+ }
+ return pkg
+}
diff --git a/scripts/release/shared.ts b/scripts/lib/shared.ts
similarity index 88%
rename from scripts/release/shared.ts
rename to scripts/lib/shared.ts
index 8548904..2ae4eeb 100644
--- a/scripts/release/shared.ts
+++ b/scripts/lib/shared.ts
@@ -2,7 +2,7 @@ import { join } from '@std/path'
const ROOT = join(import.meta.dirname!, '..', '..')
-export const TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json')
+export const TARGETS_PATH = join(ROOT, 'scripts', 'lib', 'targets.json')
export const LAUNCHER_MANIFEST_PATH = join(
ROOT,
'npm',
diff --git a/scripts/release/targets.json b/scripts/lib/targets.json
similarity index 100%
rename from scripts/release/targets.json
rename to scripts/lib/targets.json
diff --git a/scripts/release/check-matrix.ts b/scripts/tools/check-matrix.ts
similarity index 98%
rename from scripts/release/check-matrix.ts
rename to scripts/tools/check-matrix.ts
index 60cb59a..757d3ce 100755
--- a/scripts/release/check-matrix.ts
+++ b/scripts/tools/check-matrix.ts
@@ -1,13 +1,13 @@
#!/usr/bin/env -S deno run --allow-read
import { resolve } from '@std/path'
-import { parseCliArgs } from './cli.ts'
+import { parseCliArgs } from '../lib/cli.ts'
import {
LAUNCHER_MANIFEST_PATH,
type LauncherManifest,
RELEASE_WORKFLOW_PATH,
type Target,
TARGETS_PATH,
-} from './shared.ts'
+} from '../lib/shared.ts'
// The product platform set: a known list the table must name, not a copy
// derived from the table under check.
diff --git a/scripts/tools/check-publish.ts b/scripts/tools/check-publish.ts
new file mode 100755
index 0000000..89731ab
--- /dev/null
+++ b/scripts/tools/check-publish.ts
@@ -0,0 +1,185 @@
+#!/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) {
+ Deno.stdout.writeSync(
+ new TextEncoder().encode(
+ JSON.stringify({
+ name: item.name,
+ kind: item.kind,
+ local_version: item.localVersion,
+ npm_latest: item.npmLatest,
+ class: item.classification,
+ attested: item.attested ? 'yes' : 'no',
+ }) + '\n',
+ ),
+ )
+ }
+} 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}`)
+ }
+
+ Deno.stdout.writeSync(new TextEncoder().encode(lines.join('\n') + '\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) {
+ Deno.stdout.writeSync(
+ new TextEncoder().encode(
+ '\nPREFLIGHT OK: every distribution package exists on the registry.\n',
+ ),
+ )
+ } else {
+ Deno.stderr.writeSync(
+ new TextEncoder().encode(
+ `\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) {
+ Deno.stderr.writeSync(
+ new TextEncoder().encode(
+ `\nFAIL: ${unpublishedTotal} unpublished, ${noOidcTotal} without OIDC attestation, ${errorTotal} unqueryable\n`,
+ ),
+ )
+ Deno.exit(1)
+ }
+ Deno.stdout.writeSync(
+ new TextEncoder().encode(
+ '\nOK: every package is published and carries provenance attestations.\n',
+ ),
+ )
+}
diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/tools/generate-platform-manifest.ts
similarity index 67%
rename from scripts/release/generate-platform-manifest.ts
rename to scripts/tools/generate-platform-manifest.ts
index 6df7318..22d5664 100755
--- a/scripts/release/generate-platform-manifest.ts
+++ b/scripts/tools/generate-platform-manifest.ts
@@ -1,12 +1,13 @@
#!/usr/bin/env -S deno run --allow-read --allow-write
import { join } from '@std/path'
-import { parseCliArgs } from './cli.ts'
+import { parseCliArgs } from '../lib/cli.ts'
+import { buildPlatformManifest } from '../lib/platform-manifest.ts'
import {
LAUNCHER_MANIFEST_PATH,
type LauncherManifest,
type Target,
TARGETS_PATH,
-} from './shared.ts'
+} from '../lib/shared.ts'
const TARGETS: Target[] = JSON.parse(await Deno.readTextFile(TARGETS_PATH))
const LAUNCHER: LauncherManifest = JSON.parse(await Deno.readTextFile(LAUNCHER_MANIFEST_PATH))
@@ -38,26 +39,7 @@ if (!entry) {
Deno.exit(1)
}
-const pkg: Record = {
- name: `${LAUNCHER.name}-${entry.suffix}`,
- version,
- description: `${LAUNCHER.name} ${entry.suffix} platform package`,
- license: 'Apache-2.0',
- repository: LAUNCHER.repository,
- os: [entry.os],
- cpu: [entry.cpu],
- files: [entry.bin],
- // No bin field — a platform package's bin would collide with the launcher's
- // own comment-checker shim.
- publishConfig: { access: 'public', provenance: true },
-}
-if (entry.libc !== undefined) {
- pkg.libc = [entry.libc]
-}
-if (binarySha256 !== undefined) {
- pkg.binarySha256 = binarySha256 as string
-}
-
+const pkg = buildPlatformManifest(LAUNCHER, entry, version, binarySha256)
const output = JSON.stringify(pkg, null, 2) + '\n'
if (args.dryRun) {
diff --git a/scripts/tools/publish-and-setup-npm-trust.ts b/scripts/tools/publish-and-setup-npm-trust.ts
new file mode 100755
index 0000000..66d5a7b
--- /dev/null
+++ b/scripts/tools/publish-and-setup-npm-trust.ts
@@ -0,0 +1,167 @@
+#!/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(
+ new Deno.Command('git', { args: ['rev-parse', '--show-toplevel'] }).outputSync().stdout,
+).trim()
+
+const slug = remoteSlugFromRepo(repoRoot)
+const { launcher, packages } = await readDistributionSet()
+
+const targetPackages = packages.filter((p) => !hasOnly || selectedOnly[p.name] === true)
+
+function logLine(msg: string) {
+ Deno.stdout.writeSync(new TextEncoder().encode(`${msg}\n`))
+}
+
+function logError(msg: string) {
+ Deno.stderr.writeSync(new TextEncoder().encode(`ERROR: ${msg}\n`))
+}
+
+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/release/sync-root-version.ts b/scripts/tools/sync-root-version.ts
similarity index 92%
rename from scripts/release/sync-root-version.ts
rename to scripts/tools/sync-root-version.ts
index 3e8b51b..89b8113 100755
--- a/scripts/release/sync-root-version.ts
+++ b/scripts/tools/sync-root-version.ts
@@ -1,13 +1,13 @@
-#!/usr/bin/env -S deno run --allow-read --allow-write
+#!/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 './cli.ts'
+import { parseCliArgs } from '../lib/cli.ts'
import {
LAUNCHER_MANIFEST_PATH,
type LauncherManifest,
type Target,
TARGETS_PATH,
-} from './shared.ts'
+} from '../lib/shared.ts'
const VERSION_RE = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/