From e92d150af212163a28dbccb34300d5210b54048b Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 21 Aug 2026 03:28:21 +0000 Subject: [PATCH] feat(scripts): port 1-click deploy and npm trust tooling with lib/tools structure - Port publish-and-setup-npm-trust and check-publish tools adapted for generated packages - Delineate scripts/ into lib/ (pure helpers, manifest generators) and tools/ (CLI commands) - Declare strict, scoped permission shebangs on all tool scripts - Remove docs/publishing/ runbooks in favor of automated scripts and CONTRIBUTING.md - Update release.yml and repo documentation for the new script paths --- .github/workflows/release.yml | 30 ++- CONCEPTS.md | 2 +- CONTRIBUTING.md | 4 + README.md | 6 +- docs/publishing/first-publish-bootstrap.md | 122 ------------ docs/publishing/first-release-checklist.md | 99 ---------- .../rust-cli-npm-distribution.md | 18 +- scripts/deno.jsonc | 8 +- scripts/{release => lib}/cli.ts | 0 scripts/lib/distribution-set.ts | 90 +++++++++ scripts/lib/platform-manifest.ts | 43 ++++ scripts/{release => lib}/shared.ts | 2 +- scripts/{release => lib}/targets.json | 0 scripts/{release => tools}/check-matrix.ts | 4 +- scripts/tools/check-publish.ts | 185 ++++++++++++++++++ .../generate-platform-manifest.ts | 26 +-- scripts/tools/publish-and-setup-npm-trust.ts | 167 ++++++++++++++++ .../{release => tools}/sync-root-version.ts | 6 +- 18 files changed, 528 insertions(+), 284 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 docs/publishing/first-publish-bootstrap.md delete mode 100644 docs/publishing/first-release-checklist.md rename scripts/{release => lib}/cli.ts (100%) create mode 100644 scripts/lib/distribution-set.ts create mode 100644 scripts/lib/platform-manifest.ts rename scripts/{release => lib}/shared.ts (88%) rename scripts/{release => lib}/targets.json (100%) rename scripts/{release => tools}/check-matrix.ts (98%) create mode 100755 scripts/tools/check-publish.ts rename scripts/{release => tools}/generate-platform-manifest.ts (67%) create mode 100755 scripts/tools/publish-and-setup-npm-trust.ts rename scripts/{release => tools}/sync-root-version.ts (92%) 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.-]+)?$/