diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2801e413..cde1874f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -61,6 +61,36 @@ "source": "./skills/socket-scan/socket-scan-setup", "skills": "./", "description": "Set up prerequisites for Socket scanning — install the CLI, configure auth with the public demo token, and verify scan access." + }, + { + "name": "socket-release", + "source": "./skills/socket-release", + "skills": "./", + "description": "Stand up SocketDev publishing (npm, crates.io, GitHub releases, Homebrew tap) in a repo: copy in the release kit and run its bootstrap — name reservation, GitHub environments, npm trusted publisher, staged publish config, verification." + }, + { + "name": "npm-publish", + "source": "./skills/socket-release/npm-publish", + "skills": "./", + "description": "Operate the socket-release npm flow end to end — bootstrap (permissive-then-staged-only publishing access), staged publish dispatch, soak, --approve promote, backfill, and rollback/deprecate." + }, + { + "name": "gh-release", + "source": "./skills/socket-release/gh-release", + "skills": "./", + "description": "Cut, verify, and reconcile immutable GitHub releases — the registry-resolvability ORDER RULE, the draft-upload-undraft cut, checksums.txt, and tag-gap healing." + }, + { + "name": "crates-publish", + "source": "./skills/socket-release/crates-publish", + "skills": "./", + "description": "Operate the socket-release crates.io flow — the cargo staged model, trusted publishing (OIDC), index-propagation waits, and yank-as-rollback." + }, + { + "name": "brew-tap", + "source": "./skills/socket-release/brew-tap", + "skills": "./", + "description": "Operate the socket-release Homebrew tap flow — tap repo layout, formula bumps tied to published releases, and sha256 verification against the release's own checksums.txt." } ] } diff --git a/.config/fleet/oxlintrc.json b/.config/fleet/oxlintrc.json index 1f55a3a6..65481025 100644 --- a/.config/fleet/oxlintrc.json +++ b/.config/fleet/oxlintrc.json @@ -1,7 +1,13 @@ { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/5306f24d9e82ae36ad9c3c964f33075bc589c799/npm/oxlint/configuration_schema.json", - "plugins": ["typescript", "unicorn", "import"], - "jsPlugins": ["./oxlint-plugin.mjs"], + "plugins": [ + "typescript", + "unicorn", + "import" + ], + "jsPlugins": [ + "./oxlint-plugin.mjs" + ], "categories": { "correctness": "error", "suspicious": "error" @@ -37,7 +43,9 @@ "socket/no-npx-dlx": "error", "socket/no-options-param-mutation": "error", "socket/no-package-manager-auto-update-reenable": "error", - "socket/no-parenthetical-aside": ["error"], + "socket/no-parenthetical-aside": [ + "error" + ], "socket/no-placeholders": "error", "socket/no-platform-specific-import": "error", "socket/no-private-path-in-source": "error", @@ -45,7 +53,9 @@ "socket/no-process-cwd-in-scripts-hooks": "error", "socket/no-promise-race": "error", "socket/no-promise-race-in-loop": "error", - "socket/no-required-in-options-bag": ["warn"], + "socket/no-required-in-options-bag": [ + "warn" + ], "socket/no-runtime-features-below-engine-floor": "error", "socket/no-source-content-tests": "error", "socket/no-source-sniffing": "error", @@ -64,7 +74,11 @@ "socket/no-vitest-skipped-tests": "error", "socket/no-vitest-standalone-expect": [ "error", - { "additionalTestBlockFunctions": ["cmdit"] } + { + "additionalTestBlockFunctions": [ + "cmdit" + ] + } ], "socket/no-which-for-local-bin": "error", "socket/normalize-path-before-match": "error", @@ -236,7 +250,12 @@ } }, { - "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + "files": [ + "**/*.ts", + "**/*.tsx", + "**/*.mts", + "**/*.cts" + ], "rules": { "eslint/no-unused-vars": "off" } @@ -300,6 +319,7 @@ "**/.mcp.json", "**/test/fleet/nock-loopback-passthrough.test.mts", "**/test/fleet/publish-infra-placeholder.test.mts", - "#fleet-canonical-end" + "#fleet-canonical-end", + "release-kit/payload/**" ] } diff --git a/.config/repo/coverage.json b/.config/repo/coverage.json new file mode 100644 index 00000000..af057fa9 --- /dev/null +++ b/.config/repo/coverage.json @@ -0,0 +1,10 @@ +{ + "include": ["release-kit/**/*.mts", "release-kit/**/*.mjs"], + "exclude": { + "add": [ + "release-kit/examples/**", + "release-kit/**/*.d.mts", + "release-kit/payload/scripts/socket-release/templates/**" + ] + } +} diff --git a/.config/repo/socket-wheelhouse.json b/.config/repo/socket-wheelhouse.json index a61f3b11..7801fc12 100644 --- a/.config/repo/socket-wheelhouse.json +++ b/.config/repo/socket-wheelhouse.json @@ -11,7 +11,7 @@ }, "fuzz": { "exempt": true, - "reason": "Skills content repo: the published artifact is markdown skills plus generated manifests. The only parsers (scripts/repo/lib/frontmatter.mts, scripts/repo/lib/validate-marketplace.mts) consume repo-tracked SKILL.md/marketplace.json files, never untrusted input — no fuzzable boundary." + "reason": "The release-kit payload's pure parsers (brew formula rewrite, npm access/trusted-publisher/staged page parsers, the pnpm-workspace catalog editor, and the installer manifest/path-safety + byte-parity checker) ARE covered by Tier-1 fast-check property suites under test/repo/unit/release-kit/fuzz/*.fuzz.test.mts. The Tier-2 vitiate coverage-guided lane (*.fuzz.ts) is not adopted: vitiate is not a catalog dependency here, and the skills content tooling itself parses only repo-tracked SKILL.md/marketplace.json, never untrusted input." }, "hooks": { "enablePrePush": true, diff --git a/.config/repo/tsconfig.release-kit.json b/.config/repo/tsconfig.release-kit.json new file mode 100644 index 00000000..f35f4e57 --- /dev/null +++ b/.config/repo/tsconfig.release-kit.json @@ -0,0 +1,8 @@ +{ + "extends": "../fleet/tsconfig.check.base.json", + "compilerOptions": { + "rootDir": "../.." + }, + "include": ["../../release-kit/**/*.mts"], + "exclude": ["**/node_modules"] +} diff --git a/README.md b/README.md index 09812467..7d51a57c 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,15 @@ This repository contains security-focused skills for dependency management. You Install, authenticate, and configure Socket for your project. -| Name | Description | Documentation | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| `socket-scan-setup` | Set up prerequisites for Socket scanning — install the CLI, configure auth with the public demo token, and verify scan access. | [SKILL.md](skills/socket-scan/socket-scan-setup/SKILL.md) | -| `socket-setup` | Set up Socket — prompt for API key, install the CLI, authenticate, configure policies and tokens, set up CI/CD for firewall or patch modes across GitHub, GitLab, Bitbucket, and other systems. | [SKILL.md](skills/socket-setup/SKILL.md) | +| Name | Description | Documentation | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `brew-tap` | Operate the socket-release Homebrew tap flow — tap repo layout, formula bumps tied to published releases, and sha256 verification against the release's own checksums.txt. | [SKILL.md](skills/socket-release/brew-tap/SKILL.md) | +| `crates-publish` | Operate the socket-release crates.io flow — the cargo staged model, trusted publishing (OIDC), index-propagation waits, and yank-as-rollback. | [SKILL.md](skills/socket-release/crates-publish/SKILL.md) | +| `gh-release` | Cut, verify, and reconcile immutable GitHub releases — the registry-resolvability ORDER RULE, the draft-upload-undraft cut, checksums.txt, and tag-gap healing. | [SKILL.md](skills/socket-release/gh-release/SKILL.md) | +| `npm-publish` | Operate the socket-release npm flow end to end — bootstrap (permissive-then-staged-only publishing access), staged publish dispatch, soak, --approve promote, backfill, and rollback/deprecate. | [SKILL.md](skills/socket-release/npm-publish/SKILL.md) | +| `socket-release` | Stand up SocketDev publishing (npm, crates.io, GitHub releases, Homebrew tap) in a repo: copy in the release kit and run its bootstrap — name reservation, GitHub environments, npm trusted publisher, staged publish config, verification. | [SKILL.md](skills/socket-release/SKILL.md) | +| `socket-scan-setup` | Set up prerequisites for Socket scanning — install the CLI, configure auth with the public demo token, and verify scan access. | [SKILL.md](skills/socket-scan/socket-scan-setup/SKILL.md) | +| `socket-setup` | Set up Socket — prompt for API key, install the CLI, authenticate, configure policies and tokens, set up CI/CD for firewall or patch modes across GitHub, GitLab, Bitbucket, and other systems. | [SKILL.md](skills/socket-setup/SKILL.md) | #### Analysis diff --git a/agents/README.md b/agents/README.md index d30baeb9..0f83dd7f 100644 --- a/agents/README.md +++ b/agents/README.md @@ -6,12 +6,17 @@ You have additional SKILLs documented in directories containing a "SKILL.md" fil | Skill | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| brew-tap | Operate the socket-release Homebrew tap flow — the binary-download formula model, tap repo layout, formula bumps tied to published releases, and sha256 verification against the release's own checksums.txt. Use when bumping a Homebrew formula or standing up a tap for a Socket CLI. | +| crates-publish | Operate the socket-release crates.io flow — the cargo staged model (dry-run default), trusted publishing via OIDC under the cargo-publish environment, index-propagation waits, and yank-as-rollback. Use when publishing a Rust crate in a repo carrying scripts/socket-release/. | +| gh-release | Cut, verify, and reconcile immutable GitHub releases with the socket-release kit — the registry-resolvability ORDER RULE, the three-step draft-upload-undraft cut, checksums.txt production, and tag-gap healing. Use when tagging a release, healing a missing tag/release, or when the github-release workflow gate refuses a tag. | +| npm-publish | Operate the socket-release npm flow end to end — bootstrap a package (name reservation, permissive-then-staged-only publishing access, trusted publishing), dispatch a staged publish, soak, promote with --approve, backfill an old version, and roll back with deprecate. Use when publishing an npm package in a repo carrying scripts/socket-release/. | | socket-dep-cleanup | Evaluate and remove a single unused dependency from your project. Searches the entire codebase for all usages (imports, requires, config refs, scripts, type packages, indirect usage), reports findings, and performs full removal with verification. | | socket-dep-patch | Apply Socket's binary-level security patches without changing dependency versions. Uses socket-patch apply to fix vulnerabilities in-place, then verifies automated patching is configured so patches persist across installs. | | socket-dep-replace | Replace a dependency with an alternative package, eliminate it via code rewrite, or use socket-optimize for optimized replacements. | | socket-dep-upgrade | Use socket fix to find and update vulnerable dependencies, then fix any breaking changes in the codebase. Security-audited upgrades with automated code migration. | | socket-fix | Fix dependency security issues — either scan and fix everything (requires /socket-scan), or target a single named package. Orchestrates /socket-dep-cleanup, /socket-dep-replace, /socket-dep-patch, and /socket-dep-upgrade as subskills. | | socket-inspect | Research a package before you depend on it — pull every signal from Socket (scores, alerts, malware verdicts, CVEs, supply-chain risk), check the socket.dev package page, evaluate alternatives, and surface available Socket patches. | +| socket-release | Stand up SocketDev publishing (npm, crates.io, GitHub releases, Homebrew tap) in a repo — copy in the socket-release kit from a sauce checkout and run its bootstrap through name reservation, GitHub environments, npm trusted publisher, publishing-access tightening, staged publish config, and verification. | | socket-scan | Run a dependency scan using the Socket CLI. Prompts unauthenticated users to log in or create a free account. If the user skips login, falls back to cdxgen with greatly reduced alert accuracy and poor SBOM accuracy. Authenticated users get temporary read-only scans by default (--tmp). Creates a persistent dashboard scan only when explicitly requested. Includes reachability analysis for enterprise customers and license compliance auditing. | | socket-scan-setup | Set up prerequisites for Socket scanning — install the CLI, configure auth with the public demo token, and verify scan access. Use this before the first scan or when encountering auth errors. | | socket-setup | Set up Socket — prompt for API key, install the CLI, authenticate, configure policies and tokens, set up CI/CD for firewall or patch modes across GitHub, GitLab, Bitbucket, and other systems. | @@ -28,6 +33,14 @@ Paths referenced within SKILL folders are relative to that SKILL. For example th The skills are located in: +- `skills/socket-release/brew-tap/SKILL.md` + +- `skills/socket-release/crates-publish/SKILL.md` + +- `skills/socket-release/gh-release/SKILL.md` + +- `skills/socket-release/npm-publish/SKILL.md` + - `skills/socket-fix/socket-dep-cleanup/SKILL.md` - `skills/socket-fix/socket-dep-patch/SKILL.md` @@ -40,6 +53,8 @@ The skills are located in: - `skills/socket-inspect/SKILL.md` +- `skills/socket-release/SKILL.md` + - `skills/socket-scan/SKILL.md` - `skills/socket-scan/socket-scan-setup/SKILL.md` diff --git a/release-kit/README.md b/release-kit/README.md new file mode 100644 index 00000000..ef6c46ba --- /dev/null +++ b/release-kit/README.md @@ -0,0 +1,389 @@ +# socket-release-kit + +A copy-in release kit for SocketDev repos: staged npm publishing with +trusted publishing (OIDC), crates.io trusted publishing, registry-gated +immutable GitHub releases, and Homebrew tap formula bumps — installed +byte-exact from `release-kit/payload/scripts/socket-release/` into a +consumer's `scripts/socket-release/`, verified by `kit-manifest.json`. + +The primary users are an operator AND their AI: one entry-point command per +flow, idempotent, resumable, dry-run by default where destructive, `--json` +machine output, precise exit codes, every step independently re-runnable, +and every human moment rendered as a fleet human gate — never an improvised +prompt. + +## Channels + +| channel | installs | workflow | +| ------------------ | ----------------------------------------------------------------------------------------------------- | -------------------- | +| `npm` | staged publish engine (`npm-publish.mts`, `publish-infra/npm/**`, web-auth router) | `npm-publish.yml` | +| `crates` | cargo staged engine (`cargo-publish.mts`, `publish-infra/cargo/**`) | `cargo-publish.yml` | +| `github-release` | liveness-gated release cut (`create-release.mts`, `github-release.mts`, `registry-liveness-gate.mjs`) | `github-release.yml` | +| `brew` | tap formula bump (`brew-publish.mts`, `publish-infra/brew/**`, app-token composite) | `brew-publish.yml` | +| `common` (implied) | the bootstrap, shared libs, config templates | — | + +## Install + +``` +node release-kit/install.mts --target --channels npm,github-release # plan +node release-kit/install.mts --target --channels npm,github-release --apply # copy +node release-kit/install.mts --target --channels npm,github-release --verify # byte-parity +``` + +Consumers pin three devDependencies (the payload imports plain specifiers): + +``` +pnpm add -D @socketsecurity/lib@6.5.2 @socketsecurity/sdk@4.1.3 playwright-core@1.61.1 +``` + +Runtime floor for the kit CLIs: Node >= 22.18 (native `.mts`). Consumers +must exclude `scripts/socket-release/**` from their own formatters/linters — +the installer's `--verify` pins byte-parity with the payload, and a consumer +formatter that rewrites the copies breaks it permanently (R11: sauce's +formatter is the ONE formatter these bytes ever see; `kit-manifest.json` +pins the post-format bytes). + +## Bootstrap + +``` +node scripts/socket-release/bootstrap.mts # plan everything (dry-run default) +node scripts/socket-release/bootstrap.mts --apply # stand it up +node scripts/socket-release/bootstrap.mts --status # receipts table +``` + +Eight steps in canonical order — `staged-config` runs BEFORE +`trusted-publisher` (trust is configured only for a workflow that actually +exists), and the two publishing-access steps bracket the placeholder: + +1. `preflight` — ten read-only checks (node floor, GitHub origin, gh auth, + pnpm stage support, npm trust support, kit deps, registry reachability, + access level). +2. `placeholder` — the ONE irreversible act: publish `@0.0.0` to claim + the name. Hard opt-in: `--apply` alone blocks on the reserve-name gate; + only `--apply --reserve ` publishes. Immediately + after the publish creates the package, publishing access is ensured + PERMISSIVE (direct + staged both enabled) so the one-time direct publish + can land. +3. `npm-access-permissive` — idempotent report/repair of that permissive + window. NEVER re-widens: once the name is live the step is already-done + by definition. +4. `github-env` — deployment environments (one per channel), each restricted + to exactly the default branch via custom branch policies. API before + browser: `gh api` PUT/list-before-POST first; a 403 renders the + github-environment gate (the browser path is gate TEXT only — no tool + drives github.com). +5. `staged-config` — the channel workflows (byte-identical to the local + templates), the four release scripts + `publishConfig.access` in + package.json (surgical edit), and the gitignore block. File writes only; + the operator commits. +6. `trusted-publisher` — `npm trust` through the PTY web-2FA router to the + law: github · `npm-publish.yml` · environment `npm-publish` · + createPackage + createStagedPackage. Reads fail CLOSED: any error + envelope is auth-death, never "(no config)". +7. `npm-access-staged-only` — TIGHTEN AFTER: with the placeholder live and + trusted publishing standing, disable DIRECT publishing in the npm web UI + (the sanctioned browser session drives the checkbox), leaving + staged/trusted publishing only. A second bootstrap run cannot re-enable + direct publishing: the permissive shape is planned only while the + placeholder is pending, and this step's done-predicate is the staged-only + read itself. +8. `verify` — read-only end-to-end proof of the terminal state: name live, + trust conforming, environments restricted, workflows on origin, + staged-config parity, and publishing access STAGED-ONLY (a package left + permissive FAILS with the exact remediation command). + +Exit codes: `0` passed/planned · `1` failed · `2` usage · `3` blocked on a +human gate · `4` precondition not done. The state file +(`.cache/socket-release/bootstrap-state.json`) is a reporting cache, never +authority — every step re-detects live state, and `--reset` loses only +history. + +## First publish (npm), end to end + +1. `node release-kit/install.mts --target --channels npm,github-release --apply`, + add the three dev-dependency pins, commit. +2. `node scripts/socket-release/bootstrap.mts` — read the plan. +3. `node scripts/socket-release/bootstrap.mts --apply` — it stops at the + reserve gate: + +``` +🖐 HUMAN GATE — reserve name [1/1] + Need: @example/pkg is unclaimed on npm; reserving it publishes a real 0.0.0 placeholder (access restricted). + Mind: publishing @example/pkg@0.0.0 is irreversible — the version is burned forever and unpublish closes after 72h — so no default run performs it; --reserve must name the exact package. + A) You: run `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @example/pkg` yourself. + B) Me: say "reserve the name" and I run `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @example/pkg` through its PTY — npm's web-2FA opens in your browser, I wait. + Then: the bootstrap resumes at placeholder. +``` + +4. During the publish the PTY surfaces npm's web-2FA: + +``` +🖐 HUMAN GATE — web-auth approve [1/1] + Need: the placeholder publish is waiting on npm's web-2FA approval in your browser. + Mind: npm's web-2FA URLs are single-use and short-lived; the waiting command must stay alive through the approval — killing it voids the URL. + A) You: open the APPROVE HERE url printed above in your browser and approve (expires in minutes) — tick the cooldown box so follow-up writes ride the same window. + B) Me: nothing extra to run — the PTY already holds the flow; tell me when the browser approval is done and I keep waiting for the exit. + Then: the publish completes and the bootstrap continues. +``` + +5. On a staging-enabled account the placeholder lands STAGED and the run + blocks on the promote gate: + +``` +🖐 HUMAN GATE — placeholder promote [1/1] + Need: @example/pkg@0.0.0 is staged (stage-0001) and waiting on promotion before the name resolves as live. + Mind: staged entries are maintainer-visible only — an unauthenticated or wrong-account stage list reads as EMPTY, not as an error; the approve pipeline identity-checks first. + A) You: run `node scripts/socket-release/npm-publish.mts --approve` — it promotes staged entry stage-0001 and prompts your 2FA. + B) Me: say "promote the placeholder" and I run `node scripts/socket-release/npm-publish.mts --approve` through its PTY — the 2FA challenge opens in your browser, I wait. + Then: the bootstrap resumes at placeholder. +``` + +6. Re-run `bootstrap.mts --apply` until `verify` reports stood-up. Commit + the staged-config writes and push. +7. First REAL release: bump version + CHANGELOG, commit + `chore: bump version to ` (load-bearing subject — reconcile + greps it), push, dispatch `npm-publish` from the Actions UI + (`publish: true`), then promote locally: + `node scripts/socket-release/npm-publish.mts --approve`. The tag + + immutable GitHub release follow automatically once the version is live + (ORDER RULE: the release is the FINAL marker, never the first). + +If npm auth ever dies mid-flow, the gate is always the same: + +``` +🖐 HUMAN GATE — npm auth [1/1] + Need: the local npm token is missing or expired (`npm whoami` → 401). + Mind: raw `npm login` dies without a TTY (legacy Username prompt EOFs) and bare `npm` fails in-repo (devEngines pins pnpm); the router carries both limitations so neither lane can hit them. + A) You: run `cd && node scripts/socket-release/npm-web-auth.mts login` in your terminal — same flow, you drive. + B) Me: say "log me in" and I run `cd && node scripts/socket-release/npm-web-auth.mts login` through its PTY — your browser opens for the OAuth + OTP, I wait. + Then: the bootstrap resumes at the blocked step. +``` + +## First brew bump + +Prerequisite (one-time, manual — deferral #6): the tap repo +`SocketDev/homebrew-socket` exists with an unsharded `Formula/` directory +and a README documenting `HOMEBREW_REQUIRE_TAP_TRUST=1` → +`brew trust SocketDev/socket`. The layout is modeled by +`examples/brew-cli/tap-fixture/`. + +1. Cut the release first: registry publish → tag → GitHub release with + assets + `checksums.txt` (cut releases with + `scripts/socket-release/github-release.mts --tag vX.Y.Z --release`; the + npm/cargo release tail writes sha1/sha256/sha512-base64 lines per asset). + brew-publish refuses a + missing tag, a draft release, a missing asset, and a missing + checksums.txt — the four refusals are byte contracts. +2. `node scripts/socket-release/brew-publish.mts --tag vX.Y.Z` — dry-run + plan (action, tap repo, path, version, four sha256s, the exact apply + command). +3. `node scripts/socket-release/brew-publish.mts --tag vX.Y.Z --apply` — + GitHub-signed API commit direct to the tap default branch (never a PR), + then a re-read that must parse back to the desired formula. In CI the + `brew-publish.yml` workflow runs the same command with a per-run App + token minted by `./.github/actions/socket-release-app-token` from the + org-wide App credentials (org secrets are enterprise-wide — never + per-repo setup, never a human task). +4. Manual audit on an operator Mac (deferral #7): `brew style` / + `brew audit` against the tap. + +## Contract-drift posture (what detects an npm change) + +Committed goldens pin PURE logic only; CI opens no network socket. Registry +or CLI wire drift is detected by, in order: + +1. Every parser's mandatory unknown-shape-refuses arm: an unrecognized + `npm trust list` shape, stage list, environments response, or + publishing-access page classifies as a REFUSAL (`auth-died`, `unknown`, + `garbled`) — never a default classification. Drift surfaces as a loud + runtime refusal, never a wrong answer. Pinned by tests. +2. The bootstrap `verify` step is the designated LIVE contract test: it + drives the real packument, real `npm trust list`, and real `gh api` + through the REAL parsers. Run `bootstrap.mts verify` after any npm/pnpm + CLI upgrade. +3. Fixture refresh: when a wire shape legitimately changes, update the + synthetic fixtures under `test/repo/unit/release-kit/fixtures/` from the + observed new shape and change the goldens in the SAME commit — each + fixture carries a `_note`/header naming its authority and date. + +Browser-page fixtures are synthetic, hand-authored from the documented wire +contract markers (`id="github-repoInfo"`, the `allowPublish` / +`allowStagePublish` and `allowDirectPublish` / `allowStagedPublish` checkbox +names, the escaped-JSON initial-data keys) — producing real captures needs a +signed-in session; refuse-don't-misclassify is the compensating control. + +## Manual version bumps + +CI auto-bump is deferred (#1). Bump by hand: edit `version`, update the +CHANGELOG, commit with the load-bearing subject +`chore: bump version to ` (reconcile.mts greps that exact shape), +push, then dispatch the publish workflow. + +## Deferrals (explicit) + +1. CI auto-bump (`--bump`/`--release-as`, bump/changelog/release-branch + modules; `lib/release-anchor.mts` ships as a type shim only). +2. Pipeline receipts layer (`release-pipeline/**`, reconcile-gap healers) — + resumability lives in the bootstrap. +3. Remote dispatch helpers — humans dispatch from the Actions UI + (`gh workflow run` is guard-blocked for agents). +4. Multi-crate topological cargo ordering — single-crate only; ambiguity + refusal retained. +5. npm trusted-publisher browser WRITE lane — dead (2026-07-31, 132/132); + the read-side page modules ship, writes ride `npm trust` via the PTY + router. The publishing-access toggles are the one sanctioned browser + WRITE (owner directive), driven only through the sanctioned session. +6. Tap-repo scaffolder — creating `SocketDev/homebrew-socket` is a one-time + manual act; the layout is documented + modeled by `examples/brew-cli`. +7. Tap-repo formula-audit CI — `brew style`/`brew audit` run manually per + the first-brew procedure. +8. Windows PTY (no wrapping on win32 — inherited). +9. Kit self-distribution as a release tarball — consumers install from a + sauce checkout. +10. `go` channel. +11. Operator fixture-capture script + scrubber + leak-hygiene test (needs a + real signed-in session; synthetic fixtures instead). +12. Stub-bin PATH e2e harness — CLI boundary covered by spawn smokes + + in-process integration with fully fake seams. +13. Coverage thresholds / mirror-name ratchets / actionlint in consumers — + staged-config byte detection + verify cover template drift there; + sauce's own fleet gates already run here. +14. Standalone runbook docs — folded into this README and the skills. + +## Layout + +``` +release-kit/ +├── README.md this file +├── gen-manifest.mts (re)generate kit-manifest.json; --check +├── install.mts the installer CLI +├── install/{manifest,plan,seams}.mts +├── examples/{npm-lib,rust-crate,brew-cli}/ +└── payload/scripts/socket-release/ the copy-in engine (see kit-manifest.json) +``` + +Sauce-side gates: `scripts/repo/check/release-kit-is-coherent.mts`, +`release-kit-launches-are-sanctioned.mts`, +`release-kit-workflows-are-env-mapped.mts`, +`release-kit-types-resolve.mts` — auto-discovered by +`pnpm run check` (repo-owned checks run in the gate on every push). Tests +live under `test/repo/unit/release-kit/` and +`test/repo/integration/release-kit/`, all offline, importing straight from +the payload. + +## Architecture — the npm thread, file by file + +Every flow (npm, cargo, brew, github-release) runs the same shape: +`config → converge → stage → approve → mark → verify → heal`. **Knowing the +npm thread teaches the others** — the cargo tier mirrors it phase for phase, +and brew swaps the promotion phases for a `plan → apply` formula bump. Read +the npm release thread in this order (each line names the real payload file): + +1. `templates/workflows/npm-publish.yml` — the CI invocation a consumer copies + into `.github/workflows/`; dispatches the entry with `publish: true`. +2. `npm-publish.mts` — the flow entry (Layer 1): usage header, `OPTIONS`, + unknown-flag rejection, mode dispatch (stage / approve / direct), and + access + dist-tag resolution. It wires the tier below; it holds no + registry logic of its own. +3. `_shared/cli-flags.mts` — the usage gate: any flag the entry did not + declare exits 2 before a single registry read. +4. `publish-infra/npm/backfill.mts` — the pure gap-fill gate, consulted only + under `--backfill`: which already-cut versions still need a tag/release. +5. `publish-infra/npm/staged.mts` — stage orchestration (`runStaged` / + `runDirect` / `verifyStagedEntry`), which drives in turn + `pack-preflight.mts` (hollow-tarball gate) → `pack-manifest.mts` + + `_shared/{lifecycle-scripts,pack-files}.mts` (the pure pack surface) → + `publish-infra/shared.mts` (the process seam: spawn / PTY / git / JSON) → + `publish-infra/npm/registry.mts` (the registry read) → + `lib/verify-release-hashes.mts` (byte-verify of the staged entry). +6. `publish-infra/npm/approve.mts` — promote orchestration (`runApprove`), + which drives `login.mts` / `auth-identity.mts` (auth seams: logged-out vs + wrong-user repair) → `shared.mts` (the stage-list wire parse, the kit's + most safety-critical parser) → `scan.mts` / `threat-scan.mts` (Socket + full-scan gate and local threat scan) → `lib/verify-release-hashes.mts` + (the three-way hash gate before promote). +7. `publish-infra/release.mts` — the release tail: `releaseBehindLiveGate` + (registry-live gate) → `ensureTagAndRelease` + `extractChangelogSection`, + driving `lib/github-git-refs.mts` (the gh seam). +8. `publish-infra/reconcile.mts` — post-publish git alignment (fetch the + published version, find its base sha, rebase/sync onto it). +9. Failure tail — the healer: `_shared/release-gap-recovery.mts` → + `registry-liveness-gate.mjs` (the CI gate job, npm- AND crates-aware) → + `github-release.mts` (cuts the immutable release once the registry is live). + +The layers the thread crosses: + +| Layer | Home | Holds | +| --------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| 0 kit tooling | `release-kit/{install,gen-manifest}.mts`, `install/{manifest,plan,seams}.mts` | installer + manifest generator + pure copy planner behind `InstallSeams` (not shipped) | +| 1 flow entries | payload root | one CLI per flow; grandfathered residents `npm-web-auth.mts`, `registry-liveness-gate.mjs`(+`.d.mts`), `paths.mts`, `kit-manifest.json` | +| 2 orchestration | `bootstrap/`, `publish-infra//` | read → classify → plan → apply per step/phase | +| 3 pure logic | `plan / parse / render / state / config / gates` | no `node:{fs,child_process,net,http}` | +| 4 effect seams | `seams.mts`, `shared.mts`, registry / browser / git / gh | injectable effects | +| 5 shared homes | `_shared/` · `lib/` · `constants/` · `util/` | fleet-mirrored · kit-local cross-flow · import-free data · frozen target tables | +| 6 templates | `templates/` | adoption docs (comments allowed) | +| 7 tests | `test/repo/**` (never in the payload) | mirror the payload path | + +The distinction between `_shared/` and `lib/` is provenance, not taste: +`_shared/` holds ONLY files whose relative path also exists under the fleet's +`scripts/fleet/_shared/` (fleet-mirrored); `lib/` holds kit-local cross-flow +logic. This is the fleet's own taxonomy — it is documented, never merged. + +Known divergence pending removal: `create-release.mts` + +`lib/release-checksums/` is a dead second github-release entry (superseded by +`github-release.mts`); it is grandfathered in the root-entry allowlist below +until it is deleted fleet-side. + +## The naming law + +New payload files MUST conform. Rules 1 and 6 are enforced mechanically by +`scripts/repo/check/release-kit-is-coherent.mts` (which also pins manifest +freshness, the pure/effects import split, and the no-tests-in-payload rule); +the remainder is review law. A rename that touches a file whose relative path +exists under `scripts/fleet/` is done **fleet-first or not at all**. + +1. **Entries.** The payload root holds exactly one CLI per flow, named + `-.mts`, act ∈ {`publish`, `release`}: `npm-publish`, + `cargo-publish`, `brew-publish`, `github-release`. An entry contains only + its usage header, `OPTIONS`, unknown-flag rejection (exit 2), mode + dispatch, and the `isMainModule` guard. The only other permitted root + residents are `bootstrap.mts` (the one-time bootstrap CLI) and the + grandfathered `npm-web-auth.mts`, `registry-liveness-gate.mjs`(+`.d.mts`), + `paths.mts`, `kit-manifest.json` (and, until deleted, `create-release.mts`). + **Nothing else may be added at root.** _(machine-enforced)_ +2. **Tiers.** A flow's implementation lives in `publish-infra//` using + phase names from the closed set `{registry, staged, approve, placeholder, +trusted-publisher}`; flow-specific nouns (brew `formula`/`tap`) appear in + the layout table. A phase-named file contains only that phase. +3. **Pure vs effect.** PURE ⇔ imports no `node:{fs,child_process,net,http}`. + Pure modules take the nouns `plan / parse / render / state / config / +gates`. Every injectable effect module is `seams.mts` exporting + `Seams` + `resolveSeams`; browser lanes are the triple + `-{plan,parse,page}` sharing `browser-session.mts`. +4. **Gate vocabulary.** A refusal point is a _gate_ — the only sanctioned + word. `check` is reserved for CI checks; `law` may name a data constant + (`trustedPublisherLaw`) but never a module; `preflight` is reserved to the + bootstrap step id and `pack-preflight.mts`. +5. **Two-phase verbs.** Artifact-promotion flows speak `stage → approve` + (npm, cargo — crates.io has no dist-tags and no unpublish, so its promote + is a permanent one-way approve). Idempotent-convergence flows speak + `plan → apply` (bootstrap, install, and brew — a formula bump tied to an + already-published release). Never mix the two within a flow; `--dry-run` + stays a flag name only. +6. **Suffixes.** `.mts` always; `.mjs` only when the script must run on system + Node before any install (workflow gate jobs, composite-action scripts), and + any `.mjs` imported from TypeScript carries a `.d.mts` sidecar. + _(machine-enforced)_ +7. **Shared homes (closed list).** `_shared/` = fleet-mirrored (same relative + path under `scripts/fleet/_shared/`); `lib/` = kit-local cross-flow; + `constants/` = import-free data; `util/` is frozen (no new files). +8. **Tests mirror paths.** `test/repo/unit/release-kit/.test.mts` + (consumers: `tests/socket-release/.test.mts`). + Cross-module scenario tests are `.flow.test.mts`. A new payload + module lands with its mirror test in the same commit. +9. **Parity.** Every file in an adopted channel is byte-identical with the + payload — `install.mts --verify` is the oracle. +10. **Renames.** Any payload rename regenerates `kit-manifest.json`, updates + `channelsForPath` when an exact filename is pinned, and updates the + coherence check + `shipped-surfaces.mts` + the `skills/socket-release/*` + docs in the same commit. diff --git a/release-kit/examples/brew-cli/README.md b/release-kit/examples/brew-cli/README.md new file mode 100644 index 00000000..c72b8b0c --- /dev/null +++ b/release-kit/examples/brew-cli/README.md @@ -0,0 +1,16 @@ +# brew-cli example + +A CLI published to npm plus a Homebrew tap: channels `npm`, `github-release`, +`brew`. The tap layout is modeled by `tap-fixture/` (an unsharded +`Formula/examplecli.rb` — the same shape `SocketDev/homebrew-socket` +carries), and `release-fixture/checksums.txt` shows BOTH checksum grammars +the brew tooling accepts: the kit release tail's `sha256: ` +lines and plain ` ` shasum lines. The formula sha256s always +come from this manifest — brew-publish never re-hashes an asset. + +Tap consumers run once: + +``` +export HOMEBREW_REQUIRE_TAP_TRUST=1 +brew trust SocketDev/socket +``` diff --git a/release-kit/examples/brew-cli/expected-install.json b/release-kit/examples/brew-cli/expected-install.json new file mode 100644 index 00000000..ca9f4e5e --- /dev/null +++ b/release-kit/examples/brew-cli/expected-install.json @@ -0,0 +1,95 @@ +{ + "channels": ["common", "npm", "github-release", "brew"], + "files": [ + "scripts/socket-release/_shared/cli-flags.mts", + "scripts/socket-release/_shared/human-gate.mts", + "scripts/socket-release/_shared/is-main-module.mts", + "scripts/socket-release/_shared/lifecycle-scripts.mts", + "scripts/socket-release/_shared/mirror-lock.mts", + "scripts/socket-release/_shared/pack-files.mts", + "scripts/socket-release/_shared/playwright-law.mts", + "scripts/socket-release/_shared/release-gap-recovery.mts", + "scripts/socket-release/_shared/release-subject.mts", + "scripts/socket-release/_shared/run-main.mts", + "scripts/socket-release/_shared/tar-executable.mts", + "scripts/socket-release/_shared/unix-path.mts", + "scripts/socket-release/bootstrap.mts", + "scripts/socket-release/bootstrap/config.mts", + "scripts/socket-release/bootstrap/gates.mts", + "scripts/socket-release/bootstrap/plan.mts", + "scripts/socket-release/bootstrap/render.mts", + "scripts/socket-release/bootstrap/seams.mts", + "scripts/socket-release/bootstrap/state.mts", + "scripts/socket-release/bootstrap/steps/github-env.mts", + "scripts/socket-release/bootstrap/steps/npm-access-permissive.mts", + "scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts", + "scripts/socket-release/bootstrap/steps/placeholder.mts", + "scripts/socket-release/bootstrap/steps/preflight.mts", + "scripts/socket-release/bootstrap/steps/staged-config.mts", + "scripts/socket-release/bootstrap/steps/trusted-publisher.mts", + "scripts/socket-release/bootstrap/steps/verify.mts", + "scripts/socket-release/brew-publish.mts", + "scripts/socket-release/constants/npm-registry.mts", + "scripts/socket-release/create-release.mts", + "scripts/socket-release/github-release.mts", + "scripts/socket-release/kit-manifest.json", + "scripts/socket-release/lib/commit-via-github-api.mts", + "scripts/socket-release/lib/github-git-refs.mts", + "scripts/socket-release/lib/release-anchor.mts", + "scripts/socket-release/lib/release-checksums/core.mts", + "scripts/socket-release/lib/release-checksums/producer.mts", + "scripts/socket-release/lib/verify-release-hashes.mts", + "scripts/socket-release/lib/workspace-yaml.mts", + "scripts/socket-release/npm-publish.mts", + "scripts/socket-release/npm-web-auth.mts", + "scripts/socket-release/paths.mts", + "scripts/socket-release/publish-infra/brew/formula.mts", + "scripts/socket-release/publish-infra/brew/shared.mts", + "scripts/socket-release/publish-infra/brew/tap.mts", + "scripts/socket-release/publish-infra/npm/access-page.mts", + "scripts/socket-release/publish-infra/npm/access-parse.mts", + "scripts/socket-release/publish-infra/npm/access-plan.mts", + "scripts/socket-release/publish-infra/npm/approve.mts", + "scripts/socket-release/publish-infra/npm/auth-identity.mts", + "scripts/socket-release/publish-infra/npm/backfill.mts", + "scripts/socket-release/publish-infra/npm/browser-session.mts", + "scripts/socket-release/publish-infra/npm/browser-sign-in.mts", + "scripts/socket-release/publish-infra/npm/login.mts", + "scripts/socket-release/publish-infra/npm/pack-manifest.mts", + "scripts/socket-release/publish-infra/npm/pack-preflight.mts", + "scripts/socket-release/publish-infra/npm/pinned-npm.mts", + "scripts/socket-release/publish-infra/npm/placeholder.mts", + "scripts/socket-release/publish-infra/npm/provenance.mts", + "scripts/socket-release/publish-infra/npm/registry.mts", + "scripts/socket-release/publish-infra/npm/scan.mts", + "scripts/socket-release/publish-infra/npm/shared.mts", + "scripts/socket-release/publish-infra/npm/staged-browser-parse.mts", + "scripts/socket-release/publish-infra/npm/staged-browser-read.mts", + "scripts/socket-release/publish-infra/npm/staged-workspace.mts", + "scripts/socket-release/publish-infra/npm/staged.mts", + "scripts/socket-release/publish-infra/npm/threat-scan.mts", + "scripts/socket-release/publish-infra/npm/trust-sweep.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-page.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-plan.mts", + "scripts/socket-release/publish-infra/npm/workspace-plan.mts", + "scripts/socket-release/publish-infra/npm/workspace.mts", + "scripts/socket-release/publish-infra/pin-readme.mts", + "scripts/socket-release/publish-infra/reconcile.mts", + "scripts/socket-release/publish-infra/release.mts", + "scripts/socket-release/publish-infra/shared.mts", + "scripts/socket-release/publish-infra/socket-oauth.mts", + "scripts/socket-release/registry-liveness-gate.d.mts", + "scripts/socket-release/registry-liveness-gate.mjs", + "scripts/socket-release/templates/actions/socket-release-app-token/action.yml", + "scripts/socket-release/templates/actions/socket-release-app-token/mint-app-installation-token.mjs", + "scripts/socket-release/templates/config/socket-release.json", + "scripts/socket-release/templates/gitignore-block.txt", + "scripts/socket-release/templates/workflows/brew-publish.yml", + "scripts/socket-release/templates/workflows/github-release.yml", + "scripts/socket-release/templates/workflows/npm-publish.yml", + "scripts/socket-release/util/napi-targets.mts", + "scripts/socket-release/util/pack-app-triplets.mts" + ] +} diff --git a/release-kit/examples/brew-cli/package.json b/release-kit/examples/brew-cli/package.json new file mode 100644 index 00000000..266706ec --- /dev/null +++ b/release-kit/examples/brew-cli/package.json @@ -0,0 +1,13 @@ +{ + "name": "examplecli", + "version": "1.2.3", + "description": "Example CLI consumer for socket-release-kit with a Homebrew tap channel.", + "license": "MIT", + "bin": { + "examplecli": "bin/examplecli.js" + }, + "files": [ + "bin" + ], + "packageManager": "pnpm@11.17.0" +} diff --git a/release-kit/examples/brew-cli/release-fixture/checksums.txt b/release-kit/examples/brew-cli/release-fixture/checksums.txt new file mode 100644 index 00000000..09133dc0 --- /dev/null +++ b/release-kit/examples/brew-cli/release-fixture/checksums.txt @@ -0,0 +1,6 @@ +sha1: 4b7ab266b0e2b6b1fe287a42d2b119ac2c1a2b71 examplecli-darwin-arm64.tar.gz +sha256: 1111111111111111111111111111111111111111111111111111111111111111 examplecli-darwin-arm64.tar.gz +sha512-base64: SGVsbG8gZnJvbSB0aGUga2l0IGV4YW1wbGUgZml4dHVyZQ== examplecli-darwin-arm64.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 examplecli-darwin-x64.tar.gz +3333333333333333333333333333333333333333333333333333333333333333 examplecli-linux-arm64.tar.gz +4444444444444444444444444444444444444444444444444444444444444444 examplecli-linux-x64.tar.gz diff --git a/release-kit/examples/brew-cli/socket-release.json b/release-kit/examples/brew-cli/socket-release.json new file mode 100644 index 00000000..35c8dc62 --- /dev/null +++ b/release-kit/examples/brew-cli/socket-release.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "channels": ["npm", "github-release", "brew"], + "npm": { "access": "public", "distTag": "latest" }, + "brew": { + "tap": "SocketDev/socket", + "formula": "examplecli", + "assetTemplate": "-.tar.gz", + "triplets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] + } +} diff --git a/release-kit/examples/brew-cli/tap-fixture/Formula/examplecli.rb b/release-kit/examples/brew-cli/tap-fixture/Formula/examplecli.rb new file mode 100644 index 00000000..3822cfc2 --- /dev/null +++ b/release-kit/examples/brew-cli/tap-fixture/Formula/examplecli.rb @@ -0,0 +1,39 @@ +# Managed by socket-release-kit (scripts/socket-release/brew-publish.mts). +# Do not hand-edit: the next formula bump rewrites this file from the +# release's own checksums.txt. +class Examplecli < Formula + desc "examplecli (Socket release)" + homepage "https://github.com/SocketDev/example-cli" + version "1.2.3" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-arm64.tar.gz" + sha256 "1111111111111111111111111111111111111111111111111111111111111111" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-x64.tar.gz" + sha256 "2222222222222222222222222222222222222222222222222222222222222222" + end + end + + on_linux do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-arm64.tar.gz" + sha256 "3333333333333333333333333333333333333333333333333333333333333333" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-x64.tar.gz" + sha256 "4444444444444444444444444444444444444444444444444444444444444444" + end + end + + def install + bin.install "examplecli" + end + + test do + assert_match version.to_s, shell_output("#{bin}/examplecli --version") + end +end diff --git a/release-kit/examples/npm-lib/README.md b/release-kit/examples/npm-lib/README.md new file mode 100644 index 00000000..fc85f6ed --- /dev/null +++ b/release-kit/examples/npm-lib/README.md @@ -0,0 +1,15 @@ +# npm-lib example + +A minimal scoped npm library the release kit stands up on the `npm` + +`github-release` channels. `socket-release.json` is the config the installer +would seed (restricted access — private-repo default); `expected-install.json` +pins the exact file set the installer copies for these channels, and the +integration suite re-derives it on every run so the mapping cannot drift. + +Try it against a scratch copy: + +``` +cp -R release-kit/examples/npm-lib /tmp/npm-lib +node release-kit/install.mts --target /tmp/npm-lib --channels npm,github-release --apply +node release-kit/install.mts --target /tmp/npm-lib --channels npm,github-release --verify +``` diff --git a/release-kit/examples/npm-lib/expected-install.json b/release-kit/examples/npm-lib/expected-install.json new file mode 100644 index 00000000..566e7ed2 --- /dev/null +++ b/release-kit/examples/npm-lib/expected-install.json @@ -0,0 +1,86 @@ +{ + "channels": ["common", "npm", "github-release"], + "files": [ + "scripts/socket-release/_shared/cli-flags.mts", + "scripts/socket-release/_shared/human-gate.mts", + "scripts/socket-release/_shared/is-main-module.mts", + "scripts/socket-release/_shared/lifecycle-scripts.mts", + "scripts/socket-release/_shared/mirror-lock.mts", + "scripts/socket-release/_shared/pack-files.mts", + "scripts/socket-release/_shared/playwright-law.mts", + "scripts/socket-release/_shared/release-gap-recovery.mts", + "scripts/socket-release/_shared/release-subject.mts", + "scripts/socket-release/_shared/run-main.mts", + "scripts/socket-release/_shared/tar-executable.mts", + "scripts/socket-release/_shared/unix-path.mts", + "scripts/socket-release/bootstrap.mts", + "scripts/socket-release/bootstrap/config.mts", + "scripts/socket-release/bootstrap/gates.mts", + "scripts/socket-release/bootstrap/plan.mts", + "scripts/socket-release/bootstrap/render.mts", + "scripts/socket-release/bootstrap/seams.mts", + "scripts/socket-release/bootstrap/state.mts", + "scripts/socket-release/bootstrap/steps/github-env.mts", + "scripts/socket-release/bootstrap/steps/npm-access-permissive.mts", + "scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts", + "scripts/socket-release/bootstrap/steps/placeholder.mts", + "scripts/socket-release/bootstrap/steps/preflight.mts", + "scripts/socket-release/bootstrap/steps/staged-config.mts", + "scripts/socket-release/bootstrap/steps/trusted-publisher.mts", + "scripts/socket-release/bootstrap/steps/verify.mts", + "scripts/socket-release/constants/npm-registry.mts", + "scripts/socket-release/create-release.mts", + "scripts/socket-release/github-release.mts", + "scripts/socket-release/kit-manifest.json", + "scripts/socket-release/lib/github-git-refs.mts", + "scripts/socket-release/lib/release-anchor.mts", + "scripts/socket-release/lib/release-checksums/core.mts", + "scripts/socket-release/lib/release-checksums/producer.mts", + "scripts/socket-release/lib/verify-release-hashes.mts", + "scripts/socket-release/lib/workspace-yaml.mts", + "scripts/socket-release/npm-publish.mts", + "scripts/socket-release/npm-web-auth.mts", + "scripts/socket-release/paths.mts", + "scripts/socket-release/publish-infra/npm/access-page.mts", + "scripts/socket-release/publish-infra/npm/access-parse.mts", + "scripts/socket-release/publish-infra/npm/access-plan.mts", + "scripts/socket-release/publish-infra/npm/approve.mts", + "scripts/socket-release/publish-infra/npm/auth-identity.mts", + "scripts/socket-release/publish-infra/npm/backfill.mts", + "scripts/socket-release/publish-infra/npm/browser-session.mts", + "scripts/socket-release/publish-infra/npm/browser-sign-in.mts", + "scripts/socket-release/publish-infra/npm/login.mts", + "scripts/socket-release/publish-infra/npm/pack-manifest.mts", + "scripts/socket-release/publish-infra/npm/pack-preflight.mts", + "scripts/socket-release/publish-infra/npm/pinned-npm.mts", + "scripts/socket-release/publish-infra/npm/placeholder.mts", + "scripts/socket-release/publish-infra/npm/provenance.mts", + "scripts/socket-release/publish-infra/npm/registry.mts", + "scripts/socket-release/publish-infra/npm/scan.mts", + "scripts/socket-release/publish-infra/npm/shared.mts", + "scripts/socket-release/publish-infra/npm/staged-browser-parse.mts", + "scripts/socket-release/publish-infra/npm/staged-browser-read.mts", + "scripts/socket-release/publish-infra/npm/staged-workspace.mts", + "scripts/socket-release/publish-infra/npm/staged.mts", + "scripts/socket-release/publish-infra/npm/threat-scan.mts", + "scripts/socket-release/publish-infra/npm/trust-sweep.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-page.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts", + "scripts/socket-release/publish-infra/npm/trusted-publisher-plan.mts", + "scripts/socket-release/publish-infra/npm/workspace-plan.mts", + "scripts/socket-release/publish-infra/npm/workspace.mts", + "scripts/socket-release/publish-infra/pin-readme.mts", + "scripts/socket-release/publish-infra/reconcile.mts", + "scripts/socket-release/publish-infra/release.mts", + "scripts/socket-release/publish-infra/shared.mts", + "scripts/socket-release/publish-infra/socket-oauth.mts", + "scripts/socket-release/registry-liveness-gate.d.mts", + "scripts/socket-release/registry-liveness-gate.mjs", + "scripts/socket-release/templates/config/socket-release.json", + "scripts/socket-release/templates/gitignore-block.txt", + "scripts/socket-release/templates/workflows/github-release.yml", + "scripts/socket-release/templates/workflows/npm-publish.yml", + "scripts/socket-release/util/napi-targets.mts" + ] +} diff --git a/release-kit/examples/npm-lib/package.json b/release-kit/examples/npm-lib/package.json new file mode 100644 index 00000000..340f57d4 --- /dev/null +++ b/release-kit/examples/npm-lib/package.json @@ -0,0 +1,13 @@ +{ + "name": "@socketsecurity/example-lib", + "version": "1.0.0", + "description": "Example npm library consumer for socket-release-kit.", + "license": "MIT", + "files": [ + "dist" + ], + "scripts": { + "build": "echo build" + }, + "packageManager": "pnpm@11.17.0" +} diff --git a/release-kit/examples/npm-lib/socket-release.json b/release-kit/examples/npm-lib/socket-release.json new file mode 100644 index 00000000..ba36cc91 --- /dev/null +++ b/release-kit/examples/npm-lib/socket-release.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "channels": ["npm", "github-release"], + "npm": { "access": "restricted", "distTag": "latest" } +} diff --git a/release-kit/examples/npm-lib/src/index.mts b/release-kit/examples/npm-lib/src/index.mts new file mode 100644 index 00000000..116e42f2 --- /dev/null +++ b/release-kit/examples/npm-lib/src/index.mts @@ -0,0 +1,4 @@ +/** + * @file Example library entry — the kit cares about the manifest, not the code. + */ +export const answer = 42 diff --git a/release-kit/examples/rust-crate/Cargo.example.toml b/release-kit/examples/rust-crate/Cargo.example.toml new file mode 100644 index 00000000..eefe44dd --- /dev/null +++ b/release-kit/examples/rust-crate/Cargo.example.toml @@ -0,0 +1,6 @@ +[package] +name = "example-crate" +version = "1.0.0" +edition = "2021" +license = "MIT" +description = "Example crates.io consumer for socket-release-kit." diff --git a/release-kit/examples/rust-crate/README.md b/release-kit/examples/rust-crate/README.md new file mode 100644 index 00000000..1a45dace --- /dev/null +++ b/release-kit/examples/rust-crate/README.md @@ -0,0 +1,11 @@ +# rust-crate example + +A minimal crate the release kit stands up on the `crates` + `github-release` +channels: the cargo staged model (dry-run default, `--direct` under the +`cargo-publish` environment via crates.io Trusted Publishing), and the +registry-liveness-gated GitHub release. The kit CLIs run on node, so the +example carries a scripts-host package.json alongside Cargo.toml. + +Note: the manifest ships as `Cargo.example.toml` — rename it to `Cargo.toml` +in a real crate. The fixture name keeps sauce's own cargo gates from +adopting the example as a first-party crate. diff --git a/release-kit/examples/rust-crate/expected-install.json b/release-kit/examples/rust-crate/expected-install.json new file mode 100644 index 00000000..82f5b1c6 --- /dev/null +++ b/release-kit/examples/rust-crate/expected-install.json @@ -0,0 +1,61 @@ +{ + "channels": ["common", "crates", "github-release"], + "files": [ + "scripts/socket-release/_shared/cli-flags.mts", + "scripts/socket-release/_shared/human-gate.mts", + "scripts/socket-release/_shared/is-main-module.mts", + "scripts/socket-release/_shared/lifecycle-scripts.mts", + "scripts/socket-release/_shared/mirror-lock.mts", + "scripts/socket-release/_shared/pack-files.mts", + "scripts/socket-release/_shared/playwright-law.mts", + "scripts/socket-release/_shared/release-gap-recovery.mts", + "scripts/socket-release/_shared/release-subject.mts", + "scripts/socket-release/_shared/run-main.mts", + "scripts/socket-release/_shared/tar-executable.mts", + "scripts/socket-release/_shared/unix-path.mts", + "scripts/socket-release/bootstrap.mts", + "scripts/socket-release/bootstrap/config.mts", + "scripts/socket-release/bootstrap/gates.mts", + "scripts/socket-release/bootstrap/plan.mts", + "scripts/socket-release/bootstrap/render.mts", + "scripts/socket-release/bootstrap/seams.mts", + "scripts/socket-release/bootstrap/state.mts", + "scripts/socket-release/bootstrap/steps/github-env.mts", + "scripts/socket-release/bootstrap/steps/npm-access-permissive.mts", + "scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts", + "scripts/socket-release/bootstrap/steps/placeholder.mts", + "scripts/socket-release/bootstrap/steps/preflight.mts", + "scripts/socket-release/bootstrap/steps/staged-config.mts", + "scripts/socket-release/bootstrap/steps/trusted-publisher.mts", + "scripts/socket-release/bootstrap/steps/verify.mts", + "scripts/socket-release/cargo-publish.mts", + "scripts/socket-release/constants/npm-registry.mts", + "scripts/socket-release/create-release.mts", + "scripts/socket-release/github-release.mts", + "scripts/socket-release/kit-manifest.json", + "scripts/socket-release/lib/github-git-refs.mts", + "scripts/socket-release/lib/release-anchor.mts", + "scripts/socket-release/lib/release-checksums/core.mts", + "scripts/socket-release/lib/release-checksums/producer.mts", + "scripts/socket-release/lib/verify-release-hashes.mts", + "scripts/socket-release/lib/workspace-yaml.mts", + "scripts/socket-release/paths.mts", + "scripts/socket-release/publish-infra/cargo/approve.mts", + "scripts/socket-release/publish-infra/cargo/placeholder.mts", + "scripts/socket-release/publish-infra/cargo/registry.mts", + "scripts/socket-release/publish-infra/cargo/shared.mts", + "scripts/socket-release/publish-infra/cargo/staged.mts", + "scripts/socket-release/publish-infra/cargo/trusted-publisher.mts", + "scripts/socket-release/publish-infra/pin-readme.mts", + "scripts/socket-release/publish-infra/reconcile.mts", + "scripts/socket-release/publish-infra/release.mts", + "scripts/socket-release/publish-infra/shared.mts", + "scripts/socket-release/registry-liveness-gate.d.mts", + "scripts/socket-release/registry-liveness-gate.mjs", + "scripts/socket-release/templates/config/socket-release.json", + "scripts/socket-release/templates/gitignore-block.txt", + "scripts/socket-release/templates/workflows/cargo-publish.yml", + "scripts/socket-release/templates/workflows/github-release.yml", + "scripts/socket-release/util/napi-targets.mts" + ] +} diff --git a/release-kit/examples/rust-crate/package.json b/release-kit/examples/rust-crate/package.json new file mode 100644 index 00000000..059b5a9d --- /dev/null +++ b/release-kit/examples/rust-crate/package.json @@ -0,0 +1,11 @@ +{ + "name": "example-crate-scripts", + "version": "1.0.0", + "private": true, + "description": "Node-side scripts host for the example crate (the kit CLIs run on node).", + "license": "MIT", + "files": [ + "src" + ], + "packageManager": "pnpm@11.17.0" +} diff --git a/release-kit/examples/rust-crate/socket-release.json b/release-kit/examples/rust-crate/socket-release.json new file mode 100644 index 00000000..84f131eb --- /dev/null +++ b/release-kit/examples/rust-crate/socket-release.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "channels": ["crates", "github-release"], + "npm": { "access": "restricted", "distTag": "latest" } +} diff --git a/release-kit/examples/rust-crate/src/lib.rs b/release-kit/examples/rust-crate/src/lib.rs new file mode 100644 index 00000000..9f4fffa5 --- /dev/null +++ b/release-kit/examples/rust-crate/src/lib.rs @@ -0,0 +1,4 @@ +//! Example crate — the kit cares about Cargo.toml, not the code. +pub fn answer() -> u32 { + 42 +} diff --git a/release-kit/gen-manifest.mts b/release-kit/gen-manifest.mts new file mode 100644 index 00000000..c8474dc1 --- /dev/null +++ b/release-kit/gen-manifest.mts @@ -0,0 +1,92 @@ +/** + * @file (Re)generate the payload's `kit-manifest.json`: walk the payload, + * sha256 every file's POST-FORMAT bytes (R11 — run `pnpm run format` + * first, then this), tag channels from the pure mapping in + * `install/manifest.mts`, write sorted-by-path. `--check` regenerates in + * memory and exits 1 with the four ingredients on drift — sauce's + * release-kit-is-coherent check runs the same comparison in the gate. + * Usage: node release-kit/gen-manifest.mts [--check] + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { isMainModule } from './payload/scripts/socket-release/_shared/is-main-module.mts' +import { + channelsForPath, + KIT_VERSION, + MANIFEST_FILENAME, +} from './install/manifest.mts' +import type { KitManifest } from './install/manifest.mts' +import { PAYLOAD_ROOT, sha256Hex, walkPayload } from './install/seams.mts' + +/** + * Build the manifest from the payload's current bytes. + */ +export function buildManifest(payloadRoot: string = PAYLOAD_ROOT): KitManifest { + const files = walkPayload(payloadRoot).map(rel => ({ + channels: channelsForPath(rel), + path: rel, + sha256: sha256Hex(readFileSync(path.join(payloadRoot, rel))), + })) + return { files, kitVersion: KIT_VERSION, schemaVersion: 1 } +} + +export function serializeManifest(manifest: KitManifest): string { + // Match the repo formatter's JSON style so `pnpm run format` is a no-op on + // the generated file (R11): short leaf arrays — the per-file `channels` + // lists — collapse to one line. Only bracket-free innermost arrays match, + // so the long `files` array itself stays expanded. + const raw = JSON.stringify(manifest, null, 2) + const collapsed = raw.replace( + /\[\n\s+([^[\]{}]+?)\n\s+\]/g, + (_m, inner: string) => + `[${inner + .split(/,\n\s+/) + .map(s => s.trim()) + .join(', ')}]`, + ) + return `${collapsed}\n` +} + +function main(): void { + const check = process.argv.includes('--check') + const manifestPath = path.join(PAYLOAD_ROOT, MANIFEST_FILENAME) + const manifest = buildManifest() + const next = serializeManifest(manifest) + if (check) { + let current: string | undefined + try { + current = readFileSync(manifestPath, 'utf8') + } catch { + current = undefined + } + if (current !== next) { + process.stderr.write( + [ + 'Kit manifest is stale: the payload bytes drifted from kit-manifest.json.', + ` Where: ${manifestPath}`, + ` Saw: ${current === undefined ? 'no manifest file' : 'sha entries that no longer match the payload'}`, + ' Wanted: kit-manifest.json regenerated from the current (post-format) payload bytes', + ' Fix: node release-kit/gen-manifest.mts', + '', + ].join('\n'), + ) + process.exitCode = 1 + return + } + process.stdout.write('kit-manifest.json matches the payload bytes.\n') + return + } + writeFileSync(manifestPath, next) + process.stdout.write( + `wrote ${manifestPath} (${manifest.files.length} files).\n`, + ) +} + +// Entrypoint-guarded so the coherence check can import buildManifest without +// regenerating the manifest as a side effect. +if (isMainModule(import.meta.url)) { + main() +} diff --git a/release-kit/install.mts b/release-kit/install.mts new file mode 100644 index 00000000..4afb1099 --- /dev/null +++ b/release-kit/install.mts @@ -0,0 +1,291 @@ +/** + * @file The release-kit installer: copy the selected channels' payload + * files into a consumer repo at `scripts/socket-release/`, byte-exact per + * the committed `kit-manifest.json`. Plan by default (prints the file + * list); `--apply` copies; `--verify` byte-compares target vs payload and + * exits 0 identical / 1 divergent with per-file saw/wanted sha256s, zero + * writes. The installer never touches `.github/workflows`, package.json, + * or `.gitignore` — that is the bootstrap `staged-config` step's job. The + * consumer config is seeded from the template ONLY if absent. + * Usage: node release-kit/install.mts --target --channels + * [--apply] [--force] [--verify] [--json] [--help] + */ + +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { errorMessage } from '@socketsecurity/lib/errors/message' + +import { isMainModule } from './payload/scripts/socket-release/_shared/is-main-module.mts' +import { + INSTALL_PREFIX, + PAYLOAD_ROOT, + readTargetShas, + resolveInstallSeams, + sha256Hex, +} from './install/seams.mts' +import type { InstallSeams } from './install/seams.mts' +import { + filterByChannels, + MANIFEST_FILENAME, + parseChannelsFlag, + parseKitManifest, +} from './install/manifest.mts' +import type { KitChannel } from './install/manifest.mts' +import { planInstall } from './install/plan.mts' + +const USAGE = `Usage: node release-kit/install.mts --target --channels + [--apply] [--force] [--verify] [--json] [--help] + + --target consumer repo root (must contain package.json) + --channels comma list from npm,crates,github-release,brew (common always implied) + --apply perform the copies (default: plan, prints the file list) + --force overwrite a differing existing file (default: per-file conflict refusal) + --verify byte-compare target vs payload for the selected channels; zero writes + --json machine-readable output on stdout + --help this usage +` + +export interface InstallRunResult { + channels: string[] + exitCode: number + files: Array<{ + action: 'conflict' | 'copy' | 'missing' | 'skip-identical' + path: string + sha256: string + }> + mode: 'apply' | 'plan' | 'verify' + target: string +} + +export interface RunInstallConfig { + apply: boolean + channels: KitChannel[] + force: boolean + log?: ((line: string) => void) | undefined + payloadRoot?: string | undefined + seams?: InstallSeams | undefined + target: string + verify: boolean +} + +/** + * The whole install flow, in-process — the CLI wraps it; integration tests + * call it against temp dirs with real fs. + */ +export function runInstall(config: RunInstallConfig): InstallRunResult { + const cfg = { __proto__: null, ...config } as RunInstallConfig + const payloadRoot = cfg.payloadRoot ?? PAYLOAD_ROOT + const seams = cfg.seams ?? resolveInstallSeams(payloadRoot) + const log = cfg.log ?? ((line: string) => process.stderr.write(`${line}\n`)) + const mode: InstallRunResult['mode'] = cfg.verify + ? 'verify' + : cfg.apply + ? 'apply' + : 'plan' + const result: InstallRunResult = { + channels: ['common', ...cfg.channels], + exitCode: 0, + files: [], + mode, + target: cfg.target, + } + + const manifestRaw = seams.readPayloadFile(MANIFEST_FILENAME) + if (manifestRaw === undefined) { + log( + [ + 'Kit manifest is missing from the payload.', + ` Where: ${path.join(payloadRoot, MANIFEST_FILENAME)}`, + ' Saw: no such file', + ' Wanted: the committed kit-manifest.json', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + result.exitCode = 1 + return result + } + const manifest = parseKitManifest( + manifestRaw, + path.join(payloadRoot, MANIFEST_FILENAME), + ) + const entries = filterByChannels(manifest.files, cfg.channels) + // The manifest itself travels with every install so `--verify` can run + // from the consumer side later. + const manifestEntry = { + channels: ['common' as const], + path: MANIFEST_FILENAME, + sha256: sha256Hex(manifestRaw), + } + const allEntries = [...entries, manifestEntry] + const targetReads = readTargetShas( + seams, + allEntries.map(e => e.path), + cfg.target, + ) + + if (mode === 'verify') { + let divergent = 0 + for (let i = 0, { length } = allEntries; i < length; i += 1) { + const entry = allEntries[i]! + const saw = targetReads.get(entry.path) + if (saw === entry.sha256) { + result.files.push({ + action: 'skip-identical', + path: entry.path, + sha256: entry.sha256, + }) + continue + } + divergent += 1 + result.files.push({ + action: saw === undefined ? 'missing' : 'conflict', + path: entry.path, + sha256: entry.sha256, + }) + log(`x ${entry.path}: saw ${saw ?? '(missing)'}; wanted ${entry.sha256}`) + } + result.exitCode = divergent === 0 ? 0 : 1 + log( + divergent === 0 + ? `verify: ${allEntries.length} files byte-identical to the payload.` + : `verify: ${divergent} of ${allEntries.length} files diverge from the payload.`, + ) + return result + } + + const plan = planInstall({ entries: allEntries, targetReads }) + for (let i = 0, { length } = plan.identical; i < length; i += 1) { + result.files.push(plan.identical[i]!) + } + if (plan.conflicts.length > 0 && !cfg.force) { + for (let i = 0, { length } = plan.conflicts; i < length; i += 1) { + const c = plan.conflicts[i]! + result.files.push({ action: 'conflict', path: c.path, sha256: c.sha256 }) + log( + [ + `Refusing to overwrite ${c.path}: it diverges from the kit payload.`, + ` Where: ${path.join(cfg.target, INSTALL_PREFIX, c.path)}`, + ` Saw: sha256 ${c.sawSha256}`, + ` Wanted: sha256 ${c.sha256}`, + ' Fix: re-run with --force to restore the kit bytes, or reconcile your edit upstream into release-kit/payload.', + ].join('\n'), + ) + } + result.exitCode = 1 + return result + } + const toCopy = [...plan.copies, ...(cfg.force ? plan.conflicts : [])] + for (let i = 0, { length } = toCopy; i < length; i += 1) { + const f = toCopy[i]! + result.files.push({ action: 'copy', path: f.path, sha256: f.sha256 }) + if (mode === 'apply') { + seams.copyFile(f.path, cfg.target) + } else { + log(`copy ${INSTALL_PREFIX}/${f.path}`) + } + } + if (mode === 'apply') { + // Seed the consumer config from the template ONLY if absent. + const configPath = path.join(cfg.target, '.config/socket-release.json') + if (!seams.targetFileExists(configPath)) { + const template = seams.readPayloadFile( + 'templates/config/socket-release.json', + ) + if (template !== undefined) { + seams.writeTargetFile(configPath, template) + log('seeded .config/socket-release.json from the template.') + } + } + log( + `installed ${toCopy.length} file(s) (${plan.identical.length} already identical).`, + ) + log('next: node scripts/socket-release/bootstrap.mts') + } else { + log( + `plan: ${toCopy.length} file(s) to copy, ${plan.identical.length} identical, ${plan.conflicts.length} conflict(s).`, + ) + } + return result +} + +function main(): void { + let values: Record + try { + const parsed = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + apply: { type: 'boolean' }, + channels: { type: 'string' }, + force: { type: 'boolean' }, + help: { type: 'boolean' }, + json: { type: 'boolean' }, + target: { type: 'string' }, + verify: { type: 'boolean' }, + }, + strict: true, + }) + values = parsed.values as typeof values + } catch (e) { + process.stderr.write(`install: ${errorMessage(e)}\n${USAGE}`) + process.exitCode = 2 + return + } + if (values['help'] === true) { + process.stdout.write(USAGE) + return + } + const targetValue = values['target'] + const channelsValue = values['channels'] + const target = typeof targetValue === 'string' ? targetValue : undefined + const channelsRaw = + typeof channelsValue === 'string' ? channelsValue : undefined + if (!target || !channelsRaw) { + process.stderr.write( + `install: --target and --channels are required.\n${USAGE}`, + ) + process.exitCode = 2 + return + } + if (!existsSync(path.join(target, 'package.json'))) { + process.stderr.write( + [ + 'Install target is not a package root.', + ` Where: ${target}`, + ' Saw: no package.json', + ' Wanted: the consumer repo root', + ' Fix: pass --target .', + '', + ].join('\n'), + ) + process.exitCode = 2 + return + } + let channels: KitChannel[] + try { + channels = parseChannelsFlag(channelsRaw) + } catch (e) { + process.stderr.write(`install: ${errorMessage(e)}\n${USAGE}`) + process.exitCode = 2 + return + } + const result = runInstall({ + apply: values['apply'] === true, + channels, + force: values['force'] === true, + target, + verify: values['verify'] === true, + }) + if (values['json'] === true) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + } + process.exitCode = result.exitCode +} + +// Entrypoint-guarded so tests can import runInstall without running the CLI. +if (isMainModule(import.meta.url)) { + main() +} diff --git a/release-kit/install/manifest.mts b/release-kit/install/manifest.mts new file mode 100644 index 00000000..556b9ad0 --- /dev/null +++ b/release-kit/install/manifest.mts @@ -0,0 +1,250 @@ +/** + * @file PURE manifest logic for the release kit: the channel → file-set + * mapping (data, not fs walks), manifest parsing/validation, and channel + * filtering. The payload's `kit-manifest.json` pins the post-format bytes + * of every payload file (R11); `gen-manifest.mts` generates it and the + * installer consumes it. `common` is always implied — it is everything no + * optional channel claims. + */ + +export const KIT_CHANNELS = ['brew', 'crates', 'github-release', 'npm'] as const +export type KitChannel = (typeof KIT_CHANNELS)[number] +export type ManifestChannel = KitChannel | 'common' + +export const MANIFEST_FILENAME = 'kit-manifest.json' +export const KIT_VERSION = '0.1.0' + +export interface ManifestEntry { + channels: ManifestChannel[] + path: string + sha256: string +} + +export interface KitManifest { + files: ManifestEntry[] + kitVersion: string + schemaVersion: 1 +} + +/** + * The channel(s) a payload-relative path belongs to. Everything unclaimed is + * `common` (bootstrap, shared libs, constants, config templates, the + * manifest itself travels implicitly). + */ +export function channelsForPath(relPath: string): ManifestChannel[] { + const p = relPath.replaceAll('\\', '/') + if ( + p.startsWith('publish-infra/npm/') || + p === 'npm-publish.mts' || + p === 'npm-web-auth.mts' || + p === 'publish-infra/socket-oauth.mts' || + p === 'templates/workflows/npm-publish.yml' + ) { + return ['npm'] + } + if ( + p.startsWith('publish-infra/cargo/') || + p === 'cargo-publish.mts' || + p === 'templates/workflows/cargo-publish.yml' + ) { + return ['crates'] + } + if ( + p === 'create-release.mts' || + p === 'github-release.mts' || + p === 'registry-liveness-gate.mjs' || + p === 'registry-liveness-gate.d.mts' || + p.startsWith('lib/release-checksums/') || + p === 'templates/workflows/github-release.yml' + ) { + return ['github-release'] + } + if ( + p.startsWith('publish-infra/brew/') || + p === 'brew-publish.mts' || + p === 'lib/commit-via-github-api.mts' || + p === 'templates/workflows/brew-publish.yml' || + p.startsWith('templates/actions/socket-release-app-token/') || + p === 'util/pack-app-triplets.mts' + ) { + return ['brew'] + } + return ['common'] +} + +/** + * The entries a channel selection installs: the named channels plus the + * always-implied `common`. + */ +export function filterByChannels( + entries: readonly ManifestEntry[], + channels: readonly KitChannel[], +): ManifestEntry[] { + const selected = new Set(['common', ...channels]) + return entries.filter(e => e.channels.some(c => selected.has(c))) +} + +/** + * Whether a manifest path is a safe payload-relative destination: no absolute + * root, no drive letter, no `..` or empty segment that could escape the + * `scripts/socket-release/` install prefix. A manifest that fails this must + * never drive a copy — the installer joins the path under the target root, so + * an unchecked `../` would clobber files outside the target. + */ +export function isSafePayloadPath(rel: string): boolean { + if (typeof rel !== 'string' || rel === '') { + return false + } + const normalized = rel.replaceAll('\\', '/') + if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) { + return false + } + const segments = normalized.split('/') + for (let i = 0, { length } = segments; i < length; i += 1) { + const seg = segments[i]! + if (seg === '' || seg === '..') { + return false + } + } + return true +} + +/** + * Parse a `--channels` flag value. Throws on unknown names with the exact + * valid set. + */ +export function parseChannelsFlag(value: string): KitChannel[] { + const parts = value + .split(',') + .map(p => p.trim()) + .filter(p => p.length > 0 && p !== 'common') + const channels = new Set() + for (let i = 0, { length } = parts; i < length; i += 1) { + const p = parts[i]! + const known = KIT_CHANNELS.find(c => c === p) + if (!known) { + throw new Error( + `unknown channel "${p}" — valid channels: ${KIT_CHANNELS.join(', ')} (common is always implied)`, + ) + } + channels.add(known) + } + if (channels.size === 0) { + throw new Error( + `no channels selected — pick from ${KIT_CHANNELS.join(', ')}`, + ) + } + return [...channels] +} + +/** + * Parse + validate a kit manifest. Throws with the four ingredients on any + * violation — a manifest that cannot be trusted must never drive copies. + */ +export function parseKitManifest(raw: string, where: string): KitManifest { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error( + [ + 'Kit manifest is not valid JSON.', + ` Where: ${where}`, + ' Saw: unparseable JSON', + ' Wanted: a schemaVersion-1 kit manifest', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + const doc = + parsed && typeof parsed === 'object' + ? (parsed as { + files?: unknown | undefined + kitVersion?: unknown | undefined + schemaVersion?: unknown | undefined + }) + : undefined + if (!doc || doc.schemaVersion !== 1) { + throw new Error( + [ + 'Kit manifest has a foreign schema.', + ` Where: ${where}`, + ` Saw: schemaVersion ${String(doc ? doc.schemaVersion : parsed)}`, + ' Wanted: schemaVersion 1', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + if (!Array.isArray(doc.files)) { + throw new Error( + [ + 'Kit manifest carries no files array.', + ` Where: ${where}`, + ` Saw: ${typeof doc.files}`, + ' Wanted: files: [{path, sha256, channels}]', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + const files: ManifestEntry[] = [] + for (let i = 0, { length } = doc.files; i < length; i += 1) { + const entry: unknown = doc.files[i] + const f = + entry && typeof entry === 'object' + ? (entry as { + channels?: unknown | undefined + path?: unknown | undefined + sha256?: unknown | undefined + }) + : undefined + if ( + !f || + typeof f.path !== 'string' || + typeof f.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(f.sha256) || + !Array.isArray(f.channels) || + f.channels.length === 0 || + !f.channels.every( + (c: unknown) => + typeof c === 'string' && + (c === 'common' || (KIT_CHANNELS as readonly string[]).includes(c)), + ) + ) { + throw new Error( + [ + `Kit manifest files[${i}] is malformed.`, + ` Where: ${where}`, + ` Saw: ${JSON.stringify(f)}`, + ' Wanted: {path, sha256 (64 hex), channels (non-empty)}', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + if (!isSafePayloadPath(f.path)) { + throw new Error( + [ + `Kit manifest files[${i}] has an unsafe path.`, + ` Where: ${where}`, + ` Saw: ${JSON.stringify(f.path)}`, + ' Wanted: a payload-relative path with no absolute root, drive letter, or ".." segment', + ' Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + files.push({ + channels: f.channels.filter( + (c: unknown): c is ManifestChannel => + typeof c === 'string' && + (c === 'common' || (KIT_CHANNELS as readonly string[]).includes(c)), + ), + path: f.path, + sha256: f.sha256, + }) + } + return { + files, + kitVersion: + typeof doc.kitVersion === 'string' ? doc.kitVersion : KIT_VERSION, + schemaVersion: 1, + } +} diff --git a/release-kit/install/plan.mts b/release-kit/install/plan.mts new file mode 100644 index 00000000..0f1ea091 --- /dev/null +++ b/release-kit/install/plan.mts @@ -0,0 +1,62 @@ +/** + * @file PURE install planning: given the selected manifest entries and a + * map of the target's current file hashes, classify every file as + * copy / skip-identical / conflict. The planner never touches the + * filesystem — `install/seams.mts` gathers `targetReads` and performs + * the copies; this module only decides. + */ + +import type { ManifestEntry } from './manifest.mts' + +export type InstallAction = 'conflict' | 'copy' | 'skip-identical' + +export interface PlannedFile { + action: InstallAction + path: string + sha256: string + sawSha256?: string | undefined +} + +export interface InstallPlan { + conflicts: PlannedFile[] + copies: PlannedFile[] + identical: PlannedFile[] +} + +/** + * Classify every selected entry against the target's current hashes: + * absent → copy; identical hash → skip-identical; differing hash → + * conflict (the installer refuses per file unless `--force`). + */ +export function planInstall(config: { + entries: readonly ManifestEntry[] + targetReads: ReadonlyMap +}): InstallPlan { + const cfg = { __proto__: null, ...config } as typeof config + const plan: InstallPlan = { conflicts: [], copies: [], identical: [] } + for (let i = 0, { length } = cfg.entries; i < length; i += 1) { + const entry = cfg.entries[i]! + const saw = cfg.targetReads.get(entry.path) + if (saw === undefined) { + plan.copies.push({ + action: 'copy', + path: entry.path, + sha256: entry.sha256, + }) + } else if (saw === entry.sha256) { + plan.identical.push({ + action: 'skip-identical', + path: entry.path, + sha256: entry.sha256, + }) + } else { + plan.conflicts.push({ + action: 'conflict', + path: entry.path, + sawSha256: saw, + sha256: entry.sha256, + }) + } + } + return plan +} diff --git a/release-kit/install/seams.mts b/release-kit/install/seams.mts new file mode 100644 index 00000000..6d363490 --- /dev/null +++ b/release-kit/install/seams.mts @@ -0,0 +1,120 @@ +/** + * @file The installer's filesystem effects, behind `InstallSeams` so the + * pure planner never sees fs (Law 3: an injectable effect module is + * `seams.mts` exporting `Seams` + `resolveSeams`). Walking, + * hashing, reading the target's current shas, copying payload files, and + * the write-only-if-absent consumer config seed all live here. + */ + +import crypto from 'node:crypto' +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const PAYLOAD_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'payload', + 'scripts', + 'socket-release', +) + +/** + * Where the kit installs inside a consumer. + */ +export const INSTALL_PREFIX = 'scripts/socket-release' + +export interface InstallSeams { + copyFile(rel: string, targetRoot: string): void + hashTargetFile(rel: string, targetRoot: string): string | undefined + readPayloadFile(rel: string): string | undefined + targetFileExists(p: string): boolean + writeTargetFile(p: string, content: string): void +} + +/** + * Read the target's current sha for every entry path — the planner's + * `targetReads` input. + */ +export function readTargetShas( + seams: InstallSeams, + paths: readonly string[], + targetRoot: string, +): Map { + const map = new Map() + for (let i = 0, { length } = paths; i < length; i += 1) { + const rel = paths[i]! + map.set(rel, seams.hashTargetFile(rel, targetRoot)) + } + return map +} + +/** + * The real installer seams against a payload root. + */ +export function resolveInstallSeams( + payloadRoot: string = PAYLOAD_ROOT, +): InstallSeams { + return { + copyFile: (rel, targetRoot) => { + const from = path.join(payloadRoot, rel) + const to = path.join(targetRoot, INSTALL_PREFIX, rel) + mkdirSync(path.dirname(to), { recursive: true }) + copyFileSync(from, to) + }, + hashTargetFile: (rel, targetRoot) => { + const p = path.join(targetRoot, INSTALL_PREFIX, rel) + try { + return sha256Hex(readFileSync(p)) + } catch { + return undefined + } + }, + readPayloadFile: rel => { + try { + return readFileSync(path.join(payloadRoot, rel), 'utf8') + } catch { + return undefined + } + }, + targetFileExists: p => existsSync(p), + writeTargetFile: (p, content) => { + mkdirSync(path.dirname(p), { recursive: true }) + writeFileSync(p, content) + }, + } +} + +export function sha256Hex(content: Buffer | string): string { + return crypto.createHash('sha256').update(content).digest('hex') +} + +/** + * Every payload-relative file path, sorted, excluding the manifest itself. + */ +export function walkPayload(root: string = PAYLOAD_ROOT): string[] { + const files: string[] = [] + const walk = (dir: string): void => { + const entries = readdirSync(dir, { withFileTypes: true }) + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile()) { + files.push(path.relative(root, full).replaceAll('\\', '/')) + } + } + } + walk(root) + return files + .filter(f => f !== 'kit-manifest.json') + .toSorted((a, b) => (a < b ? -1 : a > b ? 1 : 0)) +} diff --git a/release-kit/payload/scripts/socket-release/_shared/cli-flags.mts b/release-kit/payload/scripts/socket-release/_shared/cli-flags.mts new file mode 100644 index 00000000..89ad07c7 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/cli-flags.mts @@ -0,0 +1,54 @@ +/** + * @file Unknown-flag rejection for the kit's CLIs. @socketsecurity/lib + * parseArgs does not throw on unknown options even under strict mode — an + * unknown flag lands in `values` as a boolean rather than an error — so a + * typo like `--dryrun` for `--dry-run` would otherwise slip through and a + * registry-writing entry would run for real instead of previewing. Each + * entry diffs the parsed keys against its declared options and exits 2 (the + * usage-error code) on any unknown flag. + */ + +function camelCase(name: string): string { + return name.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase()) +} + +/** + * The parsed-value keys that name no declared option, in encounter order. + * Empty means every flag was recognized. Both the declared spelling and its + * camelCase alias count as known, because @socketsecurity/lib parseArgs mirrors + * every `--dash-flag` into a `dashFlag` value key. + */ +export function unknownFlags( + values: Record, + declared: readonly string[], +): string[] { + const known = new Set() + for (let i = 0, { length } = declared; i < length; i += 1) { + const name = declared[i]! + known.add(name) + known.add(camelCase(name)) + } + return Object.keys(values).filter(name => !known.has(name)) +} + +/** + * The one-line refusal for unknown flags, listing each with its leading dashes. + */ +export function unknownFlagsMessage(unknown: readonly string[]): string { + const flags = unknown + .map(name => (name.length === 1 ? `-${name}` : `--${name}`)) + .join(', ') + return `Unknown flag${unknown.length === 1 ? '' : 's'}: ${flags}` +} + +/** + * The one-line refusal for stray positionals. @socketsecurity/lib parseArgs + * folds a dash-less token into `positionals` without throwing even under + * allowPositionals:false, so a mode typo like `approve` (for `--approve`) would + * otherwise fall through to the default publish path. + */ +export function unexpectedPositionalsMessage( + positionals: readonly string[], +): string { + return `Unexpected argument${positionals.length === 1 ? '' : 's'}: ${positionals.join(', ')} (did you drop a leading --?)` +} diff --git a/release-kit/payload/scripts/socket-release/_shared/human-gate.mts b/release-kit/payload/scripts/socket-release/_shared/human-gate.mts new file mode 100644 index 00000000..4036fe20 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/human-gate.mts @@ -0,0 +1,318 @@ +/** + * @file The ONE shape for prompting the human when an automated flow reaches + * a gate only they can clear: browser auth, a 2FA challenge, a hook + * authorization phrase, a staged-publish approve. Improvised asks made the + * operator re-parse a novel prompt every time; this module fixes the shape + * so every gate reads identically: + * 🖐 HUMAN GATE — [i/N] + * Need: + * Mind: + * A) You: + * B) Me: + * Then: + * Both lanes are ALWAYS printed. When no agent lane exists (authorization + * phrases count only when a human types them in a user turn), lane B says + * so honestly instead of vanishing — the operator should never wonder + * whether an option was omitted or forgotten. Lanes run the SAME + * non-interactive-capable command (a router that passes through on a real + * TTY and runs under a PTY without one) so no gate ever juggles "that + * won't work here, do this instead". Pure formatting plus a catalog of + * the canonical gates, so scripts compose gates from data instead of + * re-writing the prose; a mirror test asserts the shape. + */ + +/** + * A single human-only decision point in an otherwise scripted flow. + */ +export interface HumanGate { + /** + * Short scannable label, e.g. `npm auth`, `push grant`. + */ + name: string + /** + * What is blocked and why — one sentence. + */ + need: string + /** + * Lane A: the exact command/phrase the human runs or types themselves. + */ + humanLane: string + /** + * Lane B: what the human says to have the agent drive it (the agent opens + * their browser and waits). Undefined when no agent lane can exist; then + * `agentLaneUnavailable` must say why. + */ + agentLane?: string | undefined + /** + * Honest reason lane B is absent — printed in its place, never omitted. + */ + agentLaneUnavailable?: string | undefined + /** + * The active guard or restriction that shapes the lanes (devEngines veto, + * no-TTY `!` input, sanctioned-browser law, phrase provenance). Printed so + * the operator never picks a lane a guard would block. + */ + mind?: string | undefined + /** + * What resumes once the gate clears — the cost of ignoring it. + */ + resumes: string +} + +/** + * Render one gate in the canonical shape. `index`/`total` (1-based) chain + * multiple gates into a numbered queue so the operator sees the whole path + * to unblocked, not one ask at a time. + */ +export function formatHumanGate( + gate: HumanGate, + options?: + | { index?: number | undefined; total?: number | undefined } + | undefined, +): string[] { + const opts = { __proto__: null, ...options } as { + index?: number | undefined + total?: number | undefined + } + const position = + opts.index && opts.total ? ` [${opts.index}/${opts.total}]` : '' + const laneB = + gate.agentLane ?? + `no agent lane — ${gate.agentLaneUnavailable ?? 'this step is human-only'}` + const lines = [ + `🖐 HUMAN GATE — ${gate.name}${position}`, + ` Need: ${gate.need}`, + ] + if (gate.mind) { + lines.push(` Mind: ${gate.mind}`) + } + lines.push( + ` A) You: ${gate.humanLane}`, + ` B) Me: ${laneB}`, + ` Then: ${gate.resumes}`, + ) + return lines +} + +/** + * Render a queue of gates, numbered in the order they must clear. + */ +export function formatHumanGateQueue(gates: HumanGate[]): string[] { + const lines: string[] = [] + for (let i = 0, { length } = gates; i < length; i += 1) { + if (i > 0) { + lines.push('') + } + lines.push(...formatHumanGate(gates[i]!, { index: i + 1, total: length })) + } + return lines +} + +/** + * Canonical gate: local npm auth is missing or expired (whoami 401). Both + * lanes run the SAME command — the fleet auth router, which picks the tool + * that survives each context (pnpm's web-OAuth login when available, npm + * behind a PTY otherwise) — so there is never a mid-flight "that won't work, + * do this instead". Only the runner differs: the operator's terminal, or the + * agent through the PTY wrapper. The command is cd-anchored to a repo that + * HAS the router: a bare relative path runs against whatever cwd the + * operator's shell or the `!` in-session input happens to be in, and dies + * MODULE_NOT_FOUND anywhere else. + */ +export function npmAuthGate(repoPath: string, resumes: string): HumanGate { + const command = `cd ${repoPath} && node scripts/socket-release/npm-web-auth.mts login` + return { + agentLane: + `say "log me in" and I run \`${command}\` through its PTY — ` + + 'your browser opens for the OAuth + OTP, I wait.', + humanLane: `run \`${command}\` in your terminal — same flow, you drive.`, + mind: + 'raw `npm login` dies without a TTY (legacy Username prompt EOFs) and ' + + 'bare `npm` fails in-repo (devEngines pins pnpm); the router carries ' + + 'both limitations so neither lane can hit them.', + name: 'npm auth', + need: 'the local npm token is missing or expired (`npm whoami` → 401).', + resumes, + } +} + +/** + * Canonical gate: a guarded push needs its authorization phrase. Phrases are + * human-only artifacts — the scanner matches transcript role provenance, so + * there is no agent lane by design. + */ +export function pushGrantGate( + phrase: string, + what: string, + resumes: string, +): HumanGate { + return { + agentLaneUnavailable: + 'authorization phrases count only when a human types them in a user turn.', + humanLane: `type exactly: ${phrase}`, + mind: + 'the guard scans transcript role provenance — the phrase works typed ' + + 'here as a normal message, nothing to run.', + name: 'push grant', + need: `${what} is queued behind a push guard.`, + resumes, + } +} + +/** + * Canonical gate: promote a staged publish. Same command both lanes — the + * approve pipeline already routes stage ops through pnpm and the promotion + * through npm behind a PTY, so it survives the agent's TTY-less context and + * the operator's terminal alike. The 2FA challenge lands in the operator's + * browser either way: the agent can drive, only the human authenticates. + */ +export function approveGate( + approveCommand: string, + repoPath: string, + resumes: string, +): HumanGate { + return { + agentLane: + 'say "run the approve" and I run the same command through its PTY — ' + + 'the 2FA challenge opens in your browser, everything else is scripted.', + humanLane: `run \`cd ${repoPath} && ${approveCommand}\` — it prompts your 2FA.`, + mind: + 'staged entries are maintainer-visible only — pnpm and npm can hold ' + + 'DIFFERENT accounts, and a wrong or missing login reads as an empty ' + + 'stage list, not an error; the pipeline identity-checks first.', + name: 'publish approve', + need: 'a staged publish is byte-verified and waiting on promotion.', + resumes, + } +} + +/** + * Canonical gate: a browser-session step (Playwright driver read/apply, a + * profile sign-in) that needs the operator's window state or presence. + */ +export function browserSessionGate( + need: string, + humanLane: string, + agentLane: string, + resumes: string, +): HumanGate { + return { + agentLane, + humanLane, + mind: + 'only the sanctioned browser-session driver launches the profile — ' + + 'no scripted logins ever, and a Cloudflare challenge means pause for ' + + 'you, never retry.', + name: 'browser session', + need, + resumes, + } +} + +/** + * Kit gate: consent to burn a version. Publishing `@0.0.0` is the one + * irreversible act in the bootstrap — the version is burned forever and + * unpublish closes after 72h — so no default `--apply` run performs it. Both + * lanes run the SAME bootstrap command; only the runner differs. + */ +export function reserveNameGate( + pkg: string, + access: string, + resumes: string, +): HumanGate { + return { + agentLane: + 'say "reserve the name" and I run ' + + `\`node scripts/socket-release/bootstrap.mts placeholder --apply --reserve ${pkg}\` ` + + "through its PTY — npm's web-2FA opens in your browser, I wait.", + humanLane: `run \`node scripts/socket-release/bootstrap.mts placeholder --apply --reserve ${pkg}\` yourself.`, + mind: + `publishing ${pkg}@0.0.0 is irreversible — the version is burned forever and unpublish closes after 72h — ` + + 'so no default run performs it; --reserve must name the exact package.', + name: 'reserve name', + need: `${pkg} is unclaimed on npm; reserving it publishes a real 0.0.0 placeholder (access ${access}).`, + resumes, + } +} + +/** + * Kit gate: a staged placeholder is byte-live on the stage but not yet a + * public version — only the operator's 2FA promote makes the name resolve. + * Both lanes drive the SAME approve pipeline; the browser 2FA is the human + * part either way. + */ +export function placeholderPromoteGate( + pkg: string, + stageId: string, + resumes: string, +): HumanGate { + return { + agentLane: + 'say "promote the placeholder" and I run ' + + '`node scripts/socket-release/npm-publish.mts --approve` through its ' + + 'PTY — the 2FA challenge opens in your browser, I wait.', + humanLane: + 'run `node scripts/socket-release/npm-publish.mts --approve` — it ' + + `promotes staged entry ${stageId} and prompts your 2FA.`, + mind: + 'staged entries are maintainer-visible only — an unauthenticated or ' + + 'wrong-account stage list reads as EMPTY, not as an error; the approve ' + + 'pipeline identity-checks first.', + name: 'placeholder promote', + need: `${pkg}@0.0.0 is staged (${stageId}) and waiting on promotion before the name resolves as live.`, + resumes, + } +} + +/** + * Kit gate: npm's web-2FA approval page is open and holding a live URL — + * the command is already running and waits on the operator's click. There is + * no separate agent command to run: the PTY holds the flow either way. + */ +export function webAuthApproveGate(what: string, resumes: string): HumanGate { + return { + agentLane: + 'nothing extra to run — the PTY already holds the flow; tell me when ' + + 'the browser approval is done and I keep waiting for the exit.', + humanLane: + 'open the APPROVE HERE url printed above in your browser and approve ' + + '(expires in minutes) — tick the cooldown box so follow-up writes ride ' + + 'the same window.', + mind: + "npm's web-2FA URLs are single-use and short-lived; the waiting " + + 'command must stay alive through the approval — killing it voids the URL.', + name: 'web-auth approve', + need: `${what} is waiting on npm's web-2FA approval in your browser.`, + resumes, + } +} + +/** + * Kit gate: the GitHub environment API refused (HTTP 403 — a permissions + * boundary, typically fine-grained token scopes or org policy). The API lane + * is always tried FIRST; this gate is the browser fallback, and it is gate + * TEXT only — no tool ever drives github.com. + */ +export function ghEnvGate( + slug: string, + env: string, + resumes: string, +): HumanGate { + const command = `gh api -X PUT repos/${slug}/environments/${env} -F 'deployment_branch_policy[protected_branches]=false' -F 'deployment_branch_policy[custom_branch_policies]=true'` + return { + agentLane: + `say "retry the environment" and I re-run \`${command}\` — if your gh ` + + 'auth or org policy changed, the API lane succeeds and no browser is needed.', + humanLane: + `open https://github.com/${slug}/settings/environments , click "New environment", ` + + `name it "${env}", then under Deployment branches choose "Selected branches" ` + + 'and add the default branch — or fix the token scope and run ' + + `\`${command}\` yourself.`, + mind: + 'no tool drives github.com in a browser — the settings URL is a human ' + + 'path only; the API lane (`gh api`) is always the first choice.', + name: 'github environment', + need: `GitHub refused environment writes on ${slug} (HTTP 403) while standing up "${env}".`, + resumes, + } +} diff --git a/release-kit/payload/scripts/socket-release/_shared/is-main-module.mts b/release-kit/payload/scripts/socket-release/_shared/is-main-module.mts new file mode 100644 index 00000000..5f5b1303 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/is-main-module.mts @@ -0,0 +1,32 @@ +/** + * @file Entrypoint detection for fleet scripts. The naive + * `import.meta.url === file://argv[1]` comparison is symlink-fragile: + * Node resolves the REAL path for a module's `import.meta.url` while + * `process.argv[1]` keeps the path as invoked, so a script spawned via a + * symlinked location (macOS `/var` → `/private/var`, the shape every + * mkdtemp-based integration test hits) never matches and `main()` silently + * does not run. Compare realpaths on both sides instead. + */ + +import { realpathSync } from 'node:fs' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +/** + * True when the module at `importMetaUrl` is the process entrypoint. + * `entryPath` defaults to `process.argv[1]`; injectable for tests. + */ +export function isMainModule( + importMetaUrl: string, + entryPath?: string | undefined, +): boolean { + const entry = entryPath ?? process.argv[1] + if (!entry) { + return false + } + try { + return realpathSync(fileURLToPath(importMetaUrl)) === realpathSync(entry) + } catch { + return false + } +} diff --git a/release-kit/payload/scripts/socket-release/_shared/lifecycle-scripts.mts b/release-kit/payload/scripts/socket-release/_shared/lifecycle-scripts.mts new file mode 100644 index 00000000..481c47a4 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/lifecycle-scripts.mts @@ -0,0 +1,132 @@ +/** + * @file Lifecycle-script hygiene shared by the publish pack surface and the + * pack-contents gate. npm/pnpm run a manifest's lifecycle scripts on the + * CONSUMER's machine (preinstall/install/postinstall on install; prepare on + * git deps; prepack when the consumer re-packs), so a lifecycle command whose + * `node ` target is a repo-only file breaks every install of the + * published tarball — the 4.0.3 sdk manifest shipped `preinstall` → + * `scripts/socket-release/setup/bootstrap-zero-dep-packages.mjs` with no such + * file in the tarball. The pure core here answers one question both surfaces + * share: which declared lifecycle scripts reference local files the packed + * artifact will not carry. + */ + +// Local script file extensions we resolve. A `node ` whose path ends in +// one of these is a repo file that must exist. (Inlined from the fleet's +// check/script-paths-resolve.mts — that check does not ship with the kit.) +const SCRIPT_EXTS = ['.mts', '.cts', '.mjs', '.cjs', '.js'] + +/** + * Extract the local script path a command runs via `node `, or + * undefined when the command isn't a `node ` invocation (a bin + * tool, an aggregator like run-s, an inline `node -e`, or a `node` flag-only + * call). Tolerates a leading `NAME=val` env prefix. + */ +export function extractNodeScriptPath(command: string): string | undefined { + const tokens = command.trim().split(/\s+/) + let i = 0 + while (i < tokens.length && tokens[i]!.includes('=')) { + i += 1 + } + if (tokens[i] !== 'node') { + return undefined + } + // First non-flag token after `node` is the script path (skip `-e`, + // `--flag`, and `-e`'s inline-code argument). + for (let j = i + 1; j < tokens.length; j += 1) { + const tok = tokens[j]! + if (tok === '--eval' || tok === '-e') { + return undefined + } + if (tok.startsWith('-')) { + continue + } + // A `` segment marks a doc/template stand-in — never a real + // on-disk file; a glob token is expanded by the SHELL at run time. + if (tok.includes('<') || tok.includes('>')) { + return undefined + } + if (/[*?[\]{}]/.test(tok)) { + return undefined + } + const hasExt = SCRIPT_EXTS.some(ext => tok.endsWith(ext)) + return hasExt ? tok : undefined + } + return undefined +} + +/** + * The lifecycle scripts a published manifest must be able to run from the + * tarball alone: preinstall/install/postinstall fire on every consumer + * install, prepare on git-dep consumers, prepack when a consumer re-packs. + * Repo-side publish hooks (prepublishOnly, postpack, …) never execute on a + * consumer machine, so they are deliberately not listed. + */ +export const LIFECYCLE_SCRIPT_NAMES = [ + 'install', + 'postinstall', + 'prepack', + 'preinstall', + 'prepare', +] as const + +/** + * Every local `node ` target a script command references, across + * compound `&&` / `||` / `;` chains. Non-`node ` segments (bin + * tools, `node -e`, globs, doc placeholders) contribute nothing — same + * tolerance as the script-paths-resolve check this reuses. Pure. + */ +export function extractLocalScriptTargets(command: string): string[] { + const targets: string[] = [] + const segments = command.split(/&&|\|\||;/) + for (let i = 0, { length } = segments; i < length; i += 1) { + const target = extractNodeScriptPath(segments[i]!) + if (target) { + targets.push(target) + } + } + return targets +} + +export interface DanglingLifecycleScript { + /** + * The full script command as declared. + */ + readonly command: string + /** + * The local targets the packed artifact will not carry. + */ + readonly missing: string[] + /** + * The lifecycle script name (preinstall, install, …). + */ + readonly name: string +} + +/** + * The declared lifecycle scripts whose `node ` targets are not all in + * the pack file set (`hasFile` answers membership — tarball entry list on the + * gate side, files-field coverage + on-disk existence on the pack side). + * Lifecycle commands with no local script targets (bin tools, inline `node + * -e`) resolve trivially and are never flagged. Pure given a pure `hasFile`. + */ +export function findDanglingLifecycleScripts( + scripts: Readonly> | undefined, + hasFile: (relPath: string) => boolean, +): DanglingLifecycleScript[] { + if (!scripts) { + return [] + } + const dangling: DanglingLifecycleScript[] = [] + for (const name of LIFECYCLE_SCRIPT_NAMES) { + const command = scripts[name] + if (typeof command !== 'string') { + continue + } + const missing = extractLocalScriptTargets(command).filter(t => !hasFile(t)) + if (missing.length) { + dangling.push({ command, missing, name }) + } + } + return dangling +} diff --git a/release-kit/payload/scripts/socket-release/_shared/mirror-lock.mts b/release-kit/payload/scripts/socket-release/_shared/mirror-lock.mts new file mode 100644 index 00000000..a86ecf10 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/mirror-lock.mts @@ -0,0 +1,52 @@ +/** + * @file Mirror-lock shim. A consumer repo has no lockstep mirrors, so lifting + * a lock is a no-op and a write is just a write. The `writeThroughMirrorLock` + * export surface is stable so a consumer that later grows a mirror system + * swaps this file without touching a single importer. + */ + +import { writeFileSync } from 'node:fs' + +/** + * No-op in a consumer repo: nothing here is chmod-locked by a cascade. + */ +export async function liftMirrorLock(targetPath: string): Promise { + void targetPath +} + +/** + * Pass-through: run `fn` with no lock to lift. + */ +export async function withMirrorLockLifted( + filePath: string, + fn: () => Promise | T, +): Promise { + void filePath + return await fn() +} + +/** + * No-op sync counterpart of {@link liftMirrorLock}. + */ +export function liftMirrorLockSync(filePath: string): void { + void filePath +} + +/** + * Pass-through sync counterpart of {@link withMirrorLockLifted}. + */ +export function withMirrorLockLiftedSync(filePath: string, fn: () => T): T { + void filePath + return fn() +} + +/** + * Write a file that may be a lockstep mirror upstream. Here it is a plain + * write — the indirection exists so importers never branch on repo kind. + */ +export function writeThroughMirrorLock( + filePath: string, + content: string, +): void { + writeFileSync(filePath, content, 'utf8') +} diff --git a/release-kit/payload/scripts/socket-release/_shared/pack-files.mts b/release-kit/payload/scripts/socket-release/_shared/pack-files.mts new file mode 100644 index 00000000..58447de3 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/pack-files.mts @@ -0,0 +1,38 @@ +/** + * @file Files-field coverage — the one matcher for "would npm pack this + * path?", shared by the pack-contents gate (classifying real tarball + * entries) and the publish pack surface (predicting the pack file set when + * pruning repo-only lifecycle scripts). Dependency-light: node builtins + * plus the path normalizer only. + */ + +import { normalizePath } from '@socketsecurity/lib/paths/normalize' + +/** + * True when a tarball-relative path is covered by a package.json `files` + * entry, a listed file, or anything under a listed directory. A missing / + * empty `files` field covers everything, npm's default. Pure. + */ +export function isCoveredByFiles( + entry: string, + filesField: readonly string[] | undefined, +): boolean { + if (!filesField || filesField.length === 0) { + return true + } + const e = normalizePath(entry) + for (const f of filesField) { + const nf = normalizePath(f).replace(/\/+$/, '') + if (e === nf || e.startsWith(`${nf}/`)) { + return true + } + // A simple one-level glob (`lib/*.js`) — match by prefix + suffix. + if (nf.includes('*')) { + const [pre = '', post = ''] = nf.split('*', 2) + if (e.startsWith(pre) && e.endsWith(post)) { + return true + } + } + } + return false +} diff --git a/release-kit/payload/scripts/socket-release/_shared/playwright-law.mts b/release-kit/payload/scripts/socket-release/_shared/playwright-law.mts new file mode 100644 index 00000000..16932cdd --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/playwright-law.mts @@ -0,0 +1,195 @@ +/* + * @file The Playwright browser law, as code. Every browser the fleet opens + * follows ONE launch shape and ONE sign-in contract; this module is the + * single importable statement of both, so drivers, guards, checks, and + * agent prompts cite the same law instead of re-deriving it from prose. + * The reference implementation is the sanctioned session module + * (`scripts/socket-release/publish-infra/npm/browser-session.mts`); a + * launch-sanction check asserts this module's law matches the shipped launch + * so the two can never drift. + * The law, and why each clause exists: + * + * - `chromiumSandbox: true` is MANDATORY. Playwright defaults the Chromium + * sandbox OFF and injects a no-sandbox flag that current Chrome brands + * unsupported — observed 2026-07-30 destabilizing runs and dropping the + * signed-in session. The banner is not cosmetic. + * - ONE durable profile, shared by every npm browser tool, so an operator + * signed in for one gate is signed in everywhere. A second per-tool profile + * means a second sign-in. + * - Exactly TWO ignored Playwright defaults: `--enable-automation` (sets the + * navigator.webdriver bot signal; with it, a fresh npmjs.com login plus OTP + * bounced straight back to signed-out) and `--use-mock-keychain` (writes a + * cookie store a bare Chrome launch of the same profile cannot share). No + * `args` array, no other options. + * - Login is NEVER scripted. The operator signs in once in the headed window; + * no password, OTP, or cookie passes through the process. + * - npm auth is decided by the `/-/whoami` BODY on the website origin, never + * the HTTP status. + * - A human-verification challenge PAUSES the run for the operator and is never + * retried blindly: a retry ladder against a bot challenge earns a rate + * limit that then masquerades as a broken session. + */ + +import os from 'node:os' +import path from 'node:path' + +/** + * The ONE durable Chrome profile every npm browser tool shares. Mirrors the + * sanctioned session module so profiles already signed in keep working. + */ +export const LAWFUL_PROFILE_DIR = path.join( + os.homedir(), + '.config', + 'socket-wheelhouse', + 'staged-browser-profile', +) + +/** + * The only sanctioned `ignoreDefaultArgs` value — see the file header for + * what each entry protects. + */ +export const LAWFUL_IGNORED_DEFAULT_ARGS = Object.freeze([ + '--enable-automation', + '--use-mock-keychain', +] as const) + +/** + * Browser channel resolution: system Chrome, overridable for a machine + * without Chrome installed (playwright-core cannot conjure a channel it has + * no binary for). + */ +export function lawfulBrowserChannel(): string { + return process.env['SOCKET_BROWSER_CHANNEL'] || 'chrome' +} + +/** + * The complete lawful launch-option shape. `chromiumSandbox` is the literal + * type `true`: a launch that disables the sandbox is not a variant of the + * law, it is outside it. + */ +// Named a Shape, not Options: this is what `lawfulLaunchOptions()` RETURNS +// and is never a caller-facing parameter bag. Every member is required +// because the law IS the complete shape — an optional member would describe +// a launch that omits part of it. +export interface LawfulLaunchShape { + channel: string + chromiumSandbox: true + headless: boolean + ignoreDefaultArgs: readonly string[] +} + +/** + * Build the one lawful launch-options object. Drivers pass this straight to + * a persistent-context launch on {@link LAWFUL_PROFILE_DIR}; anything a + * driver wants to add beyond headedness is, by definition, unlawful. + */ +export function lawfulLaunchOptions( + options?: { headless?: boolean | undefined } | undefined, +): LawfulLaunchShape { + const { headless = false } = { __proto__: null, ...options } as NonNullable< + typeof options + > + return { + channel: lawfulBrowserChannel(), + chromiumSandbox: true, + headless, + ignoreDefaultArgs: LAWFUL_IGNORED_DEFAULT_ARGS, + } +} + +const LAWFUL_KEYS = new Set([ + 'channel', + 'chromiumSandbox', + 'headless', + 'ignoreDefaultArgs', +]) + +/** + * Every way the given options diverge from the law, in plain sentences. + * Empty means lawful. Pure — exported for tests and for guards that want to + * report all divergences at once instead of failing on the first. + */ +export function lawViolations(launchOptions: unknown): string[] { + if (typeof launchOptions !== 'object' || launchOptions === null) { + return ['launch options must be an object matching LawfulLaunchShape'] + } + const opts = launchOptions as Record + const violations: string[] = [] + if (opts['chromiumSandbox'] !== true) { + violations.push( + 'chromiumSandbox must be exactly true — Playwright defaults the sandbox off by injecting a no-sandbox flag Chrome refuses', + ) + } + if (typeof opts['channel'] !== 'string' || opts['channel'] === '') { + violations.push( + 'channel must be a non-empty string (lawfulBrowserChannel())', + ) + } + if (typeof opts['headless'] !== 'boolean') { + violations.push('headless must be an explicit boolean') + } + const ignored = opts['ignoreDefaultArgs'] + const lawful = + Array.isArray(ignored) && + ignored.length === LAWFUL_IGNORED_DEFAULT_ARGS.length && + LAWFUL_IGNORED_DEFAULT_ARGS.every(flag => ignored.includes(flag)) + if (!lawful) { + violations.push( + `ignoreDefaultArgs must be exactly [${LAWFUL_IGNORED_DEFAULT_ARGS.join(', ')}]`, + ) + } + if ('args' in opts) { + violations.push( + 'an args array is never lawful — the shape has no free-form flags', + ) + } + const keys = Object.keys(opts) + for (let i = 0, { length } = keys; i < length; i += 1) { + const key = keys[i]! + if (!LAWFUL_KEYS.has(key) && key !== 'args') { + violations.push( + `unexpected launch option \`${key}\` — the law has exactly ${[...LAWFUL_KEYS].join(', ')}`, + ) + } + } + return violations +} + +/** + * Throw unless the options are exactly the lawful shape, listing every + * divergence so a driver author fixes them all in one pass. + */ +export function assertLawfulLaunchOptions(launchOptions: unknown): void { + const violations = lawViolations(launchOptions) + if (violations.length > 0) { + throw new Error( + [ + 'Unlawful Playwright launch options:', + ...violations.map(v => ` - ${v}`), + ].join('\n'), + ) + } +} + +/** + * The sign-in contract as data, one rule per entry — quote these instead of + * paraphrasing them. + */ +export const SIGN_IN_CONTRACT = Object.freeze([ + 'Login is NEVER scripted: the operator signs in once in the headed window; no password, OTP, or cookie passes through the process.', + 'All npm browser tools share the ONE durable profile so a single sign-in covers every tool.', + 'npm auth is decided by the /-/whoami BODY on the website origin, never the HTTP status.', + 'A human-verification challenge PAUSES the run for the operator with a visible countdown and is never retried blindly.', +] as const) + +/** + * The law as a verbatim prompt block. Any agent prompt that may open a + * browser must carry this text unedited — paraphrase is how the law drifted + * into "the sandbox banner is cosmetic" once already. + */ +export const PLAYWRIGHT_LAW_PROMPT = [ + 'Playwright browser law (verbatim, non-negotiable):', + `- Launch ONLY via openNpmBrowserSession (scripts/socket-release/publish-infra/npm/browser-session.mts) on the durable profile ${LAWFUL_PROFILE_DIR}.`, + '- The launch shape is channel + chromiumSandbox: true + headless + the two sanctioned ignoreDefaultArgs entries, and nothing else — never an args array, never a sandbox-disabling flag.', + ...SIGN_IN_CONTRACT.map(rule => `- ${rule}`), +].join('\n') diff --git a/release-kit/payload/scripts/socket-release/_shared/release-gap-recovery.mts b/release-kit/payload/scripts/socket-release/_shared/release-gap-recovery.mts new file mode 100644 index 00000000..27982d85 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/release-gap-recovery.mts @@ -0,0 +1,64 @@ +/** + * @file The one canonical wording for a RELEASE GAP — a version that is public + * on its registry while its `v` git tag and GitHub release are + * missing. Two surfaces share it so they can never drift: the publish tail + * (`publish-infra/release.mts`) shouts it the moment the tag/release leg + * fails, and the published-versions drift gate shouts it for a gap that + * already landed. + * The gap sits in the irreversible window: an npm publish cannot be undone, + * so the operator must leave with the exact healing command, never a hint. + * That command is `github-release.mts --tag vX.Y.Z --release` — the stateless + * registry-truth healer, which re-packs at the content commit, compares + * against the packument digests, and only then cuts the tag + immutable + * release. Pure string building; no I/O, no logger, no deps. + */ + +/** + * The exact command that heals a release gap for `version`: the stateless + * registry-truth reconcile. It never promotes anything (it cannot stage, cannot + * approve, touches no npm auth or OTP) — it verifies the checked-out tree + * against the published bytes and cuts the missing tag + GitHub release. + */ +export function releaseGapRecoveryCommand(version: string): string { + return `node scripts/socket-release/github-release.mts --tag v${version} --release` +} + +/** + * Why re-running the approve leg does NOT heal a release gap. `--approve` + * filters out every staged entry whose name@version is already public BEFORE + * it reaches the tag/release leg, so a second run reports "All staged entries + * are already published; nothing to approve." and exits zero having cut + * nothing. Naming this inline keeps an operator from burning a cycle on the + * command that looks like the retry. + */ +export const APPROVE_IS_NOT_A_RESUME_PATH = + 're-running `--approve` does NOT heal this: the approve leg drops ' + + 'already-published versions before the tag/release step, so it exits ' + + '"nothing to approve" without cutting a tag.' + +/** + * The four-part (What / Where / Saw vs. wanted / Fix) release-gap message. + * `saw` states what was actually observed, a failed step, a missing tag; + * `where` names the surface that observed it. The Fix line always carries the + * literal reconcile command plus the note that `--approve` is not the retry. + */ +export function formatReleaseGapFailure(config: { + name: string + registry: string + saw: string + version: string + where: string +}): string { + const cfg = { __proto__: null, ...config } as typeof config + const tag = `v${cfg.version}` + return [ + ` What: ${cfg.name}@${cfg.version} is PUBLIC on ${cfg.registry}, but its ${tag} tag + GitHub release are missing.`, + ` The registry write is irreversible — the release is half-done until the tag + release exist.`, + ` Where: ${cfg.where}`, + ` Saw: ${cfg.saw}`, + ` Wanted: a ${tag} tag on origin AND a published (undrafted) GitHub release for ${tag}.`, + ` Fix: ${releaseGapRecoveryCommand(cfg.version)}`, + ` Run it from a checkout at the content commit for ${cfg.version};`, + ` ${APPROVE_IS_NOT_A_RESUME_PATH}`, + ].join('\n') +} diff --git a/release-kit/payload/scripts/socket-release/_shared/release-subject.mts b/release-kit/payload/scripts/socket-release/_shared/release-subject.mts new file mode 100644 index 00000000..736cd500 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/release-subject.mts @@ -0,0 +1,131 @@ +/** + * @file The ONE release-subject resolver. A repo's publishable subject is + * usually its root package.json, but a monorepo can redirect the publish via + * `publishConfig.directory` — pnpm then packs and publishes THAT directory + * instead of the root, so the published name, version, README, CHANGELOG, + * and the `pnpm pack` output directory all belong to the subject manifest, + * not the root one. Every publish/release/reconcile consumer resolves the + * subject through here — never a per-site `path.join(root, 'package.json')` + * reimplementation — so a redirected repo like socket-registry behaves + * exactly like a plain one downstream. Dependency-free by design: node + * builtins only, loadable on a bare checkout — the release-reconcile gap job + * imports this before any pnpm install. + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' + +/** + * The resolved publish subject. For a plain repo every path points at the + * root; for a `publishConfig.directory` redirect they point into the subject + * directory. `packDir` is where `pnpm pack` writes the tarball — verified + * against live pnpm: with a redirect the tarball lands INSIDE the directory, + * named from the subject manifest. + */ +export interface ReleaseSubject { + changelogPath: string + dir: string + manifestPath: string + name: string + packDir: string + private?: boolean | undefined + readmePath: string + redirected: boolean + repository?: string | { url?: string | undefined } | undefined + rootPath: string + version: string +} + +interface ManifestShape { + name?: unknown | undefined + private?: unknown | undefined + publishConfig?: { directory?: unknown | undefined } | undefined + repository?: string | { url?: string | undefined } | undefined + version?: unknown | undefined +} + +function readManifest(manifestPath: string): ManifestShape { + return JSON.parse(readFileSync(manifestPath, 'utf8')) as ManifestShape +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * Resolve the publish subject for the repo at `rootPath`, honoring + * `publishConfig.directory` when present and defaulting to the root shape — + * byte-identical behavior for every plain single-package repo. Throws LOUD + * when a declared redirect is broken: a non-string/empty directory, a + * directory escaping the repo root, a missing subject manifest, or a subject + * manifest with no name/version — a publish must never fall back to the + * private root manifest and stage the wrong package. + */ +export function resolveReleaseSubject(rootPath: string): ReleaseSubject { + const rootManifestPath = path.join(rootPath, 'package.json') + const root = readManifest(rootManifestPath) + const directory = root.publishConfig?.directory + if (directory === undefined) { + return { + changelogPath: path.join(rootPath, 'CHANGELOG.md'), + dir: rootPath, + manifestPath: rootManifestPath, + name: asString(root.name), + packDir: rootPath, + private: typeof root.private === 'boolean' ? root.private : undefined, + readmePath: path.join(rootPath, 'README.md'), + redirected: false, + repository: root.repository, + rootPath, + version: asString(root.version), + } + } + if (typeof directory !== 'string' || !directory) { + throw new Error( + `publishConfig.directory in ${rootManifestPath} must be a non-empty ` + + `package-relative path string, saw ${JSON.stringify(directory)}.`, + ) + } + const dir = path.resolve(rootPath, directory) + const rel = path.relative(rootPath, dir) + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error( + `publishConfig.directory ${JSON.stringify(directory)} in ` + + `${rootManifestPath} must resolve to a subdirectory of the repo ` + + `root — pnpm publishes that directory INSTEAD of the root.`, + ) + } + const manifestPath = path.join(dir, 'package.json') + if (!existsSync(manifestPath)) { + throw new Error( + `publishConfig.directory ${JSON.stringify(directory)} in ` + + `${rootManifestPath} points at a directory with no package.json — ` + + `expected the publish subject's manifest at ${manifestPath}.`, + ) + } + const subject = readManifest(manifestPath) + const name = asString(subject.name) + const version = asString(subject.version) + if (!name || !version) { + throw new Error( + `the publish subject manifest ${manifestPath} must carry a name and a ` + + `version, saw name=${JSON.stringify(subject.name)} ` + + `version=${JSON.stringify(subject.version)}.`, + ) + } + return { + changelogPath: path.join(dir, 'CHANGELOG.md'), + dir, + manifestPath, + name, + packDir: dir, + private: typeof subject.private === 'boolean' ? subject.private : undefined, + readmePath: path.join(dir, 'README.md'), + redirected: true, + // The subject manifest's repository wins; the root's is the fallback so a + // subject that omits it still pins README assets to the right repo. + repository: subject.repository ?? root.repository, + rootPath, + version, + } +} diff --git a/release-kit/payload/scripts/socket-release/_shared/run-main.mts b/release-kit/payload/scripts/socket-release/_shared/run-main.mts new file mode 100644 index 00000000..3225402a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/run-main.mts @@ -0,0 +1,61 @@ +/** + * @file Fail-soft entrypoint runner for fleet + repo CLI scripts. Wraps a + * script's `main()` so a throw / rejection can NEVER escape as an unhandled + * rejection + raw stack trace: the error is surfaced via the logger as a + * MESSAGE, never a stack, and the process exits non-zero. `main()` may return + * its exit code (or nothing → 0). This replaces the bare `void (async () => { + * process.exitCode = await main() })()` entry pattern, which crashes with a + * raw stack if `main()` throws. The contract: a fleet CLI entry must fail + * soft — never hard-crash the user with a raw stack. + */ + +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +const logger = getDefaultLogger() + +/** + * The shape of a script `main()`: it returns an exit code, or nothing + * (`undefined` / `void` -> exit 0), sync or async. + */ +type MainFn = () => + | number + | undefined + | void + | Promise + +/** + * Run a script's `main()` FAIL-SOFT: set `process.exitCode` to its resolved + * return (`?? 0`), and on ANY throw / rejection log the message (never a raw + * stack) via the default logger and set `process.exitCode = 1`. Never rethrows, + * so a fleet CLI can't crash the user with an unhandled stack. Call it inside + * the entrypoint guard: + * + * @example + * ;```ts + * if (isMainModule(import.meta.url)) { + * runMain(main) + * } + * ``` + */ +export function runMain(main: MainFn): void { + void runMainAsync(main) +} + +/** + * The awaitable core of {@link runMain} — set `process.exitCode` from `main()`'s + * resolved return (`?? 0`), or on any throw log the message + set exit code 1. + * Resolves, never rejects. Exported so tests can await the settled result; + * production entrypoints call the fire-and-forget {@link runMain}. + */ +export async function runMainAsync(main: MainFn): Promise { + try { + const code = await main() + process.exitCode = typeof code === 'number' ? code : 0 + } catch (e) { + logger.error(errorMessage(e)) + process.exitCode = 1 + } +} diff --git a/release-kit/payload/scripts/socket-release/_shared/tar-executable.mts b/release-kit/payload/scripts/socket-release/_shared/tar-executable.mts new file mode 100644 index 00000000..fea85fed --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/tar-executable.mts @@ -0,0 +1,14 @@ +import path from 'node:path' +import process from 'node:process' + +/** + * Select Windows' native bsdtar; use the PATH-provided tar on POSIX. + */ +export function tarExecutable( + platform: NodeJS.Platform = process.platform, + systemRoot: string | undefined = process.env['SystemRoot'], +): string { + return platform === 'win32' + ? path.join(systemRoot ?? 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' +} diff --git a/release-kit/payload/scripts/socket-release/_shared/unix-path.mts b/release-kit/payload/scripts/socket-release/_shared/unix-path.mts new file mode 100644 index 00000000..c2bf3e5f --- /dev/null +++ b/release-kit/payload/scripts/socket-release/_shared/unix-path.mts @@ -0,0 +1,21 @@ +/** + * @file The dependency-free forward-slash converter. `normalizePath` from + * `@socketsecurity/lib/paths/normalize` is the fleet's full + * normalizer (segment collapse, UNC + Windows-namespace preservation, MSYS + * drive letters) and stays the right call anywhere lib-stable is reachable. + * This leaf covers the dep-0 tier ONLY — modules that load on a bare + * checkout before any pnpm install (the release-reconcile gap job, hook + * scripts) and therefore cannot import lib-stable at all. Its inputs are + * already `path.join` / `path.relative` output, which node has collapsed, + * so the separator swap is the whole remaining job. + */ + +/** + * Convert every backslash in `pathLike` to a forward slash so a separator + * -sensitive operation (a `split('/')`, a `startsWith('/')`, a regex match) + * behaves identically on Windows and POSIX. Pass node-produced paths — this + * leaf does not collapse `.` / `..` segments. + */ +export function toUnixPath(pathLike: string): string { + return pathLike.replace(/\\/g, '/') +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap.mts b/release-kit/payload/scripts/socket-release/bootstrap.mts new file mode 100644 index 00000000..7ade284b --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap.mts @@ -0,0 +1,642 @@ +/** + * @file The socket-release bootstrap: stand up publishing for this repo in + * eight idempotent, individually re-runnable steps — + * preflight · placeholder · npm-access-permissive · github-env · + * staged-config · trusted-publisher · npm-access-staged-only · verify. + * Plan (dry-run) is the DEFAULT for everything destructive; `--apply` + * performs effects; `--json` emits exactly ONE machine-readable document + * on stdout (human logs go to stderr). Every step begins with live + * detection — the state file is a reporting cache, never authority — and + * after an apply the runner re-reads and marks `passed` only when the + * re-read says done (never false-green). + * Exit codes (pinned): 0 passed/planned clean · 1 a step failed · + * 2 usage · 3 blocked on a human gate · 4 precondition not done. + * Usage: node scripts/socket-release/bootstrap.mts [step ...] [options] + */ + +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { errorMessage } from '@socketsecurity/lib/errors/message' + +import { isMainModule } from './_shared/is-main-module.mts' +import { formatHumanGate } from './_shared/human-gate.mts' +import { parseGitHubSlug } from './publish-infra/pin-readme.mts' +import { parseKitConfig } from './bootstrap/config.mts' +import type { KitConfig } from './bootstrap/config.mts' +import { + STEP_IDS, + canonicalizeSteps, + nextCommandFor, + nextPendingStep, + planRun, + preconditionGaps, +} from './bootstrap/plan.mts' +import type { + StepContext, + StepDetection, + StepId, + StepPlan, + StepReceipt, +} from './bootstrap/plan.mts' +import { + KitError, + gateToJson, + renderStatusTable, + renderStepHuman, +} from './bootstrap/render.mts' +import type { RunJson, StepOutcomeJson } from './bootstrap/render.mts' +import { + STATE_RELATIVE_PATH, + contextKey, + loadState, + resetState, + saveState, + withReceipt, +} from './bootstrap/state.mts' +import type { BootstrapState } from './bootstrap/state.mts' +import { REPO_ROOT, resolveSeams } from './bootstrap/seams.mts' +import type { BootstrapSeams } from './bootstrap/seams.mts' +import { npmAuthGate } from './_shared/human-gate.mts' +import * as githubEnv from './bootstrap/steps/github-env.mts' +import * as npmAccessPermissive from './bootstrap/steps/npm-access-permissive.mts' +import * as npmAccessStagedOnly from './bootstrap/steps/npm-access-staged-only.mts' +import * as placeholder from './bootstrap/steps/placeholder.mts' +import * as preflight from './bootstrap/steps/preflight.mts' +import * as stagedConfig from './bootstrap/steps/staged-config.mts' +import * as trustedPublisher from './bootstrap/steps/trusted-publisher.mts' +import * as verify from './bootstrap/steps/verify.mts' + +export const KIT_NAME = 'socket-release-kit' +export const KIT_VERSION = '0.1.0' + +interface StepShape { + apply( + plan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, + ): Promise<{ effects: RunJson['steps'][number]['effects']; gate?: unknown }> + classify(inputs: unknown, ctx: StepContext): StepDetection + id: StepId + plan(detection: StepDetection, ctx: StepContext): StepPlan + read(ctx: StepContext, seams: BootstrapSeams): Promise +} + +const STEP_MODULES: Record = { + 'github-env': githubEnv as unknown as StepShape, + 'npm-access-permissive': npmAccessPermissive as unknown as StepShape, + 'npm-access-staged-only': npmAccessStagedOnly as unknown as StepShape, + placeholder: placeholder as unknown as StepShape, + preflight: preflight as unknown as StepShape, + 'staged-config': stagedConfig as unknown as StepShape, + 'trusted-publisher': trustedPublisher as unknown as StepShape, + verify: verify as unknown as StepShape, +} + +const USAGE = `Usage: node scripts/socket-release/bootstrap.mts [step ...] [options] + +Steps (canonical order): ${STEP_IDS.join(' ')} + +Options: + --apply perform effects (plan/dry-run is the default) + --dry-run explicit plan; combined with --apply -> usage error + --reserve consent for the placeholder publish (must byte-equal the package name) + --json emit exactly one JSON document on stdout (human logs -> stderr) + --yes never prompt; where a prompt would be last resort, emit the gate and exit 3 + --status print the step status table from receipts only; exit 0 + --reset delete the state file; exit 0 + --access public | restricted (overrides config) + --package subject override + --repo GitHub slug override + --branch environment deployment branch (default: the default branch) + --force consumed only by staged-config (divergent-workflow restore) + --profile-dir browser profile override (read lane only) + --help this usage +` + +export interface RunBootstrapConfig { + argv: string[] + log?: ((line: string) => void) | undefined + out?: ((text: string) => void) | undefined + repoRoot?: string | undefined + seams?: BootstrapSeams | undefined +} + +/** + * The whole run, in-process — the CLI calls this with real seams; the + * integration tests call it with fakes and capture `out`/`log`. + */ +export async function runBootstrap( + config: RunBootstrapConfig, +): Promise { + const cfg = { __proto__: null, ...config } as RunBootstrapConfig + const seams = cfg.seams ?? resolveSeams() + const repoRoot = cfg.repoRoot ?? REPO_ROOT + const out = cfg.out ?? ((text: string) => process.stdout.write(text)) + let jsonMode = false + const log = + cfg.log ?? + ((line: string) => { + if (jsonMode) { + process.stderr.write(`${line}\n`) + } else { + process.stdout.write(`${line}\n`) + } + }) + + let values: Record + let positionals: string[] + try { + const parsed = parseArgs({ + allowPositionals: true, + args: cfg.argv, + options: { + access: { type: 'string' }, + apply: { type: 'boolean' }, + branch: { type: 'string' }, + 'dry-run': { type: 'boolean' }, + force: { type: 'boolean' }, + help: { type: 'boolean' }, + json: { type: 'boolean' }, + package: { type: 'string' }, + 'profile-dir': { type: 'string' }, + repo: { type: 'string' }, + reserve: { type: 'string' }, + reset: { type: 'boolean' }, + status: { type: 'boolean' }, + yes: { type: 'boolean' }, + }, + strict: true, + }) + values = parsed.values as Record + positionals = [...parsed.positionals] + } catch (e) { + log(`bootstrap: ${errorMessage(e)}`) + log(USAGE) + return 2 + } + jsonMode = values['json'] === true + + if (values['help'] === true) { + log(USAGE) + return 0 + } + if (values['apply'] === true && values['dry-run'] === true) { + log('bootstrap: --apply and --dry-run conflict — pick one.') + log(USAGE) + return 2 + } + if (values['reset'] === true) { + const removed = resetState(repoRoot) + log( + removed + ? `removed ${STATE_RELATIVE_PATH} — receipts were history only; every step re-detects live state.` + : `nothing to reset — ${STATE_RELATIVE_PATH} does not exist.`, + ) + return 0 + } + + let requested: StepId[] + try { + requested = canonicalizeSteps(positionals) + } catch (e) { + log(`bootstrap: ${errorMessage(e)}`) + log(USAGE) + return 2 + } + + const apply = values['apply'] === true + const mode: RunJson['mode'] = + values['status'] === true ? 'status' : apply ? 'apply' : 'plan' + + // ---- resolve the run context (reads only; failures surface as checks). + const configRaw = seams.readFile( + path.join(repoRoot, '.config/socket-release.json'), + ) + let kitConfig: KitConfig + if (configRaw === undefined) { + log( + `bootstrap: no .config/socket-release.json in ${repoRoot} — install the kit first ` + + '(node release-kit/install.mts --target . --channels npm,github-release --apply).', + ) + return 2 + } + try { + kitConfig = parseKitConfig( + configRaw, + path.join(repoRoot, '.config/socket-release.json'), + ) + } catch (e) { + log(errorMessage(e)) + return e instanceof KitError ? e.exitCode : 2 + } + + const pkgRaw = seams.readFile(path.join(repoRoot, 'package.json')) + let pkg: { + name?: string | undefined + publishConfig?: { access?: string | undefined } | undefined + version?: string | undefined + } = {} + try { + pkg = pkgRaw ? (JSON.parse(pkgRaw) as typeof pkg) : {} + } catch { + pkg = {} + } + const packageName = + (values['package'] as string | undefined) ?? pkg.name ?? '(unresolved)' + const packageVersion = pkg.version ?? '0.0.0' + + let slug = values['repo'] as string | undefined + if (!slug) { + const origin = await seams.exec( + 'git', + ['remote', 'get-url', 'origin'], + repoRoot, + ) + slug = origin.code === 0 ? parseGitHubSlug(origin.stdout.trim()) : undefined + } + const resolvedSlug = slug ?? '(unresolved)' + + let defaultBranch = (values['branch'] as string | undefined) ?? 'main' + let visibility: 'private' | 'public' | 'unknown' = 'unknown' + if (slug) { + const repoRead = await seams.exec('gh', ['api', `repos/${slug}`], repoRoot) + if (repoRead.code === 0) { + try { + const repoJson = JSON.parse(repoRead.stdout) as { + default_branch?: string | undefined + private?: boolean | undefined + visibility?: string | undefined + } + if ( + typeof repoJson.default_branch === 'string' && + values['branch'] === undefined + ) { + defaultBranch = repoJson.default_branch + } + visibility = + repoJson.visibility === 'public' + ? 'public' + : repoJson.visibility === 'private' || repoJson.private === true + ? 'private' + : 'unknown' + } catch { + visibility = 'unknown' + } + } + } + + const accessFlag = values['access'] as string | undefined + if ( + accessFlag !== undefined && + accessFlag !== 'public' && + accessFlag !== 'restricted' + ) { + log(`bootstrap: --access must be public or restricted, saw ${accessFlag}.`) + return 2 + } + const access = + (accessFlag as 'public' | 'restricted' | undefined) ?? + kitConfig.npm.access ?? + (pkg.publishConfig?.access === 'public' || + pkg.publishConfig?.access === 'restricted' + ? pkg.publishConfig.access + : undefined) + + const ctx: StepContext = { + access, + apply, + branch: values['branch'] as string | undefined, + channels: kitConfig.channels, + defaultBranch, + force: values['force'] === true, + nodeVersion: process.version, + packageName, + packageVersion, + repoRoot, + reserve: values['reserve'] as string | undefined, + slug: resolvedSlug, + visibility, + yes: values['yes'] === true, + } + + const expectedKey = contextKey(resolvedSlug, packageName) + let state: BootstrapState + try { + state = loadState({ + expectedKey, + packageName, + packageVersion, + root: repoRoot, + slug: resolvedSlug, + }) + } catch (e) { + log(errorMessage(e)) + return e instanceof KitError ? e.exitCode : 2 + } + + if (mode === 'status') { + const table = renderStatusTable(state.receipts) + for (let i = 0, { length } = table; i < length; i += 1) { + log(table[i]!) + } + if (jsonMode) { + const doc = buildDoc({ + ctx, + exitCode: 0, + mode, + outcomes: [], + requested, + state, + }) + out(`${JSON.stringify(doc, null, 2)}\n`) + } + return 0 + } + + // ---- precondition DAG (exit 4). + const toRun = planRun(requested, state.receipts) + const gaps = preconditionGaps(toRun, state.receipts) + if (gaps.length > 0) { + const gap = gaps[0]! + const missingList = gap.missing.join(', ') + log( + [ + `Bootstrap precondition not done for step "${gap.step}".`, + ` Where: the ${gap.step} step's precondition DAG`, + ` Saw: no passed receipt for: ${missingList}`, + ` Wanted: ${missingList} passed before ${gap.step}`, + ` Fix: run \`node scripts/socket-release/bootstrap.mts ${gap.missing.join(' ')} --apply\` first (or run with no steps to resume everything pending).`, + ].join('\n'), + ) + if (jsonMode) { + const doc = buildDoc({ + ctx, + exitCode: 4, + mode, + outcomes: [], + requested: toRun, + state, + }) + out(`${JSON.stringify(doc, null, 2)}\n`) + } + return 4 + } + + // ---- run the steps. + const outcomes: StepOutcomeJson[] = [] + let exitCode = 0 + for (let i = 0, { length } = toRun; i < length; i += 1) { + const stepId = toRun[i]! + const mod = STEP_MODULES[stepId] + const started = seams.now().getTime() + // eslint-disable-next-line no-await-in-loop -- steps are strictly serial: each later step's detection depends on the earlier applies. + const outcome = await runStep(mod, ctx, seams, mode) + outcome.durationMs = Math.max(0, seams.now().getTime() - started) + outcomes.push(outcome) + const human = renderStepHuman(outcome) + for (let l = 0, { length: ll } = human; l < ll; l += 1) { + log(human[l]!) + } + if (mode === 'apply') { + state = withReceipt(state, stepId, { + at: seams.now().toISOString(), + detail: outcome.detail, + dryRun: false, + status: outcome.status, + }) + saveState(repoRoot, state) + } + if (outcome.status === 'failed') { + exitCode = 1 + break + } + if (outcome.status === 'blocked') { + exitCode = 3 + break + } + if (outcome.usageExit) { + exitCode = 2 + break + } + } + + const doc = buildDoc({ + ctx, + exitCode, + mode, + outcomes, + requested: toRun, + state, + }) + if (jsonMode) { + out(`${JSON.stringify(doc, null, 2)}\n`) + } else if (doc.nextCommand) { + log(`next: ${doc.nextCommand}`) + } + return exitCode +} + +interface RunStepOutcome extends StepOutcomeJson { + usageExit?: boolean | undefined +} + +async function runStep( + mod: StepShape, + ctx: StepContext, + seams: BootstrapSeams, + mode: RunJson['mode'], +): Promise { + const base: RunStepOutcome = { + already: false, + checks: [], + detail: '', + durationMs: 0, + effects: [], + gate: null, + status: 'planned', + step: mod.id, + } + let detection: StepDetection + try { + const inputs = await mod.read(ctx, seams) + detection = mod.classify(inputs, ctx) + } catch (e) { + if (e instanceof KitError) { + throw e + } + return { + ...base, + detail: `read failed: ${errorMessage(e)}`, + status: 'failed', + } + } + base.checks = detection.checks + base.detail = detection.detail + if (detection.done) { + return { ...base, already: true, status: 'passed' } + } + if (detection.gate) { + return { ...base, gate: gateToJson(detection.gate), status: 'blocked' } + } + if (detection.authUnknown) { + if (mode === 'apply') { + return { + ...base, + gate: gateToJson( + npmAuthGate(ctx.repoRoot, `the bootstrap resumes at ${mod.id}.`), + ), + status: 'blocked', + } + } + return { ...base, status: 'planned' } + } + if (detection.failed) { + // Fail-closed reads (hardFail) fail in BOTH modes; every other + // definitive failure renders `planned` in plan mode — a plan reports the + // machine, it does not grade it (the failing checks stay visible). + if (mode === 'apply' || detection.hardFail) { + return { ...base, status: 'failed' } + } + return { ...base, status: 'planned' } + } + const stepPlan = mod.plan(detection, ctx) + if (stepPlan.usage) { + return { + ...base, + detail: `--reserve does not name the package: saw ${stepPlan.usage.saw}, wanted ${stepPlan.usage.wanted}.`, + status: 'failed', + usageExit: true, + } + } + if (mode !== 'apply') { + return { ...base, effects: stepPlan.effects, status: 'planned' } + } + if (stepPlan.gate) { + return { + ...base, + effects: stepPlan.effects, + gate: gateToJson(stepPlan.gate), + status: 'blocked', + } + } + const applied = await mod.apply(stepPlan, ctx, seams) + if (applied.gate) { + return { + ...base, + effects: applied.effects, + gate: gateToJson(applied.gate as never), + status: 'blocked', + } + } + // Post-verify: re-read + re-classify; `passed` ONLY when the re-read says + // done (never false-green). + let reDetection: StepDetection + try { + const reInputs = await mod.read(ctx, seams) + reDetection = mod.classify(reInputs, ctx) + } catch (e) { + return { + ...base, + detail: `post-apply re-read failed: ${errorMessage(e)}`, + effects: applied.effects, + status: 'failed', + } + } + base.checks = reDetection.checks + if (reDetection.done) { + return { + ...base, + detail: reDetection.detail, + effects: applied.effects, + status: 'passed', + } + } + if (reDetection.gate) { + return { + ...base, + detail: reDetection.detail, + effects: applied.effects, + gate: gateToJson(reDetection.gate), + status: 'blocked', + } + } + // Read-only steps (preflight/verify) fail here with their own detail; + // apply steps fail as saved-state-unproven. + return { + ...base, + detail: + applied.effects.length > 0 + ? `saved-state unproven: the post-apply re-read reports "${reDetection.detail}" — success is the registry's answer, never the command's exit code.` + : reDetection.detail, + effects: applied.effects, + status: 'failed', + } +} + +function buildDoc(config: { + ctx: StepContext + exitCode: number + mode: RunJson['mode'] + outcomes: StepOutcomeJson[] + requested: StepId[] + state: BootstrapState +}): RunJson { + const { ctx, exitCode, mode, outcomes, requested, state } = config + const receipts: Partial> = state.receipts + const pending = nextPendingStep(receipts) + const outcomesClean = outcomes.map(o => { + const { usageExit: _usageExit, ...rest } = o as StepOutcomeJson & { + usageExit?: boolean | undefined + } + return rest + }) + return { + exitCode, + kit: { name: KIT_NAME, version: KIT_VERSION }, + mode, + nextCommand: pending + ? nextCommandFor(pending, { packageName: ctx.packageName }) + : null, + nextStep: pending ?? null, + package: { + access: ctx.access ?? 'unresolved', + name: ctx.packageName, + version: ctx.packageVersion, + }, + repo: { + defaultBranch: ctx.defaultBranch, + root: ctx.repoRoot, + slug: ctx.slug, + visibility: ctx.visibility, + }, + requestedSteps: requested, + schemaVersion: 1, + state: { path: STATE_RELATIVE_PATH, receipts }, + steps: outcomesClean, + } +} + +async function main(): Promise { + try { + process.exitCode = await runBootstrap({ argv: process.argv.slice(2) }) + } catch (e) { + if (e instanceof KitError) { + process.stderr.write(`${e.message}\n`) + process.exitCode = e.exitCode + return + } + process.stderr.write(`bootstrap: ${errorMessage(e)}\n`) + process.exitCode = 1 + } +} + +if (isMainModule(import.meta.url)) { + void main() +} + +// The gate module import keeps the runner's gate rendering shape-locked to +// the shared factories (mirror-tested); formatHumanGate is re-exported for +// smoke assertions. +export { formatHumanGate } diff --git a/release-kit/payload/scripts/socket-release/bootstrap/config.mts b/release-kit/payload/scripts/socket-release/bootstrap/config.mts new file mode 100644 index 00000000..2cfd0cd2 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/config.mts @@ -0,0 +1,201 @@ +/** + * @file Pure parsing/validation of the consumer's + * `.config/socket-release.json`. Hand-rolled — every violation is a + * four-ingredient refusal (What / Where / Saw / Fix) with the pinned usage + * exit code. The `brew` block is required only when the channels include + * `brew`. + */ + +import { KitError } from './render.mts' + +export const KIT_CHANNELS = ['brew', 'crates', 'github-release', 'npm'] as const +export type Channel = (typeof KIT_CHANNELS)[number] + +// The §2.3 byte contract lists channels in this order in every fix line. +const CHANNELS_FIX_ORDER = 'npm, crates, github-release, brew' + +export interface BrewConfig { + assetTemplate: string + formula: string + tap: string + triplets: string[] +} + +export interface KitConfig { + brew?: BrewConfig | undefined + channels: Channel[] + npm: { + access: 'public' | 'restricted' | undefined + distTag: string + } + schemaVersion: 1 +} + +function refuse( + what: string, + filePath: string, + saw: string, + fix: string, + wanted?: string, +): never { + throw new KitError({ fix, saw, wanted, what, where: filePath }, 2) +} + +/** + * Parse + validate the kit config. Throws the §5 usage refusal (exit 2) on + * every violation; never returns a partially-valid config. + */ +export function parseKitConfig(raw: string, filePath: string): KitConfig { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + refuse( + 'Kit config is not valid JSON.', + filePath, + 'unparseable JSON', + 'restore the file from scripts/socket-release/templates/config/socket-release.json.', + 'a schemaVersion-1 socket-release config', + ) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + refuse( + 'Kit config is not an object.', + filePath, + typeof parsed, + 'restore the file from scripts/socket-release/templates/config/socket-release.json.', + 'a JSON object', + ) + } + const doc = parsed as Record + if (doc['schemaVersion'] !== 1) { + refuse( + 'Kit config has a foreign schemaVersion.', + filePath, + String(doc['schemaVersion']), + 'set "schemaVersion": 1.', + '1', + ) + } + const channels = doc['channels'] + if (!Array.isArray(channels) || channels.length === 0) { + refuse( + 'Kit config carries no channels.', + filePath, + JSON.stringify(channels), + `set "channels" to a non-empty subset of ${CHANNELS_FIX_ORDER}.`, + 'a non-empty channels array', + ) + } + for (let i = 0, { length } = channels; i < length; i += 1) { + const c: unknown = channels[i] + if ( + typeof c !== 'string' || + !(KIT_CHANNELS as readonly string[]).includes(c) + ) { + refuse( + 'Kit config names an unknown channel.', + filePath, + String(c), + `use one of ${CHANNELS_FIX_ORDER}.`, + KIT_CHANNELS.join(' | '), + ) + } + } + const typedChannels = [...(channels as Channel[])] + const npmBlock = doc['npm'] + const npm = + typeof npmBlock === 'object' && npmBlock !== null + ? (npmBlock as Record) + : {} + const access = npm['access'] + if (access !== undefined && access !== 'public' && access !== 'restricted') { + refuse( + 'Kit config npm.access is not a valid access level.', + filePath, + String(access), + 'set "npm": { "access": "restricted" } (or "public").', + 'public | restricted', + ) + } + const distTag = npm['distTag'] ?? 'latest' + if (typeof distTag !== 'string' || distTag === '') { + refuse( + 'Kit config npm.distTag is not a dist-tag.', + filePath, + String(distTag), + 'set "npm": { "distTag": "latest" }.', + 'a non-empty string', + ) + } + let brew: BrewConfig | undefined + if (typedChannels.includes('brew')) { + const brewBlock = doc['brew'] + if (typeof brewBlock !== 'object' || brewBlock === null) { + refuse( + 'Kit config enables the brew channel without a brew block.', + filePath, + String(brewBlock), + 'add "brew": { "tap", "formula", "assetTemplate", "triplets" } — see templates/config/socket-release.json.', + 'a brew object', + ) + } + const b = brewBlock as Record + const tap = b['tap'] + const formula = b['formula'] + const assetTemplate = b['assetTemplate'] + const triplets = b['triplets'] + if (typeof tap !== 'string' || tap === '') { + refuse( + 'Kit config brew.tap is missing.', + filePath, + String(tap), + 'set "brew": { "tap": "SocketDev/socket" }.', + 'an / tap slug', + ) + } + if (typeof formula !== 'string') { + refuse( + 'Kit config brew.formula is not a string.', + filePath, + String(formula), + 'set "brew": { "formula": "" } (empty means the package basename).', + 'a string', + ) + } + if (typeof assetTemplate !== 'string' || assetTemplate === '') { + refuse( + 'Kit config brew.assetTemplate is missing.', + filePath, + String(assetTemplate), + 'set "brew": { "assetTemplate": "-.tar.gz" }.', + 'a template naming // placeholders', + ) + } + if ( + !Array.isArray(triplets) || + triplets.length === 0 || + !triplets.every(t => typeof t === 'string') + ) { + refuse( + 'Kit config brew.triplets is not a non-empty string array.', + filePath, + JSON.stringify(triplets), + 'set "brew": { "triplets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] }.', + 'a non-empty string array', + ) + } + brew = { + assetTemplate, + formula, + tap, + triplets: [...(triplets as string[])], + } + } + return { + brew, + channels: typedChannels, + npm: { access: access as 'public' | 'restricted' | undefined, distTag }, + schemaVersion: 1, + } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/gates.mts b/release-kit/payload/scripts/socket-release/bootstrap/gates.mts new file mode 100644 index 00000000..81c69f0e --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/gates.mts @@ -0,0 +1,108 @@ +/** + * @file The bootstrap's gate catalog: every human gate any kit flow can + * render, composed from the factories in `_shared/human-gate.mts` — never + * hand-written prose. `CANONICAL_GATES` instantiates all EIGHT factories + * with representative arguments so the mirror test asserts the 6-line + * fleet shape (🖐 HUMAN GATE — [i/N] / Need / Mind / A) You / + * B) Me / Then) over the complete catalog in one place. Pure data + + * re-exports; no I/O. + */ + +import { + approveGate, + browserSessionGate, + ghEnvGate, + npmAuthGate, + placeholderPromoteGate, + pushGrantGate, + reserveNameGate, + webAuthApproveGate, +} from '../_shared/human-gate.mts' +import type { HumanGate } from '../_shared/human-gate.mts' +import { NPM_APPROVE_COMMAND } from '../publish-infra/npm/shared.mts' + +export { + approveGate, + browserSessionGate, + ghEnvGate, + npmAuthGate, + placeholderPromoteGate, + pushGrantGate, + reserveNameGate, + webAuthApproveGate, +} +export type { HumanGate } + +/** + * All eight canonical gate factories instantiated with representative + * arguments — the mirror test's single subject. Order matches the + * README's gate catalog. + */ +export const CANONICAL_GATES: ReadonlyArray<{ + gate: HumanGate + id: string +}> = [ + { + gate: npmAuthGate( + '/tmp/example-repo', + 'the bootstrap resumes at the blocked step.', + ), + id: 'npm-auth', + }, + { + gate: pushGrantGate( + 'push it: example', + 'the release commit', + 'the push proceeds.', + ), + id: 'push-grant', + }, + { + gate: approveGate( + NPM_APPROVE_COMMAND, + '/tmp/example-repo', + 'the staged publish promotes and the tag/release cut follows.', + ), + id: 'publish-approve', + }, + { + gate: browserSessionGate( + 'the staged-tarball byte check needs the signed-in browser session.', + 'sign in to npm in the Chrome window the tool opened.', + 'say "signed in" once the npm session is live and I resume the read.', + 'the byte verification resumes.', + ), + id: 'browser-session', + }, + { + gate: reserveNameGate( + '@example/pkg', + 'restricted', + 'the bootstrap resumes at placeholder.', + ), + id: 'reserve-name', + }, + { + gate: placeholderPromoteGate( + '@example/pkg', + 'stage-0001', + 'the bootstrap resumes at placeholder.', + ), + id: 'placeholder-promote', + }, + { + gate: webAuthApproveGate( + 'the placeholder publish', + 'the publish completes and the bootstrap continues.', + ), + id: 'web-auth-approve', + }, + { + gate: ghEnvGate( + 'ExampleOwner/example', + 'npm-publish', + 'the bootstrap resumes at github-env.', + ), + id: 'gh-env', + }, +] diff --git a/release-kit/payload/scripts/socket-release/bootstrap/plan.mts b/release-kit/payload/scripts/socket-release/bootstrap/plan.mts new file mode 100644 index 00000000..55e0cf38 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/plan.mts @@ -0,0 +1,295 @@ +/** + * @file Pure planning core for the bootstrap: the canonical step order, the + * precondition DAG, run selection (resume), receipt currency, and the + * next-command rendering. No I/O anywhere in this module — every function + * is unit-testable from inline data, and the runner (`bootstrap.mts`) is + * the only caller that feeds it live reads. The canonical order runs + * `staged-config` BEFORE `trusted-publisher` (trust config is derived from + * the repo's ACTUAL workflows — never configure trust for a workflow that + * does not exist), and the two publishing-access steps bracket the + * irreversible placeholder publish: PERMISSIVE before it can be needed, + * STAGED-ONLY only after trusted publishing is stood up (disabling direct + * publishing before OIDC works would brick the package's publish path). + */ + +import type { HumanGate } from '../_shared/human-gate.mts' +import type { BootstrapSeams } from './seams.mts' + +/** + * The eight bootstrap steps in canonical execution order. + */ +export const STEP_IDS = [ + 'preflight', + 'placeholder', + 'npm-access-permissive', + 'github-env', + 'staged-config', + 'trusted-publisher', + 'npm-access-staged-only', + 'verify', +] as const + +export type StepId = (typeof STEP_IDS)[number] + +export type StepStatus = 'blocked' | 'failed' | 'passed' | 'planned' | 'skipped' + +/** + * One named detection check inside a step, `--json`-shaped. + */ +export interface Check { + fix: string | null + id: string + ok: boolean + saw: string + wanted: string +} + +/** + * One effect a step plans or performs, `--json`-shaped. `applied` is false + * in plan mode and true only after the effect actually ran. + */ +export interface Effect { + applied: boolean + description: string + kind: + | 'exec' + | 'file-write' + | 'gh-api' + | 'npm-access' + | 'npm-trust' + | 'registry-publish' +} + +/** + * What a step's pure `classify` returns: named state, the checks that + * support it, and the three routing bits the runner acts on. + */ +export interface StepDetection { + /** + * Auth-dependent read died: `planned` + an `auth-unavailable` check in + * plan mode, `blocked` + npmAuthGate in apply mode (fail closed). + */ + authUnknown?: boolean | undefined + checks: Check[] + detail: string + /** + * Live detection says the step's work is already done — `passed` + + * `already: true`, zero effects. + */ + done: boolean + /** + * Detection-level failure. In APPLY mode → `failed`, exit 1. In PLAN mode + * the step renders `planned` with its failing checks visible (plan mode + * reports, it does not classify a machine it cannot fix) — UNLESS + * `hardFail` is set. + */ + failed?: boolean | undefined + /** + * A failure that holds in BOTH modes: the fail-closed reads (an + * unreachable registry is never read as unpublished, in plan or apply). + */ + hardFail?: boolean | undefined + /** + * Detection itself blocks on a human (e.g. a staged placeholder pending + * promotion) — `blocked`, exit 3. + */ + gate?: HumanGate | undefined + state: string +} + +/** + * What a step's pure `plan` returns: the effects `apply` would perform, and + * the gate that blocks apply when consent/auth is missing. + */ +export interface StepPlan { + effects: Effect[] + /** + * Apply cannot proceed without a human decision — `blocked`, exit 3. + * Ignored in plan mode (a plan performs nothing, so nothing blocks it). + */ + gate?: HumanGate | undefined + /** + * A usage-level refusal (e.g. `--reserve` naming the wrong package) — + * exit 2 with saw/wanted. + */ + usage?: { saw: string; wanted: string } | undefined +} + +/** + * One step receipt in the state file — a reporting cache, never authority. + */ +export interface StepReceipt { + at: string + detail?: string | undefined + dryRun: boolean + status: StepStatus +} + +/** + * Everything a step needs to know about the run — resolved once by the + * runner, never re-derived inside a step (`process.cwd()` is never called). + */ +export interface StepContext { + access: 'public' | 'restricted' | undefined + apply: boolean + branch: string | undefined + channels: readonly string[] + defaultBranch: string + force: boolean + nodeVersion: string + packageName: string + packageVersion: string + repoRoot: string + reserve: string | undefined + slug: string + visibility: 'private' | 'public' | 'unknown' + yes: boolean +} + +/** + * What a step's `apply` reports back — the runner still re-reads and + * re-classifies before ever marking the step passed (never false-green). + */ +export interface StepApplyResult { + effects: Effect[] + /** + * Apply itself hit a human gate mid-flight (e.g. the web-2FA window). + */ + gate?: HumanGate | undefined +} + +/** + * The step state machine: `read` gathers live inputs (effects: reads only), + * `classify` and `plan` are pure, `apply` performs the planned effects. The + * runner drives read → classify → plan → apply → re-read → re-classify and + * marks `passed` ONLY when the re-read says done. + */ +export interface StepModule { + apply( + plan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, + ): Promise + classify(inputs: unknown, ctx: StepContext): StepDetection + id: StepId + plan(detection: StepDetection, ctx: StepContext): StepPlan + read(ctx: StepContext, seams: BootstrapSeams): Promise +} + +/** + * The precondition DAG: which steps must have PASSED (receipt or earlier in + * the same run) before a step may run. `verify` has none by design — it is + * always runnable read-only. + */ +export const PRECONDITIONS: Readonly> = { + 'github-env': ['preflight'], + 'npm-access-permissive': ['preflight', 'placeholder'], + 'npm-access-staged-only': ['placeholder', 'trusted-publisher'], + placeholder: ['preflight'], + preflight: [], + 'staged-config': ['preflight'], + 'trusted-publisher': ['placeholder', 'github-env', 'staged-config'], + verify: [], +} + +/** + * Whether `value` names a real step. + */ +export function isStepId(value: string): value is StepId { + return (STEP_IDS as readonly string[]).includes(value) +} + +/** + * Positional step args → canonical-order, deduped step list. Unknown names + * throw a plain Error the CLI maps to a usage refusal (exit 2) listing the + * valid steps. + */ +export function canonicalizeSteps(positionals: readonly string[]): StepId[] { + const requested = new Set() + for (let i = 0, { length } = positionals; i < length; i += 1) { + const name = positionals[i]! + if (!isStepId(name)) { + throw new Error( + `unknown step "${name}" — valid steps: ${STEP_IDS.join(', ')}`, + ) + } + requested.add(name) + } + return STEP_IDS.filter(id => requested.has(id)) +} + +/** + * Whether a receipt counts toward resume/precondition satisfaction: only a + * PASSED receipt does — blocked and failed receipts never satisfy a resume, + * and a plan-mode (dryRun) receipt never exists (plan mode writes nothing). + */ +export function isReceiptCurrent( + receipt: StepReceipt | undefined, +): receipt is StepReceipt { + return receipt !== undefined && receipt.status === 'passed' +} + +/** + * The precondition gaps for a requested run: for each requested step, the + * precondition steps that are neither passed (receipt) nor scheduled earlier + * in this same run. Non-empty → the runner refuses with exit 4 naming the + * missing steps and the exact command. + */ +export function preconditionGaps( + requested: readonly StepId[], + receipts: Readonly>>, +): Array<{ missing: StepId[]; step: StepId }> { + const gaps: Array<{ missing: StepId[]; step: StepId }> = [] + const scheduled = new Set() + for (let i = 0, { length } = requested; i < length; i += 1) { + const step = requested[i]! + const missing = PRECONDITIONS[step].filter( + pre => !scheduled.has(pre) && !isReceiptCurrent(receipts[pre]), + ) + if (missing.length > 0) { + gaps.push({ missing: [...missing], step }) + } + scheduled.add(step) + } + return gaps +} + +/** + * The steps a run executes: the requested steps, or — with no positionals — + * every step whose receipt is not currently passed (resume). An all-passed + * state resumes to just `verify` so a bare re-run still re-proves the stood + * up state read-only instead of reporting nothing. + */ +export function planRun( + requested: readonly StepId[], + receipts: Readonly>>, +): StepId[] { + if (requested.length > 0) { + return [...requested] + } + const pending = STEP_IDS.filter(id => !isReceiptCurrent(receipts[id])) + return pending.length > 0 ? pending : ['verify'] +} + +/** + * The exact command that runs `step` for real — printed as `nextCommand` so + * the operator (or their AI) never reconstructs flags by hand. + */ +export function nextCommandFor( + step: StepId, + config: { packageName: string }, +): string { + const cfg = { __proto__: null, ...config } as typeof config + const reserve = step === 'placeholder' ? ` --reserve ${cfg.packageName}` : '' + return `node scripts/socket-release/bootstrap.mts ${step} --apply${reserve}` +} + +/** + * The first canonical step not yet passed after a run, or undefined when + * everything is stood up. + */ +export function nextPendingStep( + receipts: Readonly>>, +): StepId | undefined { + return STEP_IDS.find(id => !isReceiptCurrent(receipts[id])) +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/render.mts b/release-kit/payload/scripts/socket-release/bootstrap/render.mts new file mode 100644 index 00000000..c2aaed84 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/render.mts @@ -0,0 +1,345 @@ +/** + * @file Pure rendering for the bootstrap: the four-ingredient error shape + * every kit refusal uses (What / Where / Saw vs. wanted / Fix), the + * `--json` document assembly, its hand-rolled validator (no schema dep — + * tests validate every emitted document AND every committed golden with + * the same function so the two can never drift), the human status table, + * and the per-step human lines. Nothing here touches process, fs, or the + * network. + */ + +import { formatHumanGate } from '../_shared/human-gate.mts' +import type { HumanGate } from '../_shared/human-gate.mts' +import { STEP_IDS, isStepId } from './plan.mts' +import type { Check, Effect, StepId, StepReceipt, StepStatus } from './plan.mts' + +/** + * The four ingredients of every kit refusal, as machine fields — tests + * assert these, never prose sentences. + */ +export interface KitErrorFields { + fix: string + saw: string + wanted?: string | undefined + what: string + where: string +} + +/** + * Render the four-ingredient message. Fix is imperative, one concrete + * action. + */ +export function formatKitError(fields: KitErrorFields): string { + const f = { __proto__: null, ...fields } as KitErrorFields + const lines = [f.what, ` Where: ${f.where}`, ` Saw: ${f.saw}`] + if (f.wanted !== undefined) { + lines.push(` Wanted: ${f.wanted}`) + } + lines.push(` Fix: ${f.fix}`) + return lines.join('\n') +} + +/** + * A kit error: the four ingredients plus the pinned exit code from the §5 + * taxonomy (2 usage · 4 precondition · 1 check-failed/conflict/unproven). + * Exit 3 is never thrown — a block renders as a human gate, not an error. + */ +export class KitError extends Error { + exitCode: number + fields: KitErrorFields + constructor( + fields: KitErrorFields, + exitCode: number, + options?: ErrorOptions, + ) { + super(formatKitError(fields), options) + this.exitCode = exitCode + this.fields = { __proto__: null, ...fields } as KitErrorFields + this.name = 'KitError' + } +} + +/** + * A gate in `--json` shape: name + the exact rendered lines. + */ +export interface GateJson { + lines: string[] + name: string +} + +/** + * Render one gate to its JSON shape (single-gate rendering — queues render + * through `formatHumanGateQueue` on the human side). + */ +export function gateToJson(gate: HumanGate): GateJson { + return { + lines: formatHumanGate(gate, { index: 1, total: 1 }), + name: gate.name, + } +} + +/** + * One step's outcome in the emitted document. + */ +export interface StepOutcomeJson { + already: boolean + checks: Check[] + detail: string + durationMs: number + effects: Effect[] + gate: GateJson | null + status: StepStatus + step: StepId +} + +/** + * The whole `--json` document — see the pinned schema in the kit README. + */ +export interface RunJson { + exitCode: number + kit: { name: string; version: string } + mode: 'apply' | 'plan' | 'status' + nextCommand: string | null + nextStep: StepId | null + package: { access: string; name: string; version: string } + repo: { + defaultBranch: string + root: string + slug: string + visibility: 'private' | 'public' | 'unknown' + } + requestedSteps: StepId[] + schemaVersion: 1 + state: { + path: string + receipts: Partial> + } + steps: StepOutcomeJson[] +} + +const STATUSES: readonly string[] = [ + 'blocked', + 'failed', + 'passed', + 'planned', + 'skipped', +] +const EFFECT_KINDS: readonly string[] = [ + 'exec', + 'file-write', + 'gh-api', + 'npm-access', + 'npm-trust', + 'registry-publish', +] + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +/** + * Hand-rolled structural validation of an emitted (or golden) run document. + * Returns the violations found — an empty array means valid. Tests run every + * emitted document AND every committed golden through this, so schema and + * fixtures cannot drift apart. + */ +export function validateRunJson(doc: unknown): string[] { + const errors: string[] = [] + if (!isRecord(doc)) { + return ['document is not an object'] + } + if (doc['schemaVersion'] !== 1) { + errors.push('schemaVersion must be 1') + } + const kit = doc['kit'] + if ( + !isRecord(kit) || + typeof kit['name'] !== 'string' || + typeof kit['version'] !== 'string' + ) { + errors.push('kit must be { name, version } strings') + } + if (!['apply', 'plan', 'status'].includes(doc['mode'] as string)) { + errors.push('mode must be plan | apply | status') + } + const repo = doc['repo'] + if ( + !isRecord(repo) || + typeof repo['root'] !== 'string' || + typeof repo['slug'] !== 'string' || + typeof repo['defaultBranch'] !== 'string' || + !['private', 'public', 'unknown'].includes(repo['visibility'] as string) + ) { + errors.push('repo must carry root/slug/defaultBranch/visibility') + } + const pkg = doc['package'] + if ( + !isRecord(pkg) || + typeof pkg['name'] !== 'string' || + typeof pkg['version'] !== 'string' || + typeof pkg['access'] !== 'string' + ) { + errors.push('package must carry name/version/access strings') + } + const requested = doc['requestedSteps'] + if ( + !Array.isArray(requested) || + !requested.every(s => typeof s === 'string' && isStepId(s)) + ) { + errors.push(`requestedSteps must be an array of ${STEP_IDS.join('|')}`) + } + const steps = doc['steps'] + if (!Array.isArray(steps)) { + errors.push('steps must be an array') + } else { + for (let i = 0, { length } = steps; i < length; i += 1) { + const s: unknown = steps[i] + const at = `steps[${i}]` + if (!isRecord(s)) { + errors.push(`${at} is not an object`) + continue + } + if (typeof s['step'] !== 'string' || !isStepId(s['step'])) { + errors.push(`${at}.step is not a step id`) + } + if (!STATUSES.includes(s['status'] as string)) { + errors.push(`${at}.status must be one of ${STATUSES.join('|')}`) + } + if (typeof s['already'] !== 'boolean') { + errors.push(`${at}.already must be a boolean`) + } + if (typeof s['detail'] !== 'string') { + errors.push(`${at}.detail must be a string`) + } + if (!Number.isInteger(s['durationMs'])) { + errors.push(`${at}.durationMs must be an integer`) + } + const checks = s['checks'] + if (!Array.isArray(checks)) { + errors.push(`${at}.checks must be an array`) + } else { + for (let c = 0, cl = checks.length; c < cl; c += 1) { + const check: unknown = checks[c] + if ( + !isRecord(check) || + typeof check['id'] !== 'string' || + typeof check['ok'] !== 'boolean' || + typeof check['saw'] !== 'string' || + typeof check['wanted'] !== 'string' || + (check['fix'] !== null && typeof check['fix'] !== 'string') + ) { + errors.push(`${at}.checks[${c}] must be {id, ok, saw, wanted, fix}`) + } + } + } + const effects = s['effects'] + if (!Array.isArray(effects)) { + errors.push(`${at}.effects must be an array`) + } else { + for (let e = 0, el = effects.length; e < el; e += 1) { + const effect: unknown = effects[e] + if ( + !isRecord(effect) || + !EFFECT_KINDS.includes(effect['kind'] as string) || + typeof effect['description'] !== 'string' || + typeof effect['applied'] !== 'boolean' + ) { + errors.push( + `${at}.effects[${e}] must be {kind, description, applied}`, + ) + } + } + } + const gate = s['gate'] + if (gate !== null) { + if ( + !isRecord(gate) || + typeof gate['name'] !== 'string' || + !Array.isArray(gate['lines']) || + !(gate['lines'] as unknown[]).every(l => typeof l === 'string') + ) { + errors.push(`${at}.gate must be null or {name, lines[]}`) + } + } + } + } + const state = doc['state'] + if ( + !isRecord(state) || + typeof state['path'] !== 'string' || + !isRecord(state['receipts']) + ) { + errors.push('state must carry path + receipts') + } + const nextStep = doc['nextStep'] + if ( + nextStep !== null && + !(typeof nextStep === 'string' && isStepId(nextStep)) + ) { + errors.push('nextStep must be null or a step id') + } + const nextCommand = doc['nextCommand'] + if (nextCommand !== null && typeof nextCommand !== 'string') { + errors.push('nextCommand must be null or a string') + } + const exitCode = doc['exitCode'] + if ( + !Number.isInteger(exitCode) || + (exitCode as number) < 0 || + (exitCode as number) > 4 + ) { + errors.push('exitCode must be an integer 0..4') + } + return errors +} + +/** + * The `--status` six-line table (eight with the access steps) from receipts + * only — no live reads. + */ +export function renderStatusTable( + receipts: Readonly>>, +): string[] { + const width = Math.max(...STEP_IDS.map(id => id.length)) + return STEP_IDS.map(id => { + const r = receipts[id] + const status = r ? `${r.status}${r.dryRun ? ' (dry-run)' : ''}` : 'pending' + const at = r ? ` at ${r.at}` : '' + return `${id.padEnd(width)} ${status}${at}` + }) +} + +/** + * One step outcome as human lines (stderr in `--json` mode, stdout + * otherwise). + */ +export function renderStepHuman(outcome: StepOutcomeJson): string[] { + const mark = + outcome.status === 'passed' + ? '✓' + : outcome.status === 'planned' || outcome.status === 'skipped' + ? '·' + : '×' + const lines = [ + `${mark} ${outcome.step}: ${outcome.status}${outcome.already ? ' (already)' : ''} — ${outcome.detail}`, + ] + for (let i = 0, { length } = outcome.checks; i < length; i += 1) { + const c = outcome.checks[i]! + if (!c.ok) { + lines.push(` × ${c.id}: saw ${c.saw}; wanted ${c.wanted}`) + if (c.fix) { + lines.push(` Fix: ${c.fix}`) + } + } + } + for (let i = 0, { length } = outcome.effects; i < length; i += 1) { + const e = outcome.effects[i]! + lines.push( + ` ${e.applied ? 'did' : 'would'} [${e.kind}] ${e.description}`, + ) + } + if (outcome.gate) { + lines.push(...outcome.gate.lines) + } + return lines +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/seams.mts b/release-kit/payload/scripts/socket-release/bootstrap/seams.mts new file mode 100644 index 00000000..76256eb1 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/seams.mts @@ -0,0 +1,209 @@ +/** + * @file The ONLY place bootstrap effects live. Every step's `read`/`apply` + * body drives these seams; `resolveSeams()` returns the real + * implementations (delegating to the ported engine — spawn-backed exec, + * `httpRequest` for registry reads, the sanctioned browser session for the + * publishing-access lane, `runPlaceholder` for the one-time reservation), + * and every test injects fakes. No step body calls `node:fs`, + * `node:child_process`, `fetch`, or playwright directly — that discipline + * is what makes the whole state machine testable without a browser, a + * registry, or a network socket. + */ + +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' + +import { httpRequest } from '@socketsecurity/lib/http-request' +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { REPO_ROOT } from '../paths.mts' +import { ensureNpmIdentity } from '../publish-infra/npm/auth-identity.mts' +import { cacheBustedRead } from '../publish-infra/npm/registry.mts' +import { runPlaceholder } from '../publish-infra/npm/placeholder.mts' +import type { + Access, + PlaceholderResult, +} from '../publish-infra/npm/placeholder.mts' +import type { PublishingAccessRead } from '../publish-infra/npm/access-parse.mts' +import type { PublishingAccessDesired } from '../publish-infra/npm/access-plan.mts' + +export interface ExecResult { + code: number + stderr: string + stdout: string +} + +export type RegistryJsonResult = + | { body: unknown; status: number } + | { unreachable: string } + +/** + * The bootstrap's complete effects surface. The six core members are the + * §3.6 contract; the operation members are thin named wrappers over the + * ported engine so tests can assert "runPlaceholder invoked once with the + * expected access" without faking a PTY. + */ +export interface BootstrapSeams { + ensureNpmIdentity(pkg: string): Promise + exec(cmd: string, args: string[], cwd: string): Promise + execPty(cmd: string, args: string[], cwd: string): Promise + listDir(p: string): string[] + now(): Date + readFile(p: string): string | undefined + readPublishingAccess(pkg: string): Promise + registryJson(url: string): Promise + resolveKitDep(specifier: string, fromRoot: string): boolean + runPlaceholder(config: { + access: Access + apply: boolean + names: string[] + }): Promise + writeFile(p: string, content: string): void + writePublishingAccess( + pkg: string, + desired: PublishingAccessDesired, + ): Promise<{ ok: boolean; read: PublishingAccessRead }> +} + +async function execCapture( + cmd: string, + args: string[], + cwd: string, +): Promise { + return await new Promise(resolve => { + const childPromise = spawn(cmd, args, { + cwd, + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }) + // The enriched promise rejects on non-zero exit; the exit code IS the + // signal here, so swallow the rejection and resolve from the events. + void childPromise.catch(() => undefined) + const child = childPromise.process + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.on('error', (e: Error) => { + resolve({ + code: 127, + stderr: stderr || `spawn ${cmd} failed: ${e.message}`, + stdout, + }) + }) + child.on('exit', code => { + resolve({ code: code ?? 0, stderr, stdout }) + }) + }) +} + +/** + * The real seams. The publishing-access lanes open the sanctioned browser + * session lazily (dynamic import) so a plan-mode run that never reaches them + * loads no playwright at all. + */ +export function resolveSeams(): BootstrapSeams { + return { + ensureNpmIdentity: pkg => ensureNpmIdentity(pkg), + exec: execCapture, + execPty: async (cmd, args, cwd) => { + // The npm-web-auth router self-wraps in a PTY when it needs one, so + // an inherit-stdio spawn is the right lane: its APPROVE HERE + // passthrough reaches the operator directly. + const child = spawn(cmd, args, { cwd, stdio: 'inherit' }) + void child.catch(() => undefined) + return await new Promise((resolve, reject) => { + child.process.on('error', reject) + child.process.on('exit', code => resolve(code ?? 0)) + }) + }, + listDir: p => { + try { + return fs.readdirSync(p) + } catch { + return [] + } + }, + now: () => new Date(), + readFile: p => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return undefined + } + }, + readPublishingAccess: async pkg => { + const { openNpmBrowserSession } = + await import('../publish-infra/npm/browser-session.mts') + const { readPublishingAccessInPage } = + await import('../publish-infra/npm/access-page.mts') + const session = await openNpmBrowserSession({ scope: 'bootstrap' }) + try { + return await readPublishingAccessInPage(session.page, pkg) + } finally { + await session.close() + } + }, + registryJson: async url => { + const read = cacheBustedRead(url, 'application/vnd.npm.install-v1+json') + try { + const res = await httpRequest(read.url, { + headers: read.headers, + timeout: 15_000, + }) + let body: unknown + try { + body = JSON.parse(res.body.toString('utf8')) + } catch { + body = undefined + } + return { body, status: res.status } + } catch (e) { + return { unreachable: errorMessage(e) } + } + }, + resolveKitDep: (specifier, fromRoot) => { + try { + createRequire(path.join(fromRoot, 'package.json')).resolve(specifier) + return true + } catch { + return false + } + }, + runPlaceholder: config => + runPlaceholder({ + access: config.access, + apply: config.apply, + names: config.names, + }), + writeFile: (p, content) => { + fs.mkdirSync(path.dirname(p), { recursive: true }) + fs.writeFileSync(p, content) + }, + writePublishingAccess: async (pkg, desired) => { + const { openNpmBrowserSession } = + await import('../publish-infra/npm/browser-session.mts') + const { drivePublishingAccess } = + await import('../publish-infra/npm/access-page.mts') + const session = await openNpmBrowserSession({ scope: 'bootstrap' }) + try { + return await drivePublishingAccess(session.page, pkg, desired) + } finally { + await session.close() + } + }, + } +} + +/** + * Where the bootstrap's own repo root is — re-exported so steps never call + * `process.cwd()`. + */ +export { REPO_ROOT } diff --git a/release-kit/payload/scripts/socket-release/bootstrap/state.mts b/release-kit/payload/scripts/socket-release/bootstrap/state.mts new file mode 100644 index 00000000..6b843ab5 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/state.mts @@ -0,0 +1,185 @@ +/** + * @file Bootstrap state file — a REPORTING CACHE of step receipts, never + * authority (every step re-detects live before trusting anything here; + * deleting the file loses only history). Receipts are keyed to a + * `contextKey` derived from the repo slug + package name so a changed + * remote or subject invalidates every receipt loudly instead of resuming + * against the wrong package. Parsing/serialization is pure; fs is + * confined to `loadState`/`saveState`. + */ + +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { KitError } from './render.mts' +import type { StepId, StepReceipt } from './plan.mts' + +/** + * Repo-relative state file location (gitignored via the kit's gitignore + * block: `.cache/`). + */ +export const STATE_RELATIVE_PATH = '.cache/socket-release/bootstrap-state.json' + +export interface BootstrapState { + contextKey: string + package: { name: string; version: string } + receipts: Partial> + repo: { root: string; slug: string } + schemaVersion: 1 +} + +/** + * The receipt-invalidation key: same slug + same package name, or every + * receipt is void. + */ +export function contextKey(slug: string, packageName: string): string { + return createHash('sha256').update(`${slug} ${packageName}`).digest('hex') +} + +/** + * A fresh state for `expectedKey`. + */ +export function freshState(config: { + expectedKey: string + packageName: string + packageVersion: string + root: string + slug: string +}): BootstrapState { + const cfg = { __proto__: null, ...config } as typeof config + return { + contextKey: cfg.expectedKey, + package: { name: cfg.packageName, version: cfg.packageVersion }, + receipts: {}, + repo: { root: cfg.root, slug: cfg.slug }, + schemaVersion: 1, + } +} + +/** + * Parse a state file. Refuses (usage, exit 2) on a foreign schemaVersion, a + * contextKey that no longer matches the resolved repo/package, or corrupted + * JSON — a corrupted file must never silently read as fresh state. + */ +export function parseState( + raw: string, + expectedKey: string, + filePath: string, +): BootstrapState { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (e) { + throw new KitError( + { + fix: 'run with --reset to discard the corrupted state file (receipts are history only), then re-run — every step re-detects live state.', + saw: 'unparseable JSON', + wanted: 'a schemaVersion-1 bootstrap state document', + what: 'Bootstrap state file is corrupted.', + where: filePath, + }, + 2, + { cause: e }, + ) + } + const doc = parsed as Partial | null + if (!doc || typeof doc !== 'object' || doc.schemaVersion !== 1) { + throw new KitError( + { + fix: 'run with --reset to discard it — receipts are history only.', + saw: `schemaVersion ${String(doc && typeof doc === 'object' ? doc.schemaVersion : doc)}`, + wanted: 'schemaVersion 1', + what: 'Bootstrap state file has a foreign schema.', + where: filePath, + }, + 2, + ) + } + if (doc.contextKey !== expectedKey) { + throw new KitError( + { + fix: 'run with --reset — the state belongs to a different repo/package context.', + saw: `contextKey ${String(doc.contextKey)}`, + wanted: `contextKey ${expectedKey} (sha256 of " ")`, + what: 'Bootstrap state file belongs to another context.', + where: filePath, + }, + 2, + ) + } + return { + contextKey: doc.contextKey, + package: doc.package ?? { name: '', version: '' }, + receipts: doc.receipts ?? {}, + repo: doc.repo ?? { root: '', slug: '' }, + schemaVersion: 1, + } +} + +/** + * Serialize with a trailing newline (fleet file hygiene). + */ +export function serializeState(state: BootstrapState): string { + return `${JSON.stringify(state, null, 2)}\n` +} + +/** + * A copy of `state` with `step`'s receipt replaced. Pure. + */ +export function withReceipt( + state: BootstrapState, + step: StepId, + receipt: StepReceipt, +): BootstrapState { + return { + ...state, + receipts: { ...state.receipts, [step]: receipt }, + } +} + +/** + * Load the state for a context: absent file → fresh state; present → + * parsed + validated (throws the §5 usage refusal on mismatch). + */ +export function loadState(config: { + expectedKey: string + packageName: string + packageVersion: string + root: string + slug: string +}): BootstrapState { + const cfg = { __proto__: null, ...config } as typeof config + const filePath = path.join(cfg.root, STATE_RELATIVE_PATH) + if (!fs.existsSync(filePath)) { + return freshState(cfg) + } + return parseState( + fs.readFileSync(filePath, 'utf8'), + cfg.expectedKey, + filePath, + ) +} + +/** + * Persist the state (apply mode only — plan mode writes nothing, not even + * this file). + */ +export function saveState(root: string, state: BootstrapState): void { + const filePath = path.join(root, STATE_RELATIVE_PATH) + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, serializeState(state)) +} + +/** + * `--reset`: delete the state file. Receipts are history only, so this can + * never lose real progress. + */ +export function resetState(root: string): boolean { + const filePath = path.join(root, STATE_RELATIVE_PATH) + if (!fs.existsSync(filePath)) { + return false + } + fs.rmSync(filePath, { force: true }) + return true +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/github-env.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/github-env.mts new file mode 100644 index 00000000..a10b0042 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/github-env.mts @@ -0,0 +1,362 @@ +/** + * @file Step 4 — github-env: stand up the deployment environments the + * channel workflows pin (`npm-publish`, `github-release`, `cargo-publish`, + * `brew-publish`), each restricted to exactly the target branch via + * custom branch policies. API BEFORE BROWSER: the `gh api` lane is + * unconditionally first (PUT is idempotent; policies are LISTED before any + * POST so duplicates are never created), and the browser fallback is GATE + * TEXT ONLY — no tool ever drives github.com. HTTP 403 blocks on + * `ghEnvGate`. + */ + +import { ghEnvGate } from '../../_shared/human-gate.mts' +import type { + Check, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { BootstrapSeams, ExecResult } from '../seams.mts' + +export const id = 'github-env' as const + +/** + * The environment each channel's workflow pins. + */ +export const CHANNEL_ENVIRONMENTS: Readonly> = { + brew: 'brew-publish', + crates: 'cargo-publish', + 'github-release': 'github-release', + npm: 'npm-publish', +} + +/** + * The environments this run must stand up, in channel order, deduped. + */ +export function desiredEnvironments(channels: readonly string[]): string[] { + const envs = new Set() + for (let i = 0, { length } = channels; i < length; i += 1) { + const env = CHANNEL_ENVIRONMENTS[channels[i]!] + if (env) { + envs.add(env) + } + } + return [...envs] +} + +export interface GithubEnvInputs { + envList: ExecResult + policies: Record +} + +export type EnvProbeState = + | 'forbidden' + | 'garbled' + | 'missing' + | 'restricted-ok' + | 'unrestricted' + | 'wrong-branch' + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const envList = await seams.exec( + 'gh', + ['api', `repos/${ctx.slug}/environments`], + ctx.repoRoot, + ) + const policies: Record = {} + const envs = desiredEnvironments(ctx.channels) + for (let i = 0, { length } = envs; i < length; i += 1) { + const env = envs[i]! + // eslint-disable-next-line no-await-in-loop -- serial: a handful of environments, and gh api rate limits favor pacing. + policies[env] = await seams.exec( + 'gh', + [ + 'api', + `repos/${ctx.slug}/environments/${env}/deployment-branch-policies`, + ], + ctx.repoRoot, + ) + } + return { envList, policies } +} + +function isForbidden(result: ExecResult): boolean { + return ( + result.code !== 0 && + /HTTP 403|status:? ?403|Forbidden/i.test(result.stderr + result.stdout) + ) +} + +/** + * Classify one environment's probes into the six honest states. Pure — + * exported for tests. A garbled/unknown response NEVER reads as + * `restricted-ok`. + */ +export function classifyEnvProbe(config: { + branch: string + env: string + envList: ExecResult + policy: ExecResult | undefined +}): EnvProbeState { + const cfg = { __proto__: null, ...config } as typeof config + if (isForbidden(cfg.envList) || (cfg.policy && isForbidden(cfg.policy))) { + return 'forbidden' + } + if (cfg.envList.code !== 0) { + return 'garbled' + } + let listed: Array<{ + deployment_branch_policy?: { + custom_branch_policies?: boolean | undefined + protected_branches?: boolean | undefined + } | null + name?: string | undefined + }> + try { + const parsed = JSON.parse(cfg.envList.stdout) as { + environments?: unknown + } + if (!Array.isArray(parsed.environments)) { + return 'garbled' + } + listed = parsed.environments as typeof listed + } catch { + return 'garbled' + } + const entry = listed.find(e => e.name === cfg.env) + if (!entry) { + return 'missing' + } + const policy = entry.deployment_branch_policy + if (!policy || policy.custom_branch_policies !== true) { + return 'unrestricted' + } + if (!cfg.policy || cfg.policy.code !== 0) { + return 'garbled' + } + let branches: string[] + try { + const parsed = JSON.parse(cfg.policy.stdout) as { + branch_policies?: Array<{ name?: string | undefined }> | undefined + } + if (!Array.isArray(parsed.branch_policies)) { + return 'garbled' + } + branches = parsed.branch_policies + .map(p => p.name) + .filter((n): n is string => typeof n === 'string') + } catch { + return 'garbled' + } + return branches.length === 1 && branches[0] === cfg.branch + ? 'restricted-ok' + : 'wrong-branch' +} + +/** + * Classify every desired environment. Pure — exported for tests. + */ +export function classifyEnvProbes( + inputs: GithubEnvInputs, + ctx: StepContext, +): StepDetection { + const branch = ctx.branch ?? ctx.defaultBranch + const envs = desiredEnvironments(ctx.channels) + const checks: Check[] = [] + const states: Record = {} + for (let i = 0, { length } = envs; i < length; i += 1) { + const env = envs[i]! + const state = classifyEnvProbe({ + branch, + env, + envList: inputs.envList, + policy: inputs.policies[env], + }) + states[env] = state + checks.push({ + fix: + state === 'restricted-ok' + ? null + : state === 'forbidden' + ? 'grant environment write access (repo admin / token scopes) or follow the gate below.' + : `run: node scripts/socket-release/bootstrap.mts github-env --apply`, + id: `env-${env}`, + ok: state === 'restricted-ok', + saw: state, + wanted: `environment ${env} restricted to exactly [${branch}]`, + }) + } + const forbidden = envs.find(env => states[env] === 'forbidden') + if (forbidden) { + return { + checks, + detail: `GitHub refused environment reads/writes on ${ctx.slug} (HTTP 403).`, + done: false, + gate: ghEnvGate( + ctx.slug, + forbidden, + 'the bootstrap resumes at github-env.', + ), + state: 'forbidden', + } + } + const garbled = envs.find(env => states[env] === 'garbled') + if (garbled) { + return { + checks, + detail: `the environment read for ${garbled} on ${ctx.slug} did not parse — refusing to classify it (never restricted-ok by default).`, + done: false, + failed: true, + state: 'garbled', + } + } + const pending = envs.filter(env => states[env] !== 'restricted-ok') + return { + checks, + detail: + pending.length === 0 + ? `every desired environment is restricted to [${branch}]` + : `${pending.length} environment(s) need standing up: ${pending.join(', ')}`, + done: pending.length === 0, + state: pending.length === 0 ? 'restricted-ok' : 'pending', + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyEnvProbes(inputs as GithubEnvInputs, ctx) +} + +/** + * The exact `gh api` argv fixes for the pending environments: one idempotent + * PUT per env, then list-before-POST for the branch policy so duplicates are + * never created. Pure — exported for tests. + */ +export function planEnvFixes(config: { + branch: string + envs: readonly string[] + slug: string +}): Array<{ + argv: string[] + env: string + listBeforePost?: string[] | undefined +}> { + const cfg = { __proto__: null, ...config } as typeof config + const fixes: Array<{ + argv: string[] + env: string + listBeforePost?: string[] | undefined + }> = [] + for (let i = 0, { length } = cfg.envs; i < length; i += 1) { + const env = cfg.envs[i]! + fixes.push({ + argv: [ + 'api', + '-X', + 'PUT', + `repos/${cfg.slug}/environments/${env}`, + '-F', + 'deployment_branch_policy[protected_branches]=false', + '-F', + 'deployment_branch_policy[custom_branch_policies]=true', + ], + env, + }) + fixes.push({ + argv: [ + 'api', + '-X', + 'POST', + `repos/${cfg.slug}/environments/${env}/deployment-branch-policies`, + '-f', + `name=${cfg.branch}`, + '-f', + 'type=branch', + ], + env, + listBeforePost: [ + 'api', + `repos/${cfg.slug}/environments/${env}/deployment-branch-policies`, + '--jq', + '[.branch_policies[].name]', + ], + }) + } + return fixes +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done) { + return { effects: [] } + } + const branch = ctx.branch ?? ctx.defaultBranch + const pending = detection.checks.filter(c => !c.ok).map(c => c.id.slice(4)) + const fixes = planEnvFixes({ branch, envs: pending, slug: ctx.slug }) + return { + effects: fixes.map(f => ({ + applied: false, + description: `gh ${f.argv.join(' ')}`, + kind: 'gh-api' as const, + })), + } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const branch = ctx.branch ?? ctx.defaultBranch + const effects: StepApplyResult['effects'] = [] + for (let i = 0, { length } = stepPlan.effects; i < length; i += 1) { + const effect = stepPlan.effects[i]! + const argv = effect.description.replace(/^gh /, '').split(' ') + if (argv.includes('-X') && argv.includes('POST')) { + // List-before-POST: never create a duplicate branch policy. + // eslint-disable-next-line no-await-in-loop -- serial: PUT-then-POST ordering is the API contract. + const list = await seams.exec( + 'gh', + ['api', argv[3]!, '--jq', '[.branch_policies[].name]'], + ctx.repoRoot, + ) + let existing: string[] = [] + try { + const parsed: unknown = JSON.parse(list.stdout) + existing = Array.isArray(parsed) + ? parsed.filter((n): n is string => typeof n === 'string') + : [] + } catch { + existing = [] + } + if (existing.includes(branch)) { + effects.push({ ...effect, applied: false }) + continue + } + } + // eslint-disable-next-line no-await-in-loop -- serial: PUT-then-POST ordering is the API contract. + const result = await seams.exec('gh', argv, ctx.repoRoot) + if (isForbidden(result)) { + return { + effects, + gate: ghEnvGate( + ctx.slug, + argv + .find(a => a.includes('/environments/')) + ?.split('/environments/')[1] + ?.split('/')[0] ?? 'npm-publish', + 'the bootstrap resumes at github-env.', + ), + } + } + if (result.code !== 0) { + throw new Error( + `gh ${argv.join(' ')} exited ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`, + ) + } + effects.push({ ...effect, applied: true }) + } + return { effects } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-permissive.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-permissive.mts new file mode 100644 index 00000000..fa71366d --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-permissive.mts @@ -0,0 +1,216 @@ +/** + * @file Step 3 — npm-access-permissive: ensure the package's + * publishing-access settings permit BOTH direct and staged publishing — + * but ONLY while the one-time placeholder publish is still pending. Once + * the name is live the placeholder succeeded and this step is + * already-done by definition: a re-run NEVER re-widens permissions (the + * live-name short-circuit fires before any browser read). The browser + * read/write lane runs only under `--apply` (plan mode opens no browser), + * and an unreadable page refuses rather than classifying. + */ + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' +import { browserSessionGate } from '../../_shared/human-gate.mts' +import { PERMISSIVE_ACCESS } from '../../publish-infra/npm/access-plan.mts' +import type { PublishingAccessRead } from '../../publish-infra/npm/access-parse.mts' +import { classifyPackument } from './preflight.mts' +import type { + Check, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { BootstrapSeams, RegistryJsonResult } from '../seams.mts' + +export const id = 'npm-access-permissive' as const + +export interface AccessPermissiveInputs { + /** + * Undefined when the browser read was deliberately skipped (plan mode, or + * the live-name short-circuit). + */ + access: PublishingAccessRead | undefined + packument: RegistryJsonResult +} + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const packument = await seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ) + const live = classifyPackument(packument) === 'live' + // The browser opens ONLY under --apply and ONLY while the placeholder is + // pending — a live name never re-widens, and a plan run stays browserless. + const access = + ctx.apply && !live + ? await seams.readPublishingAccess(ctx.packageName) + : undefined + return { access, packument } +} + +/** + * Pure classification of the permissive step. Exported for tests. + */ +export function classifyAccessPermissive( + inputs: AccessPermissiveInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + const registryState = classifyPackument(inputs.packument) + if (registryState === 'unreachable') { + checks.push({ + fix: 'check the network/proxy and re-run — an unreachable registry is never read as an unclaimed name.', + id: 'registry-read', + ok: false, + saw: + 'unreachable' in inputs.packument + ? inputs.packument.unreachable + : `HTTP ${(inputs.packument as { status: number }).status}`, + wanted: 'a 200 packument or a definitive 404', + }) + return { + checks, + detail: `Refusing to read ${ctx.packageName}'s access state: the registry read failed.`, + done: false, + failed: true, + hardFail: true, + state: 'unreachable', + } + } + if (registryState === 'live') { + checks.push({ + fix: null, + id: 'never-re-widen', + ok: true, + saw: 'name live — the placeholder publish already succeeded', + wanted: 'permissive access only while the placeholder is pending', + }) + return { + checks, + detail: `${ctx.packageName} is live; publishing access is left untouched (a re-run never re-widens permissions).`, + done: true, + state: 'live', + } + } + if (inputs.access === undefined) { + checks.push({ + fix: null, + id: 'access-read-deferred', + ok: true, + saw: 'browser read deferred (plan mode opens no browser)', + wanted: 'a publishing-access read under --apply', + }) + return { + checks, + detail: `${ctx.packageName} is not yet live; the permissive ensure runs under --apply (npm defaults a brand-new package to permissive).`, + done: false, + state: 'pending-unread', + } + } + if (inputs.access.state === 'unknown') { + checks.push({ + fix: null, + id: 'access-page-unreadable', + ok: true, + saw: 'no readable publishing-access block (package likely not created yet)', + wanted: 'the signed-in access page, once the package exists', + }) + return { + checks, + detail: `${ctx.packageName} has no readable access page yet — npm defaults a brand-new package to permissive, and the placeholder apply re-ensures it right after the publish.`, + done: true, + state: 'not-created', + } + } + if (inputs.access.state === 'both-enabled') { + checks.push({ + fix: null, + id: 'access-permissive', + ok: true, + saw: 'both-enabled', + wanted: + 'direct + staged publishing enabled while the placeholder is pending', + }) + return { + checks, + detail: `${ctx.packageName} already permits both direct and staged publishing.`, + done: true, + state: 'both-enabled', + } + } + checks.push({ + fix: null, + id: 'access-needs-widening', + ok: false, + saw: inputs.access.state, + wanted: 'both-enabled while the placeholder publish is pending', + }) + return { + checks, + detail: `${ctx.packageName} reads ${inputs.access.state}; the pending placeholder needs both direct and staged publishing enabled.`, + done: false, + state: inputs.access.state, + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyAccessPermissive(inputs as AccessPermissiveInputs, ctx) +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done) { + return { effects: [] } + } + return { + effects: [ + { + applied: false, + description: `enable direct + staged publishing (permissive) on ${ctx.packageName} via the sanctioned browser session`, + kind: 'npm-access', + }, + ], + } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + if (stepPlan.effects.length === 0) { + return { effects: [] } + } + const write = await seams.writePublishingAccess( + ctx.packageName, + PERMISSIVE_ACCESS, + ) + if (!write.ok) { + return { + effects: [ + { + applied: false, + description: `enable direct + staged publishing (permissive) on ${ctx.packageName} via the sanctioned browser session`, + kind: 'npm-access', + }, + ], + gate: browserSessionGate( + `the publishing-access save on ${ctx.packageName} did not verify — the page may need your sign-in or 2FA.`, + 'sign in to npm in the Chrome window the tool opened, then re-run the step.', + 'say "retry the access write" and I re-run `node scripts/socket-release/bootstrap.mts npm-access-permissive --apply` with the session open.', + 'the bootstrap resumes at npm-access-permissive.', + ), + } + } + return { + effects: [ + { + applied: true, + description: `enable direct + staged publishing (permissive) on ${ctx.packageName} via the sanctioned browser session`, + kind: 'npm-access', + }, + ], + } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts new file mode 100644 index 00000000..e9a7e8f5 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts @@ -0,0 +1,201 @@ +/** + * @file Step 7 — npm-access-staged-only: TIGHTEN AFTER. Once the direct + * placeholder publish has succeeded and the trusted publisher stands + * (both are DAG preconditions — disabling direct publishing before OIDC + * works would brick the package's publish path), disable DIRECT + * publishing in the npm web UI, leaving ONLY staged/trusted publishing + * enabled. From this point the package is staged-only. Idempotent: a + * package already staged-only detects as done and no-ops; the browser + * opens only under `--apply`. + */ + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' +import { browserSessionGate } from '../../_shared/human-gate.mts' +import { STAGED_ONLY_ACCESS } from '../../publish-infra/npm/access-plan.mts' +import type { PublishingAccessRead } from '../../publish-infra/npm/access-parse.mts' +import { classifyPackument } from './preflight.mts' +import type { + Check, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { BootstrapSeams, RegistryJsonResult } from '../seams.mts' + +export const id = 'npm-access-staged-only' as const + +export interface AccessStagedOnlyInputs { + access: PublishingAccessRead | undefined + packument: RegistryJsonResult +} + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const packument = await seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ) + const live = classifyPackument(packument) === 'live' + // Plan mode opens no browser; the tighten needs the read only under + // --apply and only once the name is live. + const access = + ctx.apply && live + ? await seams.readPublishingAccess(ctx.packageName) + : undefined + return { access, packument } +} + +/** + * Pure classification of the tighten step. Exported for tests. + */ +export function classifyAccessStagedOnly( + inputs: AccessStagedOnlyInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + const registryState = classifyPackument(inputs.packument) + if (registryState === 'unreachable') { + checks.push({ + fix: 'check the network/proxy and re-run — an unreachable registry read is never classified.', + id: 'registry-read', + ok: false, + saw: + 'unreachable' in inputs.packument + ? inputs.packument.unreachable + : `HTTP ${(inputs.packument as { status: number }).status}`, + wanted: 'a 200 packument', + }) + return { + checks, + detail: `Refusing to read ${ctx.packageName}'s access state: the registry read failed.`, + done: false, + failed: true, + hardFail: true, + state: 'unreachable', + } + } + if (registryState !== 'live') { + checks.push({ + fix: `run: node scripts/socket-release/bootstrap.mts placeholder --apply --reserve ${ctx.packageName}`, + id: 'registry-name-live', + ok: false, + saw: registryState, + wanted: 'the package live on the registry before its access is tightened', + }) + return { + checks, + detail: `${ctx.packageName} is not live yet — the tighten step runs after the placeholder resolves.`, + done: false, + failed: true, + state: 'not-live', + } + } + if (inputs.access === undefined) { + checks.push({ + fix: null, + id: 'access-read-deferred', + ok: true, + saw: 'browser read deferred (plan mode opens no browser)', + wanted: 'a publishing-access read under --apply', + }) + return { + checks, + detail: `${ctx.packageName} is live; the staged-only tighten runs under --apply (browser read + uncheck direct publishing).`, + done: false, + state: 'unread', + } + } + if (inputs.access.state === 'unknown') { + checks.push({ + fix: 'sign in to npm in the sanctioned browser session and re-run — an unreadable page is never a state.', + id: 'access-page-unreadable', + ok: false, + saw: 'unknown', + wanted: 'a readable publishing-access block', + }) + return { + checks, + detail: `${ctx.packageName}'s access page could not be read — refusing to classify.`, + done: false, + gate: browserSessionGate( + `the publishing-access read on ${ctx.packageName} needs the signed-in browser session.`, + 'sign in to npm in the Chrome window the tool opened, then re-run the step.', + 'say "retry the access read" and I re-run `node scripts/socket-release/bootstrap.mts npm-access-staged-only --apply` with the session open.', + 'the bootstrap resumes at npm-access-staged-only.', + ), + state: 'unknown', + } + } + if (inputs.access.state === 'staged-only') { + checks.push({ + fix: null, + id: 'access-staged-only', + ok: true, + saw: 'staged-only (direct publishing disabled)', + wanted: 'staged publishing only', + }) + return { + checks, + detail: `${ctx.packageName} is staged-only — direct publishing is disabled.`, + done: true, + state: 'staged-only', + } + } + checks.push({ + fix: 'run: node scripts/socket-release/bootstrap.mts npm-access-staged-only --apply', + id: 'access-needs-tightening', + ok: false, + saw: inputs.access.state, + wanted: 'staged-only (direct publishing disabled, staged enabled)', + }) + return { + checks, + detail: `${ctx.packageName} reads ${inputs.access.state}; direct publishing must be disabled now that trusted publishing stands.`, + done: false, + state: inputs.access.state, + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyAccessStagedOnly(inputs as AccessStagedOnlyInputs, ctx) +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done || detection.failed) { + return { effects: [] } + } + return { + effects: [ + { + applied: false, + description: `disable direct publishing on ${ctx.packageName} (uncheck it in the npm web UI via the sanctioned browser session), leaving staged publishing only`, + kind: 'npm-access', + }, + ], + } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + if (stepPlan.effects.length === 0) { + return { effects: [] } + } + const write = await seams.writePublishingAccess( + ctx.packageName, + STAGED_ONLY_ACCESS, + ) + return { + effects: [ + { + applied: write.ok, + description: `disable direct publishing on ${ctx.packageName} (uncheck it in the npm web UI via the sanctioned browser session), leaving staged publishing only`, + kind: 'npm-access', + }, + ], + } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/placeholder.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/placeholder.mts new file mode 100644 index 00000000..d40bfbb4 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/placeholder.mts @@ -0,0 +1,313 @@ +/** + * @file Step 2 — placeholder: the sanctioned ONE-TIME publish of + * `@0.0.0` that claims the package name. This is the bootstrap's + * single irreversible act (the version is burned forever; unpublish closes + * after 72h), so the consent policy is hard opt-in: `--apply` alone never + * publishes — it blocks on `reserveNameGate` until the invocation carries + * `--reserve ` (a mismatch is a usage refusal naming + * saw/wanted). Detection short-circuits on a live name, making a + * double-publish structurally unreachable, and an unreachable registry is + * NEVER read as unclaimed. Post-publish, the publishing-access settings + * are ensured PERMISSIVE (direct + staged both enabled) while the + * placeholder is still pending — the one window that shape is ever + * applied; the `npm-access-staged-only` step tightens it after trusted + * publishing stands. + */ + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' +import { + npmAuthGate, + placeholderPromoteGate, + reserveNameGate, +} from '../../_shared/human-gate.mts' +import { PLACEHOLDER_VERSION } from '../../publish-infra/npm/placeholder.mts' +import { parseStageListJson } from '../../publish-infra/npm/shared.mts' +import type { StageListEntry } from '../../publish-infra/npm/shared.mts' +import { PERMISSIVE_ACCESS } from '../../publish-infra/npm/access-plan.mts' +import { classifyPackument } from './preflight.mts' +import type { + Check, + Effect, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { + BootstrapSeams, + ExecResult, + RegistryJsonResult, +} from '../seams.mts' + +export const id = 'placeholder' as const + +export interface PlaceholderInputs { + packument: RegistryJsonResult + stageList: ExecResult | undefined +} + +export type PlaceholderState = + | 'auth-unknown' + | 'live' + | 'staged-pending' + | 'unclaimed' + | 'unreachable' + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const packument = await seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ) + const state = classifyPackument(packument) + const stageList = + state === 'live' + ? undefined + : await seams.exec('pnpm', ['stage', 'list', '--json'], ctx.repoRoot) + return { packument, stageList } +} + +/** + * Find this package's staged 0.0.0 entry in a stage list, tolerating an + * auth-dead or garbled list by classifying it honestly. Pure. + */ +export function findStagedPlaceholder( + stageList: ExecResult | undefined, + packageName?: string | undefined, +): { entry?: StageListEntry | undefined; state: 'auth-unknown' | 'read' } { + if (stageList === undefined) { + return { state: 'read' } + } + if (stageList.code !== 0) { + return { state: 'auth-unknown' } + } + try { + const entries = parseStageListJson(stageList.stdout) + const entry = + packageName === undefined + ? entries[0] + : entries.find(e => e.name === packageName) + return { entry, state: 'read' } + } catch { + // StageListAuthError and any unparseable list: an unauthenticated stage + // list reads as EMPTY, never as an error — so a parse failure must be + // classified as auth-unknown, never as "nothing staged". + return { state: 'auth-unknown' } + } +} + +/** + * The placeholder detection state machine. Pure — exported for tests. + */ +export function classifyPlaceholderState( + inputs: PlaceholderInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + const registryState = classifyPackument(inputs.packument) + if (registryState === 'unreachable') { + checks.push({ + fix: 'check the network/proxy and re-run — an unreachable registry is never read as an unclaimed name.', + id: 'registry-read', + ok: false, + saw: + 'unreachable' in inputs.packument + ? inputs.packument.unreachable + : `HTTP ${(inputs.packument as { status: number }).status}`, + wanted: 'a 200 packument or a definitive 404', + }) + return { + checks, + detail: `Refusing to classify ${ctx.packageName} as unpublished: the registry read failed.`, + done: false, + failed: true, + hardFail: true, + state: 'unreachable', + } + } + if (registryState === 'live') { + checks.push({ + fix: null, + id: 'registry-name-live', + ok: true, + saw: 'at least one version live on the registry', + wanted: 'the name resolves', + }) + return { + checks, + detail: `${ctx.packageName} is live on the registry — the name is claimed; nothing to publish.`, + done: true, + state: 'live', + } + } + const staged = findStagedPlaceholder(inputs.stageList, ctx.packageName) + if (staged.state === 'auth-unknown') { + checks.push({ + fix: 'log in (node scripts/socket-release/npm-web-auth.mts login) before --apply', + id: 'stage-list-unknown', + ok: false, + saw: 'auth-unavailable', + wanted: 'a readable stage list', + }) + return { + authUnknown: true, + checks, + detail: `${ctx.packageName} is not live and the stage list could not be read (auth unavailable).`, + done: false, + state: 'auth-unknown', + } + } + if (staged.entry) { + const stageId = staged.entry.stageId ?? '(unknown stage id)' + checks.push({ + fix: null, + id: 'staged-placeholder-pending', + ok: false, + saw: `staged entry ${stageId} awaiting promotion`, + wanted: 'the name live on the registry', + }) + return { + checks, + detail: `${ctx.packageName}@${PLACEHOLDER_VERSION} is staged (${stageId}) and waiting on promotion.`, + done: false, + gate: placeholderPromoteGate( + ctx.packageName, + stageId, + 'the bootstrap resumes at placeholder once the name resolves as live.', + ), + state: 'staged-pending', + } + } + if (ctx.packageName.startsWith('@') && ctx.access === undefined) { + // §6 byte contract: no accidental `public` — a scoped package refuses + // before planning without an explicit access level. + checks.push({ + fix: 'set "npm": { "access": "restricted" } in .config/socket-release.json, or pass --access restricted.', + id: 'access-resolved', + ok: false, + saw: 'none of them is set', + wanted: 'public or restricted', + }) + return { + checks, + detail: `Placeholder needs an explicit access level for the scoped package ${ctx.packageName}.`, + done: false, + failed: true, + state: 'access-unresolved', + } + } + checks.push({ + fix: null, + id: 'registry-name-unclaimed', + ok: true, + saw: 'definitive 404 — the name is unclaimed', + wanted: 'a definitive registry answer', + }) + return { + checks, + detail: `${ctx.packageName} is unclaimed on npm — reserving it publishes a real ${PLACEHOLDER_VERSION} placeholder.`, + done: false, + state: 'unclaimed', + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyPlaceholderState(inputs as PlaceholderInputs, ctx) +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done || detection.state !== 'unclaimed') { + return { effects: [] } + } + const access = ctx.access ?? 'public' + // Consent policy: --reserve must byte-equal the resolved package name. + if (ctx.reserve !== undefined && ctx.reserve !== ctx.packageName) { + return { + effects: [], + usage: { saw: ctx.reserve, wanted: ctx.packageName }, + } + } + const effects = [ + { + applied: false, + description: `publish ${ctx.packageName}@${PLACEHOLDER_VERSION} --access ${access} via npm-web-auth PTY (placeholder package: package.json + one-line README, files: [])`, + kind: 'registry-publish' as const, + }, + { + applied: false, + description: `ensure publishing access PERMISSIVE (direct + staged) on ${ctx.packageName} while the placeholder is pending`, + kind: 'npm-access' as const, + }, + ] + if (ctx.apply && ctx.reserve === undefined) { + // Yes-mode does NOT substitute for --reserve: publishing 0.0.0 is the + // irreversible act, so the gate renders even under --yes. + return { + effects, + gate: reserveNameGate( + ctx.packageName, + access, + 'the bootstrap resumes at placeholder and continues to the remaining steps.', + ), + } + } + return { effects } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + if (stepPlan.effects.length === 0) { + return { effects: [] } + } + const access = ctx.access ?? 'public' + const identified = await seams.ensureNpmIdentity(ctx.packageName) + if (!identified) { + return { + effects: [], + gate: npmAuthGate(ctx.repoRoot, 'the bootstrap resumes at placeholder.'), + } + } + const results = await seams.runPlaceholder({ + access, + apply: true, + names: [ctx.packageName], + }) + const publishEffect: Effect = { + applied: true, + description: `publish ${ctx.packageName}@${PLACEHOLDER_VERSION} --access ${access} via npm-web-auth PTY (placeholder package: package.json + one-line README, files: [])`, + kind: 'registry-publish', + } + const outcome = results[0] + if (!outcome || outcome.status === 'failed' || outcome.status === 'skipped') { + throw new Error( + `placeholder publish for ${ctx.packageName} did not complete: ${outcome?.detail ?? 'no result'}`, + ) + } + // AMENDMENT: permissive-first — the settings exist only once the publish + // created the package, so ensure BOTH direct and staged publishing are + // enabled immediately after, while the placeholder is still pending. A + // name that re-reads as live needs nothing (and is never re-widened). + const effects: Effect[] = [publishEffect] + const reread = await seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ) + if (classifyPackument(reread) !== 'live') { + const accessRead = await seams.readPublishingAccess(ctx.packageName) + if (accessRead.state !== 'unknown' && accessRead.state !== 'both-enabled') { + const write = await seams.writePublishingAccess( + ctx.packageName, + PERMISSIVE_ACCESS, + ) + effects.push({ + applied: write.ok, + description: `ensure publishing access PERMISSIVE (direct + staged) on ${ctx.packageName} while the placeholder is pending`, + kind: 'npm-access', + }) + } + } + return { effects } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/preflight.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/preflight.mts new file mode 100644 index 00000000..dcb442f7 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/preflight.mts @@ -0,0 +1,313 @@ +/** + * @file Step 1 — preflight: the ten read-only checks that decide whether + * this repo can be stood up at all. `plan` and `apply` are identical (a + * read-only step performs the same reads either way); classification is + * pure over the gathered inputs so every check arm is unit-testable from + * inline data. Fail-closed rule: an unreachable registry FAILS the + * `registry-reachable` check — it is never read as "unpublished". + */ + +import path from 'node:path' + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' +import { parseGitHubSlug } from '../../publish-infra/pin-readme.mts' +import { npmScratchCwd } from '../../publish-infra/npm/shared.mts' +import type { + Check, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { + BootstrapSeams, + ExecResult, + RegistryJsonResult, +} from '../seams.mts' + +/** + * Node floor for the kit CLIs: native `.mts` execution (no tsx, no + * strip-types flag). + */ +export const NODE_FLOOR = { major: 22, minor: 18 } + +export const KIT_DEP_PINS: ReadonlyArray<{ pin: string; specifier: string }> = [ + { pin: '6.5.2', specifier: '@socketsecurity/lib' }, + { pin: '4.1.3', specifier: '@socketsecurity/sdk' }, + { pin: '1.61.1', specifier: 'playwright-core' }, +] + +export interface PreflightInputs { + deps: { lib: boolean; playwright: boolean; sdk: boolean } + ghAuth: ExecResult + ghRepo: ExecResult + gitOrigin: ExecResult + npmTrustHelp: ExecResult + packageJsonRaw: string | undefined + packument: RegistryJsonResult + pnpmStageHelp: ExecResult +} + +/** + * Gather the ten checks' inputs — reads only. + */ +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const [gitOrigin, ghRepo, ghAuth, pnpmStageHelp, npmTrustHelp, packument] = + await Promise.all([ + seams.exec('git', ['remote', 'get-url', 'origin'], ctx.repoRoot), + seams.exec('gh', ['api', `repos/${ctx.slug}`], ctx.repoRoot), + seams.exec('gh', ['auth', 'status'], ctx.repoRoot), + seams.exec('pnpm', ['help', 'stage'], ctx.repoRoot), + seams.exec('npm', ['trust', '--help'], npmScratchCwd()), + seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ), + ]) + return { + deps: { + lib: seams.resolveKitDep( + '@socketsecurity/lib/errors/message', + ctx.repoRoot, + ), + playwright: seams.resolveKitDep('playwright-core', ctx.repoRoot), + sdk: seams.resolveKitDep('@socketsecurity/sdk', ctx.repoRoot), + }, + ghAuth, + ghRepo, + gitOrigin, + npmTrustHelp, + packageJsonRaw: seams.readFile(path.join(ctx.repoRoot, 'package.json')), + packument, + pnpmStageHelp, + } +} + +/** + * Classify the running node version against the floor. Pure — exported for + * tests. + */ +export function nodeVersionOk(version: string): boolean { + const m = /^v?(\d+)\.(\d+)/.exec(version) + if (!m) { + return false + } + const major = Number(m[1]) + const minor = Number(m[2]) + return ( + major > NODE_FLOOR.major || + (major === NODE_FLOOR.major && minor >= NODE_FLOOR.minor) + ) +} + +/** + * Classify a packument read into the three honest states. Pure — exported + * for tests and reused by placeholder/verify. + */ +export function classifyPackument( + packument: RegistryJsonResult, +): 'live' | 'unpublished' | 'unreachable' { + if ('unreachable' in packument) { + return 'unreachable' + } + if (packument.status === 404) { + return 'unpublished' + } + if (packument.status >= 200 && packument.status < 300) { + const body = packument.body as + | { versions?: Record | undefined } + | undefined + return body && Object.keys(body.versions ?? {}).length > 0 + ? 'live' + : 'unpublished' + } + return 'unreachable' +} + +/** + * The ten preflight checks over the gathered inputs. Pure. + */ +export function classifyPreflightInputs( + inputs: PreflightInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + const push = ( + id: string, + ok: boolean, + saw: string, + wanted: string, + fix: string | null, + ) => { + checks.push({ fix: ok ? null : fix, id, ok, saw, wanted }) + } + push( + 'node-version', + nodeVersionOk(ctx.nodeVersion), + ctx.nodeVersion, + `node >= ${NODE_FLOOR.major}.${NODE_FLOOR.minor} (native .mts execution)`, + 'run the kit CLIs with node >= 22.18 (nvm install 24).', + ) + const originUrl = inputs.gitOrigin.stdout.trim() + const slug = + inputs.gitOrigin.code === 0 ? parseGitHubSlug(originUrl) : undefined + push( + 'git-origin-github', + inputs.gitOrigin.code === 0 && slug !== undefined, + originUrl || `git remote get-url origin exited ${inputs.gitOrigin.code}`, + 'https://github.com//(.git) or git@github.com:/(.git)', + 'git remote set-url origin https://github.com//.git', + ) + let defaultBranchSaw = `gh api repos/${ctx.slug} exited ${inputs.ghRepo.code}` + let defaultBranchOk = false + if (inputs.ghRepo.code === 0) { + try { + const repo = JSON.parse(inputs.ghRepo.stdout) as { + default_branch?: string | undefined + visibility?: string | undefined + } + defaultBranchOk = typeof repo.default_branch === 'string' + defaultBranchSaw = `default branch ${repo.default_branch ?? '(none)'}, visibility ${repo.visibility ?? 'unknown'}` + } catch { + defaultBranchSaw = 'unparseable gh api response' + } + } + push( + 'default-branch', + defaultBranchOk, + defaultBranchSaw, + 'a readable repo with a default branch', + 'run `gh auth login` (or fix the slug with --repo ) and re-run.', + ) + if (ctx.visibility === 'private') { + push( + 'provenance-expectation', + true, + 'private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works', + 'informational', + null, + ) + } + let manifestOk = false + let manifestSaw = 'package.json missing' + if (inputs.packageJsonRaw !== undefined) { + try { + const pkg = JSON.parse(inputs.packageJsonRaw) as { + files?: unknown + name?: unknown + packageManager?: unknown + version?: unknown + } + const filesOk = Array.isArray(pkg.files) && pkg.files.length > 0 + const pmOk = + typeof pkg.packageManager === 'string' && + /^pnpm@\d+\.\d+\.\d+$/.test(pkg.packageManager) + manifestOk = + typeof pkg.name === 'string' && + typeof pkg.version === 'string' && + filesOk && + pmOk + manifestSaw = `name ${String(pkg.name)}, version ${String(pkg.version)}, files ${ + filesOk ? 'present' : 'missing/empty' + }, packageManager ${String(pkg.packageManager)}` + } catch { + manifestSaw = 'unparseable package.json' + } + } + push( + 'package-manifest', + manifestOk, + manifestSaw, + 'name + version + non-empty files + packageManager matching ^pnpm@X.Y.Z$', + 'fill in package.json: name, version, a files allow-list, and a pinned packageManager.', + ) + push( + 'pnpm-stage-support', + inputs.pnpmStageHelp.code === 0, + `pnpm help stage exited ${inputs.pnpmStageHelp.code}`, + 'exit 0 (staged publishing supported)', + 'Set "packageManager": "pnpm@11.17.0" in package.json and run pnpm install — the pinned pnpm predates staged publishing (this also fixes CI: pnpm/action-setup reads packageManager).', + ) + push( + 'npm-trust-support', + inputs.npmTrustHelp.code === 0, + `npm trust --help exited ${inputs.npmTrustHelp.code}`, + 'exit 0 (npm trust available)', + "upgrade npm to >= 12 (node 24 ships it): the trusted-publisher step drives 'npm trust'.", + ) + push( + 'gh-auth', + inputs.ghAuth.code === 0, + `gh auth status exited ${inputs.ghAuth.code}`, + 'exit 0 (gh authenticated)', + 'run `gh auth login`.', + ) + const missingDeps = KIT_DEP_PINS.filter(d => + d.specifier === '@socketsecurity/lib' + ? !inputs.deps.lib + : d.specifier === '@socketsecurity/sdk' + ? !inputs.deps.sdk + : !inputs.deps.playwright, + ) + push( + 'kit-deps-resolvable', + missingDeps.length === 0, + missingDeps.length === 0 + ? 'all three kit dependencies resolve' + : `unresolvable: ${missingDeps.map(d => d.specifier).join(', ')}`, + '@socketsecurity/lib + @socketsecurity/sdk + playwright-core resolvable from the repo root', + `pnpm add -D ${KIT_DEP_PINS.map(d => `${d.specifier}@${d.pin}`).join(' ')}`, + ) + const registryState = classifyPackument(inputs.packument) + push( + 'registry-reachable', + registryState !== 'unreachable', + registryState === 'unreachable' + ? `unreachable: ${'unreachable' in inputs.packument ? inputs.packument.unreachable : `HTTP ${(inputs.packument as { status: number }).status}`}` + : registryState, + 'a 200 packument or a definitive 404', + 'check the network/proxy and re-run — an unreachable registry is never read as an unclaimed name.', + ) + const scoped = ctx.packageName.startsWith('@') + push( + 'access-resolved', + !scoped || ctx.access !== undefined, + ctx.access ?? + 'none of config npm.access, publishConfig.access, --access is set', + 'public or restricted', + 'set "npm": { "access": "restricted" } in .config/socket-release.json, or pass --access restricted.', + ) + const failing = checks.filter(c => !c.ok) + return { + checks, + detail: + failing.length === 0 + ? 'all preflight checks pass' + : `${failing.length} preflight check(s) failing: ${failing.map(c => c.id).join(', ')}`, + done: failing.length === 0, + failed: failing.length > 0, + // Fail-closed in BOTH modes only for the unreachable registry; every + // other preflight gap renders `planned` in plan mode. + hardFail: registryState === 'unreachable', + state: failing.length === 0 ? 'ready' : 'not-ready', + } +} + +export const id = 'preflight' as const + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyPreflightInputs(inputs as PreflightInputs, ctx) +} + +export function plan(): StepPlan { + // Read-only step: nothing to perform, plan and apply are the same reads. + return { effects: [] } +} + +export async function apply(): Promise { + return { effects: [] } +} + +export { read as readPreflight } diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/staged-config.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/staged-config.mts new file mode 100644 index 00000000..b5b84189 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/staged-config.mts @@ -0,0 +1,403 @@ +/** + * @file Step 5 — staged-config: write the channel workflows (byte-identical + * to the LOCAL templates under `scripts/socket-release/templates/` — the + * local template is the authority), the four release scripts + + * `publishConfig.access` into package.json (JSON-surgical: key order, + * 2-space indent, trailing newline preserved), and the kit gitignore + * block. File writes ONLY, no commits — the operator commits (worktree + * hygiene). A workflow whose bytes diverge from its template is a CONFLICT + * refusal; `--force` (accepted only by this step) restores the kit + * version. + */ + +import path from 'node:path' + +import type { + Check, + Effect, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import { KitError } from '../render.mts' +import type { BootstrapSeams } from '../seams.mts' + +export const id = 'staged-config' as const + +/** + * The workflow file each channel installs. + */ +export const CHANNEL_WORKFLOWS: Readonly> = { + brew: 'brew-publish.yml', + crates: 'cargo-publish.yml', + 'github-release': 'github-release.yml', + npm: 'npm-publish.yml', +} + +/** + * The §3.2 scripts every consumer carries, exact strings. + */ +export const KIT_SCRIPTS: Readonly> = { + prepublishOnly: + "echo 'ERROR: publish via the socket-release kit (scripts/socket-release)' && exit 1", + release: 'node scripts/socket-release/bootstrap.mts', + 'release:npm': 'node scripts/socket-release/npm-publish.mts', + 'release:status': 'node scripts/socket-release/bootstrap.mts --status', +} + +/** + * The kit gitignore block, exactly two lines. + */ +export const GITIGNORE_BLOCK = '# socket-release-kit\n.cache/\n' + +export function workflowsForChannels(channels: readonly string[]): string[] { + const files = new Set() + for (let i = 0, { length } = channels; i < length; i += 1) { + const f = CHANNEL_WORKFLOWS[channels[i]!] + if (f) { + files.add(f) + } + } + return [...files] +} + +export interface StagedConfigItem { + id: string + source?: string | undefined + state: 'conflict' | 'missing' | 'ok' + target?: string | undefined +} + +export interface StagedConfigInputs { + gitignore: string | undefined + packageJsonRaw: string | undefined + targets: Record + templates: Record +} + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const files = workflowsForChannels(ctx.channels) + const templates: Record = {} + const targets: Record = {} + for (let i = 0, { length } = files; i < length; i += 1) { + const f = files[i]! + templates[f] = seams.readFile( + path.join(ctx.repoRoot, 'scripts/socket-release/templates/workflows', f), + ) + targets[f] = seams.readFile(path.join(ctx.repoRoot, '.github/workflows', f)) + } + return { + gitignore: seams.readFile(path.join(ctx.repoRoot, '.gitignore')), + packageJsonRaw: seams.readFile(path.join(ctx.repoRoot, 'package.json')), + targets, + templates, + } +} + +/** + * JSON-surgical package.json edit: parse, add the missing script entries and + * publishConfig.access, re-serialize with 2-space indent + trailing newline. + * JS object insertion order is preserved for string keys, so existing key + * order survives; new keys append to their object. Pure — exported for + * tests. + */ +export function editPackageJsonRaw( + raw: string, + access: string, +): { changed: boolean; next: string } { + const pkg = JSON.parse(raw) as Record + let changed = false + const scripts = + typeof pkg['scripts'] === 'object' && pkg['scripts'] !== null + ? (pkg['scripts'] as Record) + : {} + for (const [name, body] of Object.entries(KIT_SCRIPTS)) { + if (scripts[name] !== body) { + scripts[name] = body + changed = true + } + } + pkg['scripts'] = scripts + const publishConfig = + typeof pkg['publishConfig'] === 'object' && pkg['publishConfig'] !== null + ? (pkg['publishConfig'] as Record) + : {} + if (publishConfig['access'] !== access) { + publishConfig['access'] = access + changed = true + } + pkg['publishConfig'] = publishConfig + return { changed, next: `${JSON.stringify(pkg, null, 2)}\n` } +} + +/** + * Whether package.json already carries the exact §3.2 entries. Pure. + */ +export function packageJsonConforms( + raw: string | undefined, + access: string, +): boolean { + if (raw === undefined) { + return false + } + try { + const pkg = JSON.parse(raw) as { + publishConfig?: { access?: unknown } | undefined + scripts?: Record | undefined + } + return ( + Object.entries(KIT_SCRIPTS).every( + ([name, body]) => pkg.scripts?.[name] === body, + ) && pkg.publishConfig?.access === access + ) + } catch { + return false + } +} + +/** + * Classify the staged-config surface: per-workflow byte parity vs the LOCAL + * template, the package.json entries, and the gitignore block. Pure — + * exported for tests. + */ +export function classifyStagedConfig( + inputs: StagedConfigInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + const items: StagedConfigItem[] = [] + const files = workflowsForChannels(ctx.channels) + for (let i = 0, { length } = files; i < length; i += 1) { + const f = files[i]! + const template = inputs.templates[f] + const target = inputs.targets[f] + const state: StagedConfigItem['state'] = + template === undefined + ? 'missing' + : target === undefined + ? 'missing' + : target === template + ? 'ok' + : 'conflict' + items.push({ id: `workflow-${f}`, state }) + checks.push({ + fix: + state === 'ok' + ? null + : template === undefined + ? 'run the installer again — the local template is missing (node release-kit/install.mts --verify names the gap).' + : state === 'conflict' + ? 'reconcile your edits into the template question first, or re-run `bootstrap staged-config --apply --force` to restore the kit version.' + : 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + id: `workflow-${f}`, + ok: state === 'ok', + saw: + state === 'ok' + ? 'byte-identical to the local template' + : state === 'conflict' + ? 'bytes differing from the local template' + : template === undefined + ? 'local template missing' + : 'workflow not installed', + wanted: `.github/workflows/${f} byte-identical to scripts/socket-release/templates/workflows/${f}`, + }) + } + const access = ctx.access ?? 'restricted' + const pkgOk = packageJsonConforms(inputs.packageJsonRaw, access) + checks.push({ + fix: pkgOk + ? null + : 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + id: 'package-json-scripts', + ok: pkgOk, + saw: pkgOk + ? 'all four kit scripts + publishConfig.access present' + : 'kit scripts or publishConfig.access missing/divergent', + wanted: `release, release:status, release:npm, prepublishOnly scripts + publishConfig.access ${access}`, + }) + const gitignoreOk = + inputs.gitignore !== undefined && + inputs.gitignore.includes(GITIGNORE_BLOCK.trimEnd()) + checks.push({ + fix: gitignoreOk + ? null + : 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + id: 'gitignore-block', + ok: gitignoreOk, + saw: gitignoreOk ? 'kit block present' : 'kit block absent', + wanted: 'a `# socket-release-kit` + `.cache/` block in .gitignore', + }) + const failing = checks.filter(c => !c.ok) + const conflicts = items.filter(i => i.state === 'conflict') + return { + checks, + detail: + failing.length === 0 + ? 'staged-config surface is byte-complete' + : conflicts.length > 0 && !ctx.force + ? `${conflicts.length} workflow(s) diverge from their template — refusing to overwrite without --force` + : `${failing.length} staged-config item(s) pending`, + done: failing.length === 0, + state: + failing.length === 0 + ? 'ok' + : conflicts.length > 0 + ? 'conflict' + : 'pending', + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyStagedConfig(inputs as StagedConfigInputs, ctx) +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done) { + return { effects: [] } + } + const effects: Effect[] = [] + for (let i = 0, { length } = detection.checks; i < length; i += 1) { + const c = detection.checks[i]! + if (c.ok) { + continue + } + if (c.id.startsWith('workflow-')) { + const f = c.id.slice('workflow-'.length) + effects.push({ + applied: false, + description: `write .github/workflows/${f} from scripts/socket-release/templates/workflows/${f}${ctx.force ? ' (force restore)' : ''}`, + kind: 'file-write', + }) + } else if (c.id === 'package-json-scripts') { + effects.push({ + applied: false, + description: + 'surgical package.json edit: add the four kit scripts + publishConfig.access', + kind: 'file-write', + }) + } else if (c.id === 'gitignore-block') { + effects.push({ + applied: false, + description: 'append the kit block to .gitignore', + kind: 'file-write', + }) + } + } + return { effects } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + if (stepPlan.effects.length === 0) { + return { effects: [] } + } + const inputs = await read(ctx, seams) + const detection = classifyStagedConfig(inputs, ctx) + const files = workflowsForChannels(ctx.channels) + const effects: Effect[] = [] + // Conflict pre-scan: refuse BEFORE any write so a conflicted run performs + // zero writes, not a partial set. + if (!ctx.force) { + for (let i = 0, { length } = files; i < length; i += 1) { + const f = files[i]! + const template = inputs.templates[f] + const target = inputs.targets[f] + if ( + template !== undefined && + target !== undefined && + target !== template + ) { + throw new KitError( + { + fix: 'reconcile your edits into the template question first, or re-run `bootstrap staged-config --apply --force` to restore the kit version.', + saw: `bytes differing from scripts/socket-release/templates/workflows/${f}`, + wanted: 'the kit-managed workflow, byte-identical to its template', + what: 'Refusing to overwrite a hand-edited workflow.', + where: `.github/workflows/${f}`, + }, + 1, + ) + } + } + } + for (let i = 0, { length } = files; i < length; i += 1) { + const f = files[i]! + const template = inputs.templates[f] + const target = inputs.targets[f] + if (template === undefined) { + throw new KitError( + { + fix: 'run the installer again (node release-kit/install.mts --target . --channels --apply) to restore the local templates.', + saw: 'no such file', + wanted: 'the channel workflow template installed with the kit', + what: `Local template scripts/socket-release/templates/workflows/${f} is missing.`, + where: path.join( + ctx.repoRoot, + 'scripts/socket-release/templates/workflows', + f, + ), + }, + 1, + ) + } + if (target === template) { + continue + } + if (target !== undefined && !ctx.force) { + throw new KitError( + { + fix: 'reconcile your edits into the template question first, or re-run `bootstrap staged-config --apply --force` to restore the kit version.', + saw: `bytes differing from scripts/socket-release/templates/workflows/${f}`, + wanted: 'the kit-managed workflow, byte-identical to its template', + what: 'Refusing to overwrite a hand-edited workflow.', + where: `.github/workflows/${f}`, + }, + 1, + ) + } + seams.writeFile(path.join(ctx.repoRoot, '.github/workflows', f), template) + effects.push({ + applied: true, + description: `write .github/workflows/${f} from scripts/socket-release/templates/workflows/${f}${target !== undefined ? ' (force restore)' : ''}`, + kind: 'file-write', + }) + } + const access = ctx.access ?? 'restricted' + if ( + inputs.packageJsonRaw !== undefined && + !packageJsonConforms(inputs.packageJsonRaw, access) + ) { + const edit = editPackageJsonRaw(inputs.packageJsonRaw, access) + if (edit.changed) { + seams.writeFile(path.join(ctx.repoRoot, 'package.json'), edit.next) + effects.push({ + applied: true, + description: + 'surgical package.json edit: add the four kit scripts + publishConfig.access', + kind: 'file-write', + }) + } + } + const gitignoreOk = detection.checks.find(c => c.id === 'gitignore-block')?.ok + if (!gitignoreOk) { + const current = inputs.gitignore ?? '' + const next = + current === '' || current.endsWith('\n') + ? `${current}${GITIGNORE_BLOCK}` + : `${current}\n${GITIGNORE_BLOCK}` + seams.writeFile(path.join(ctx.repoRoot, '.gitignore'), next) + effects.push({ + applied: true, + description: 'append the kit block to .gitignore', + kind: 'file-write', + }) + } + return { effects } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/trusted-publisher.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/trusted-publisher.mts new file mode 100644 index 00000000..be25268e --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/trusted-publisher.mts @@ -0,0 +1,356 @@ +/** + * @file Step 6 — trusted-publisher: drive the registry-side trusted + * publisher config to the law (github · npm-publish.yml · env npm-publish + * · createPackage + createStagedPackage) via `npm trust` through the PTY + * router — the browser here is the OPERATOR'S browser via npm's web-2FA; + * the CDP/Playwright WRITE lane is dead (2026-07-31, 132/132) and MUST NOT + * be attempted. Derive-don't-assume: the local `.github/workflows` must + * actually carry npm-publish.yml before trust is configured for it (the + * cargo-twin lesson). Reads FAIL CLOSED: any `npm trust list` error + * envelope is auth-death, never "(no config)". + */ + +import path from 'node:path' +import process from 'node:process' + +import { npmAuthGate, webAuthApproveGate } from '../../_shared/human-gate.mts' +import { + PACE_MS, + conformsToLaw, + trustedPublisherLaw, +} from '../../publish-infra/npm/trust-sweep.mts' +import type { + TrustConfig, + TrustedPublisherLaw, +} from '../../publish-infra/npm/trust-sweep.mts' +import { npmScratchCwd } from '../../publish-infra/npm/shared.mts' +import type { + Check, + Effect, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { BootstrapSeams, ExecResult } from '../seams.mts' + +export const id = 'trusted-publisher' as const + +export interface TrustedPublisherInputs { + trustList: ExecResult + workflows: string[] +} + +export type TrustListClassification = + | { kind: 'absent' } + | { kind: 'auth-died'; saw: string } + | { kind: 'conforms' } + | { config: TrustConfig; kind: 'stale'; staleFields: string[] } + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + return { + trustList: await seams.exec( + 'npm', + ['trust', 'list', ctx.packageName, '--json'], + npmScratchCwd(), + ), + workflows: seams.listDir(path.join(ctx.repoRoot, '.github/workflows')), + } +} + +/** + * Classify an `npm trust list --json` read against the law. FAIL CLOSED: + * a non-zero exit, an error envelope, or an unrecognized JSON shape is + * auth-death — never "(no config)" (the unauthenticated-reads-as-empty trap + * produced a "132 unconfigured" audit against a fully configured registry, + * twice). A genuinely unconfigured package is the clean-exit-without-config + * shape ONLY. Pure — exported for tests. + */ +export function classifyTrustList( + result: ExecResult, + law: TrustedPublisherLaw, +): TrustListClassification { + if (result.code !== 0) { + return { + kind: 'auth-died', + saw: + result.stderr.trim().split('\n')[0] || + result.stdout.trim().split('\n')[0] || + `exit ${result.code}`, + } + } + const jsonStart = result.stdout.indexOf('{') + if (jsonStart === -1) { + // Clean exit with no JSON at all: the genuinely-unconfigured shape. + return { kind: 'absent' } + } + let parsed: (TrustConfig & { error?: unknown }) | undefined + try { + parsed = JSON.parse(result.stdout.slice(jsonStart)) as TrustConfig & { + error?: unknown + } + } catch { + return { kind: 'auth-died', saw: 'unparseable trust list JSON' } + } + if (parsed.error) { + return { + kind: 'auth-died', + saw: JSON.stringify(parsed.error).slice(0, 120), + } + } + const recognizable = + parsed.type !== undefined || + parsed.file !== undefined || + parsed.repository !== undefined || + parsed.environment !== undefined + if (!recognizable) { + // An unknown JSON shape must REFUSE, never read as unconfigured. + return { kind: 'auth-died', saw: 'unrecognized trust list shape' } + } + if (conformsToLaw(parsed, law)) { + return { kind: 'conforms' } + } + const staleFields: string[] = [] + if (parsed.type !== law.type) { + staleFields.push('type') + } + if (parsed.file !== law.file) { + staleFields.push('file') + } + if (parsed.repository !== law.repository) { + staleFields.push('repository') + } + if (parsed.environment !== law.environment) { + staleFields.push('environment') + } + const perms = [...(parsed.permissions ?? [])].toSorted() + const wanted = [...law.permissions].toSorted() + if ( + perms.length !== wanted.length || + !perms.every((p, i) => p === wanted[i]) + ) { + staleFields.push('permissions') + } + return { config: parsed, kind: 'stale', staleFields } +} + +export function classifyTrustedPublisher( + inputs: TrustedPublisherInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + if (!inputs.workflows.includes('npm-publish.yml')) { + checks.push({ + fix: 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + id: 'workflow-exists-locally', + ok: false, + saw: 'no .github/workflows/npm-publish.yml', + wanted: + 'the workflow trust binds to must exist before trust is configured for it', + }) + return { + checks, + detail: + 'refusing to configure trust for a workflow that does not exist — run staged-config first.', + done: false, + failed: true, + state: 'workflow-missing', + } + } + checks.push({ + fix: null, + id: 'workflow-exists-locally', + ok: true, + saw: 'npm-publish.yml present', + wanted: 'the trust-bound workflow exists locally', + }) + const law = trustedPublisherLaw(ctx.slug) + const classification = classifyTrustList(inputs.trustList, law) + if (classification.kind === 'auth-died') { + checks.push({ + fix: 'log in (node scripts/socket-release/npm-web-auth.mts login) before --apply', + id: 'trust-list-unknown', + ok: false, + saw: 'auth-unavailable', + wanted: 'a parseable trust config list', + }) + return { + authUnknown: true, + checks, + detail: `Trusted-publisher read could not be trusted: npm trust returned an error envelope (${classification.saw}).`, + done: false, + state: 'auth-died', + } + } + if (classification.kind === 'conforms') { + checks.push({ + fix: null, + id: 'trusted-publisher-conforms', + ok: true, + saw: 'type github, workflow npm-publish.yml, environment npm-publish, both permissions', + wanted: `the law bound to ${ctx.slug}`, + }) + return { + checks, + detail: `trusted publisher for ${ctx.packageName} conforms to the law.`, + done: true, + state: 'conforms', + } + } + if (classification.kind === 'stale') { + checks.push({ + fix: 'run: node scripts/socket-release/bootstrap.mts trusted-publisher --apply (revoke-then-create)', + id: 'trusted-publisher-stale', + ok: false, + saw: `stale fields: ${classification.staleFields.join(', ')}`, + wanted: `type github, repository ${ctx.slug}, workflow npm-publish.yml, environment npm-publish, permissions createPackage + createStagedPackage`, + }) + return { + checks, + detail: `trusted publisher for ${ctx.packageName} is stale (${classification.staleFields.join(', ')}).`, + done: false, + state: 'stale', + } + } + checks.push({ + fix: 'run: node scripts/socket-release/bootstrap.mts trusted-publisher --apply', + id: 'trusted-publisher-absent', + ok: false, + saw: '(no config)', + wanted: `the law bound to ${ctx.slug}`, + }) + return { + checks, + detail: `no trusted publisher configured for ${ctx.packageName}.`, + done: false, + state: 'absent', + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyTrustedPublisher(inputs as TrustedPublisherInputs, ctx) +} + +/** + * The exact npm-web-auth argv(s) that take the current state to the law: + * `absent` → create; `stale` → revoke (by id, read at apply time) then + * create. Pure — exported for tests. + */ +export function trustCommandArgv(pkg: string, slug: string): string[] { + return [ + 'scripts/socket-release/npm-web-auth.mts', + 'trust', + 'github', + pkg, + '--file', + 'npm-publish.yml', + '--repo', + slug, + '--env', + 'npm-publish', + '--allow-publish', + '--allow-stage-publish', + '--yes', + ] +} + +export function trustRevokeArgv(pkg: string, configId: string): string[] { + return [ + 'scripts/socket-release/npm-web-auth.mts', + 'trust', + 'revoke', + pkg, + `--id=${configId}`, + ] +} + +export function plan(detection: StepDetection, ctx: StepContext): StepPlan { + if (detection.done || detection.failed || detection.authUnknown) { + return { effects: [] } + } + const effects: Effect[] = [] + if (detection.state === 'stale') { + effects.push({ + applied: false, + description: `node ${trustRevokeArgv(ctx.packageName, '').join(' ')}`, + kind: 'npm-trust', + }) + } + effects.push({ + applied: false, + description: `node ${trustCommandArgv(ctx.packageName, ctx.slug).join(' ')}`, + kind: 'npm-trust', + }) + return { effects } +} + +export async function apply( + stepPlan: StepPlan, + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + if (stepPlan.effects.length === 0) { + return { effects: [] } + } + // Re-read at apply time for the live config id (revoke targets it). + const inputs = await read(ctx, seams) + const law = trustedPublisherLaw(ctx.slug) + const classification = classifyTrustList(inputs.trustList, law) + if (classification.kind === 'auth-died') { + return { + effects: [], + gate: npmAuthGate( + ctx.repoRoot, + 'the bootstrap resumes at trusted-publisher.', + ), + } + } + const effects: Effect[] = [] + if (classification.kind === 'conforms') { + return { effects } + } + if (classification.kind === 'stale') { + const configId = classification.config.id + if (configId) { + const revokeArgv = trustRevokeArgv(ctx.packageName, configId) + const code = await seams.execPty( + process.execPath, + revokeArgv, + ctx.repoRoot, + ) + if (code !== 0) { + throw new Error( + `npm trust revoke for ${ctx.packageName} exited ${code} — the stale config still stands.`, + ) + } + effects.push({ + applied: true, + description: `node ${revokeArgv.join(' ')}`, + kind: 'npm-trust', + }) + await new Promise(resolve => { + setTimeout(resolve, PACE_MS) + }) + } + } + const createArgv = trustCommandArgv(ctx.packageName, ctx.slug) + const code = await seams.execPty(process.execPath, createArgv, ctx.repoRoot) + effects.push({ + applied: code === 0, + description: `node ${createArgv.join(' ')}`, + kind: 'npm-trust', + }) + if (code !== 0) { + return { + effects, + gate: webAuthApproveGate( + `the trusted-publisher create for ${ctx.packageName}`, + 'the bootstrap re-reads the trust config and resumes at trusted-publisher.', + ), + } + } + return { effects } +} diff --git a/release-kit/payload/scripts/socket-release/bootstrap/steps/verify.mts b/release-kit/payload/scripts/socket-release/bootstrap/steps/verify.mts new file mode 100644 index 00000000..241a3b4b --- /dev/null +++ b/release-kit/payload/scripts/socket-release/bootstrap/steps/verify.mts @@ -0,0 +1,331 @@ +/** + * @file Step 8 — verify: the read-only end-to-end proof, and the designated + * LIVE CONTRACT TEST for npm wire drift — it drives the REAL packument, + * `npm trust list`, and `gh api` reads through the REAL parsers, so a + * registry-side contract change surfaces as a loud refusal at operator + * run time, never a silently green suite. Never dispatches a workflow, + * never stages — "no real staged publish" is structural. Terminal-state + * assertion (owner directive): package live, trusted publisher conforming, + * environments restricted, workflows on origin, staged-config parity, and + * publishing access STAGED-ONLY — a package left permissive is a FAIL with + * the exact remediation command. + */ + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' +import { placeholderPromoteGate } from '../../_shared/human-gate.mts' +import { trustedPublisherLaw } from '../../publish-infra/npm/trust-sweep.mts' +import { npmScratchCwd } from '../../publish-infra/npm/shared.mts' +import type { PublishingAccessRead } from '../../publish-infra/npm/access-parse.mts' +import { classifyPackument } from './preflight.mts' +import { findStagedPlaceholder } from './placeholder.mts' +import { classifyEnvProbe, desiredEnvironments } from './github-env.mts' +import { classifyTrustList } from './trusted-publisher.mts' +import { + classifyStagedConfig, + read as readStagedConfig, + workflowsForChannels, +} from './staged-config.mts' +import type { StagedConfigInputs } from './staged-config.mts' +import type { + Check, + StepApplyResult, + StepContext, + StepDetection, + StepPlan, +} from '../plan.mts' +import type { + BootstrapSeams, + ExecResult, + RegistryJsonResult, +} from '../seams.mts' + +export const id = 'verify' as const + +export interface VerifyInputs { + access: PublishingAccessRead | undefined + envList: ExecResult + packument: RegistryJsonResult + pnpmStageHelp: ExecResult + policies: Record + stagedConfig: StagedConfigInputs + stageList: ExecResult | undefined + trustList: ExecResult + workflowsOnOrigin: Record +} + +export async function read( + ctx: StepContext, + seams: BootstrapSeams, +): Promise { + const packument = await seams.registryJson( + `${NPM_REGISTRY_URL}/${encodeURIComponent(ctx.packageName).replace('%40', '@')}`, + ) + const live = classifyPackument(packument) === 'live' + const stageList = live + ? undefined + : await seams.exec('pnpm', ['stage', 'list', '--json'], ctx.repoRoot) + const trustList = await seams.exec( + 'npm', + ['trust', 'list', ctx.packageName, '--json'], + npmScratchCwd(), + ) + const envList = await seams.exec( + 'gh', + ['api', `repos/${ctx.slug}/environments`], + ctx.repoRoot, + ) + const policies: Record = {} + const envs = desiredEnvironments(ctx.channels) + for (let i = 0, { length } = envs; i < length; i += 1) { + const env = envs[i]! + // eslint-disable-next-line no-await-in-loop -- serial gh api pacing. + policies[env] = await seams.exec( + 'gh', + [ + 'api', + `repos/${ctx.slug}/environments/${env}/deployment-branch-policies`, + ], + ctx.repoRoot, + ) + } + const workflowsOnOrigin: Record = {} + const files = workflowsForChannels(ctx.channels) + for (let i = 0, { length } = files; i < length; i += 1) { + const f = files[i]! + // eslint-disable-next-line no-await-in-loop -- serial gh api pacing. + workflowsOnOrigin[f] = await seams.exec( + 'gh', + [ + 'api', + `repos/${ctx.slug}/contents/.github/workflows/${f}?ref=${ctx.branch ?? ctx.defaultBranch}`, + ], + ctx.repoRoot, + ) + } + const access = + ctx.apply && live + ? await seams.readPublishingAccess(ctx.packageName) + : undefined + return { + access, + envList, + packument, + pnpmStageHelp: await seams.exec('pnpm', ['help', 'stage'], ctx.repoRoot), + policies, + stagedConfig: await readStagedConfig(ctx, seams), + stageList, + trustList, + workflowsOnOrigin, + } +} + +/** + * Aggregate verification over every read. Pure — exported for tests. + */ +export function classifyVerify( + inputs: VerifyInputs, + ctx: StepContext, +): StepDetection { + const checks: Check[] = [] + let authUnknown = false + const registryState = classifyPackument(inputs.packument) + if (registryState !== 'live') { + const staged = findStagedPlaceholder(inputs.stageList, ctx.packageName) + if (registryState === 'unpublished' && staged.entry) { + const stageId = staged.entry.stageId ?? '(unknown stage id)' + checks.push({ + fix: 'node scripts/socket-release/npm-publish.mts --approve', + id: 'registry-name-live', + ok: false, + saw: `staged entry ${stageId} awaiting promotion`, + wanted: 'the name live on the registry', + }) + return { + checks, + detail: `${ctx.packageName} is staged and pending promotion.`, + done: false, + gate: placeholderPromoteGate( + ctx.packageName, + stageId, + 'verify re-runs once the name resolves as live.', + ), + state: 'staged-pending', + } + } + checks.push({ + fix: + registryState === 'unreachable' + ? 'check the network/proxy and re-run — an unreachable registry is never read as an unclaimed name.' + : `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve ${ctx.packageName}`, + id: 'registry-name-live', + ok: false, + saw: registryState, + wanted: 'at least one version live on the registry', + }) + } else { + checks.push({ + fix: null, + id: 'registry-name-live', + ok: true, + saw: 'live', + wanted: 'at least one version live on the registry', + }) + } + const law = trustedPublisherLaw(ctx.slug) + const trust = classifyTrustList(inputs.trustList, law) + if (trust.kind === 'auth-died') { + authUnknown = true + checks.push({ + fix: 'log in (node scripts/socket-release/npm-web-auth.mts login) before --apply', + id: 'trusted-publisher-conforms', + ok: false, + saw: 'auth-unavailable', + wanted: 'a parseable trust config list', + }) + } else { + checks.push({ + fix: + trust.kind === 'conforms' + ? null + : 'run: node scripts/socket-release/bootstrap.mts trusted-publisher --apply', + id: 'trusted-publisher-conforms', + ok: trust.kind === 'conforms', + saw: trust.kind, + wanted: `type github, repository ${ctx.slug}, workflow npm-publish.yml, environment npm-publish, permissions createPackage + createStagedPackage`, + }) + } + const branch = ctx.branch ?? ctx.defaultBranch + const envs = desiredEnvironments(ctx.channels) + const envStates = envs.map(env => + classifyEnvProbe({ + branch, + env, + envList: inputs.envList, + policy: inputs.policies[env], + }), + ) + const envOk = envStates.every(s => s === 'restricted-ok') + checks.push({ + fix: envOk + ? null + : 'run: node scripts/socket-release/bootstrap.mts github-env --apply', + id: 'environments-restricted', + ok: envOk, + saw: + envs.map((env, i) => `${env}: ${envStates[i]}`).join(', ') || + '(none desired)', + wanted: `every desired environment restricted to [${branch}]`, + }) + const files = workflowsForChannels(ctx.channels) + const onOriginOk = files.every(f => inputs.workflowsOnOrigin[f]?.code === 0) + checks.push({ + fix: onOriginOk + ? null + : 'commit and push the kit workflows — an uncommitted workflow is not stood up.', + id: 'workflows-on-origin', + ok: onOriginOk, + saw: files + .map( + f => + `${f}: ${inputs.workflowsOnOrigin[f]?.code === 0 ? 'on origin' : 'absent'}`, + ) + .join(', '), + wanted: `every channel workflow present on origin ${branch}`, + }) + const stagedConfig = classifyStagedConfig(inputs.stagedConfig, ctx) + checks.push({ + fix: stagedConfig.done + ? null + : 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + id: 'staged-config-parity', + ok: stagedConfig.done, + saw: stagedConfig.detail, + wanted: + 'local workflows byte-identical to templates; scripts + gitignore present', + }) + checks.push({ + fix: + inputs.pnpmStageHelp.code === 0 + ? null + : 'Set "packageManager": "pnpm@11.17.0" in package.json and run pnpm install — the pinned pnpm predates staged publishing (this also fixes CI: pnpm/action-setup reads packageManager).', + id: 'pnpm-stage-support', + ok: inputs.pnpmStageHelp.code === 0, + saw: `pnpm help stage exited ${inputs.pnpmStageHelp.code}`, + wanted: 'exit 0 — CI publishes with the pinned pnpm', + }) + if (ctx.visibility === 'private') { + checks.push({ + fix: null, + id: 'provenance-expectation', + ok: true, + saw: 'private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works', + wanted: 'informational', + }) + } + // Terminal access state (owner directive): staged-only, direct DISABLED. + if (inputs.access === undefined) { + checks.push({ + fix: null, + id: 'npm-access-staged-only', + ok: !ctx.apply, + saw: ctx.apply + ? 'browser read unavailable' + : 'browser read deferred (plan mode opens no browser)', + wanted: 'staged-only (direct publishing disabled)', + }) + } else { + const stagedOnly = inputs.access.state === 'staged-only' + checks.push({ + fix: stagedOnly + ? null + : 'run: node scripts/socket-release/bootstrap.mts npm-access-staged-only --apply', + id: 'npm-access-staged-only', + ok: stagedOnly, + saw: inputs.access.state, + wanted: 'staged-only (direct publishing disabled, staged enabled)', + }) + } + checks.push({ + fix: null, + id: 'state-coherent', + ok: true, + saw: 'contextKey matches the resolved repo/package', + wanted: 'receipts keyed to this context', + }) + const failing = checks.filter(c => !c.ok) + if (authUnknown && failing.every(c => c.saw === 'auth-unavailable')) { + return { + authUnknown: true, + checks, + detail: 'auth-dependent verifications could not be read.', + done: false, + state: 'auth-unknown', + } + } + return { + checks, + detail: + failing.length === 0 + ? "publishing is stood up; first real release: bump version + CHANGELOG, commit 'chore: bump version to ', push, dispatch npm-publish from the Actions UI, then run 'node scripts/socket-release/npm-publish.mts --approve' locally." + : `${failing.length} verification(s) failing: ${failing.map(c => c.id).join(', ')}`, + done: failing.length === 0, + failed: failing.length > 0 && !authUnknown, + hardFail: registryState === 'unreachable', + ...(authUnknown ? { authUnknown: true } : {}), + state: failing.length === 0 ? 'stood-up' : 'not-stood-up', + } +} + +export function classify(inputs: unknown, ctx: StepContext): StepDetection { + return classifyVerify(inputs as VerifyInputs, ctx) +} + +export function plan(): StepPlan { + // Read-only end-to-end: verify never dispatches, never stages, never + // writes — plan and apply are the same reads. + return { effects: [] } +} + +export async function apply(): Promise { + return { effects: [] } +} diff --git a/release-kit/payload/scripts/socket-release/brew-publish.mts b/release-kit/payload/scripts/socket-release/brew-publish.mts new file mode 100644 index 00000000..049cc7a0 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/brew-publish.mts @@ -0,0 +1,396 @@ +/** + * @file Bump the Homebrew tap formula for an ALREADY-CUT release. Ordered + * gates, dry-run default: (1) config + subject; (2) tag-tied — the tag + * must already be on origin, this tool never creates tags; (3) the GitHub + * release exists and is not a draft; (4) every templated asset exists on + * the release; (5) checksum authority — the sha256s come ONLY from the + * release's own checksums.txt, never re-hashed; (6) plan the formula bump; + * (7) unchanged → no-op exit 0; (8) dry-run prints the plan + the exact + * apply command; (9) `--apply` commits direct to the tap default branch + * (GitHub-signed API commit, never a PR) and the re-read must echo the + * desired formula — success is the registry's answer, never the click. + * Usage: node scripts/socket-release/brew-publish.mts --tag vX.Y.Z + * [--apply] [--json] [--tap ] [--formula ] + * [--repo ] + */ + +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +import { isMainModule } from './_shared/is-main-module.mts' +import { REPO_ROOT } from './paths.mts' +import { parseKitConfig } from './bootstrap/config.mts' +import { parseGitHubSlug } from './publish-infra/pin-readme.mts' +import { runCapture } from './publish-infra/shared.mts' +import { + FORMULA_PLATFORMS, + planFormulaBump, +} from './publish-infra/brew/formula.mts' +import type { + FormulaPlatform, + FormulaSpec, +} from './publish-infra/brew/formula.mts' +import { + assetNamesForTriplets, + formulaClassName, + formulaPath, + normalizeTap, + parseChecksumsTxt, +} from './publish-infra/brew/shared.mts' +import { + commitFormula, + readTapFormula, + resolveBrewSeams, +} from './publish-infra/brew/tap.mts' +import type { BrewSeams } from './publish-infra/brew/tap.mts' + +const logger = getDefaultLogger() + +export interface BrewPublishConfig { + apply: boolean + brewConfig: + | { + assetTemplate: string + formula: string + tap: string + triplets: string[] + } + | undefined + formula?: string | undefined + json: boolean + repoRoot: string + seams: BrewSeams + slug: string + tag: string + tap?: string | undefined +} + +export interface BrewPublishResult { + action?: string | undefined + checks: Array<{ + fix?: string | undefined + id: string + ok: boolean + saw: string + }> + exitCode: number +} + +/** + * The whole flow with injected seams — the CLI wraps this; tests call it + * with fakes and assert check ids + exit codes + zero commit calls. + */ +export async function runBrewPublish( + config: BrewPublishConfig, +): Promise { + const cfg = { __proto__: null, ...config } as BrewPublishConfig + const checks: BrewPublishResult['checks'] = [] + const refuse = ( + id: string, + lines: string[], + saw: string, + fix?: string | undefined, + ): BrewPublishResult => { + checks.push({ fix, id, ok: false, saw }) + for (let i = 0, { length } = lines; i < length; i += 1) { + logger.fail(lines[i]!) + } + return { checks, exitCode: 1 } + } + + const seams = cfg.seams + const version = cfg.tag.replace(/^v/, '') + const brew = cfg.brewConfig + if (!brew) { + return refuse( + 'config-brew-block', + [ + 'x the brew channel is not configured.', + ' Fix: add the "brew" block to .config/socket-release.json (tap, formula, assetTemplate, triplets).', + ], + 'no brew block', + 'add the brew block to .config/socket-release.json', + ) + } + const tap = normalizeTap(cfg.tap ?? brew.tap) + const productName = cfg.formula || brew.formula || cfg.slug.split('/')[1]! + const spec = { assetTemplate: brew.assetTemplate, triplets: brew.triplets } + + // 2. Tag-tied: the tag must already be on origin. + const tagRead = await seams.ghApiJson( + `repos/${cfg.slug}/git/ref/tags/${cfg.tag}`, + ) + if (tagRead.code !== 0) { + return refuse( + 'tag-on-origin', + [ + `x tag "${cfg.tag}" is not on origin ${cfg.slug}.`, + 'A formula bump must tie to an already-pushed tag; this tool never creates tags.', + 'Fix: cut the release first (registry publish -> tag -> GitHub release), then re-run brew-publish.', + ], + `no ref tags/${cfg.tag}`, + 'cut the release first (registry publish -> tag -> GitHub release), then re-run brew-publish.', + ) + } + checks.push({ id: 'tag-on-origin', ok: true, saw: cfg.tag }) + + // 3. Release exists and is not a draft. + const release = await seams.ghReleaseView(cfg.tag, cfg.slug) + if (!release.exists || release.isDraft) { + return refuse( + 'release-published', + [ + `x release ${cfg.tag} on ${cfg.slug} is ${release.exists ? 'a draft' : 'missing'}.`, + 'Fix: finish the release cut; a draft release is not a release.', + ], + release.exists ? 'draft' : 'missing', + 'finish the release cut; a draft release is not a release', + ) + } + checks.push({ id: 'release-published', ok: true, saw: 'published' }) + + // 4. Every templated asset must exist on the release. + const assets = assetNamesForTriplets( + productName, + version, + spec.assetTemplate, + spec.triplets, + ) + for (let i = 0, { length } = assets; i < length; i += 1) { + const { asset } = assets[i]! + if (!release.assets.includes(asset)) { + return refuse( + 'assets-present', + [ + `x asset "${asset}" does not exist on release ${cfg.tag}.`, + 'Fix: build and upload the asset before bumping the formula, or remove the triplet from .config/socket-release.json brew.triplets.', + ], + `missing ${asset}`, + 'build and upload the asset before bumping the formula, or remove the triplet from .config/socket-release.json brew.triplets.', + ) + } + } + checks.push({ + id: 'assets-present', + ok: true, + saw: `${assets.length} assets`, + }) + + // 5. Checksum authority: the release's own checksums.txt, never re-hashed. + const checksumsText = await seams.downloadChecksums(cfg.tag, cfg.slug) + if (checksumsText === undefined) { + return refuse( + 'checksums-authority', + [ + `x release ${cfg.tag} carries no checksums.txt.`, + "The formula sha256 is derived from the release's own checksum manifest, never re-hashed independently.", + 'Fix: cut the release with scripts/socket-release/github-release.mts --tag --release (it writes the sha256-hex checksums.txt).', + ], + 'no checksums.txt', + 'cut the release with scripts/socket-release/github-release.mts --tag --release (it writes the sha256-hex checksums.txt).', + ) + } + const checksums = parseChecksumsTxt(checksumsText) + const platforms = {} as FormulaSpec['platforms'] + for (let i = 0, { length } = assets; i < length; i += 1) { + const { asset, triplet } = assets[i]! + const hex = checksums.get(asset) + if (hex === undefined) { + return refuse( + 'checksums-cover-assets', + [ + `x checksums.txt on release ${cfg.tag} does not name ${asset}.`, + 'Fix: regenerate the release checksums so every published asset is covered.', + ], + `no sha256 for ${asset}`, + 'regenerate the release checksums so every published asset is covered.', + ) + } + if ((FORMULA_PLATFORMS as readonly string[]).includes(triplet)) { + platforms[triplet as FormulaPlatform] = { + sha256: hex, + url: `https://github.com/${cfg.slug}/releases/download/${cfg.tag}/${asset}`, + } + } + } + checks.push({ + id: 'checksums-authority', + ok: true, + saw: 'checksums.txt parsed', + }) + + // 6. Desired spec -> current tap formula -> bump plan. + const desired: FormulaSpec = { + className: formulaClassName(productName), + desc: `${productName} (Socket release)`, + homepage: `https://github.com/${cfg.slug}`, + license: 'MIT', + name: productName, + platforms, + } + const fPath = formulaPath(productName) + const current = await readTapFormula(seams, tap.repo, fPath) + const bump = planFormulaBump(current?.raw, desired) + + // 7. Unchanged -> no-op. + if (bump.action === 'unchanged') { + logger.log( + `Formula ${productName} already reads ${version}; leaving it untouched.`, + ) + checks.push({ id: 'formula-bump', ok: true, saw: 'unchanged' }) + return { action: 'unchanged', checks, exitCode: 0 } + } + + // 8. Dry-run: print the plan + the exact apply command. + if (!cfg.apply) { + logger.log(`brew-publish plan (${bump.action}):`) + logger.log(` tap repo: ${tap.repo}`) + logger.log(` path: ${fPath}`) + logger.log(` version: ${version}`) + for (let i = 0, { length } = FORMULA_PLATFORMS; i < length; i += 1) { + const p = FORMULA_PLATFORMS[i]! + const entry = platforms[p] + if (entry) { + logger.log(` ${p}: sha256 ${entry.sha256}`) + } + } + logger.log( + ` apply: node scripts/socket-release/brew-publish.mts --tag ${cfg.tag} --apply`, + ) + checks.push({ + id: 'formula-bump', + ok: true, + saw: `[dry-run] ${bump.action}`, + }) + return { action: bump.action, checks, exitCode: 0 } + } + + // 9. Apply: GitHub-signed commit direct to the tap default branch, then a + // re-read that must echo the desired formula. + const committed = await commitFormula(seams, { + content: bump.rendered, + formulaName: productName, + path: fPath, + repo: tap.repo, + version, + }) + if (!committed.verified) { + return refuse( + 'formula-verified', + [ + `x Formula bump saved-state unproven for ${productName}.`, + ` Where: ${tap.repo}/${fPath} re-read after the commit`, + ' Saw: bytes differing from the rendered formula', + ' Wanted: the committed formula byte-identical to the plan', + ` Fix: re-run \`node scripts/socket-release/brew-publish.mts --tag ${cfg.tag} --apply\` — success is the registry's answer, never the click.`, + ], + 're-read mismatch', + `re-run node scripts/socket-release/brew-publish.mts --tag ${cfg.tag} --apply`, + ) + } + logger.log(`Formula ${productName} bumped to ${version} on ${tap.repo}.`) + checks.push({ id: 'formula-verified', ok: true, saw: version }) + return { action: bump.action, checks, exitCode: 0 } +} + +async function main(): Promise { + let values: { + apply?: boolean + formula?: string + help?: boolean + json?: boolean + repo?: string + tag?: string + tap?: string + } + try { + ;({ values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + apply: { type: 'boolean' }, + formula: { type: 'string' }, + help: { type: 'boolean' }, + json: { type: 'boolean' }, + repo: { type: 'string' }, + tag: { type: 'string' }, + tap: { type: 'string' }, + }, + strict: true, + })) + } catch (e) { + logger.fail(errorMessage(e)) + logger.error( + 'Usage: node scripts/socket-release/brew-publish.mts --tag vX.Y.Z [--apply] [--json] [--tap ] [--formula ] [--repo ]', + ) + process.exitCode = 2 + return + } + if (values.help) { + logger.log( + 'Usage: node scripts/socket-release/brew-publish.mts --tag vX.Y.Z [--apply] [--json] [--tap ] [--formula ] [--repo ]', + ) + return + } + if (!values.tag || !/^v\d/.test(values.tag)) { + logger.fail('brew-publish: --tag vX.Y.Z is required.') + process.exitCode = 2 + return + } + const fs = await import('node:fs') + const configPath = path.join(REPO_ROOT, '.config/socket-release.json') + let brewConfig: BrewPublishConfig['brewConfig'] + try { + const kitConfig = parseKitConfig( + fs.readFileSync(configPath, 'utf8'), + configPath, + ) + brewConfig = kitConfig.brew + } catch (e) { + logger.fail(errorMessage(e)) + process.exitCode = 2 + return + } + let slug = values.repo + if (!slug) { + const origin = await runCapture( + 'git', + ['remote', 'get-url', 'origin'], + REPO_ROOT, + ) + slug = origin.code === 0 ? parseGitHubSlug(origin.stdout.trim()) : undefined + } + if (!slug) { + logger.fail( + 'brew-publish: could not resolve the product repo — pass --repo .', + ) + process.exitCode = 2 + return + } + const result = await runBrewPublish({ + apply: values.apply === true, + brewConfig, + formula: values.formula, + json: values.json === true, + repoRoot: REPO_ROOT, + seams: resolveBrewSeams(REPO_ROOT), + slug, + tag: values.tag, + tap: values.tap, + }) + if (values.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + } + process.exitCode = result.exitCode +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.fail(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/cargo-publish.mts b/release-kit/payload/scripts/socket-release/cargo-publish.mts new file mode 100644 index 00000000..78dc6731 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/cargo-publish.mts @@ -0,0 +1,184 @@ +/* + * @file Fleet-canonical cargo (crates.io) publish runner — the Rust analog of + * npm-publish.mts. Three modes: + * + * --staged Verify + package the crate WITHOUT uploading. crates.io has no + * staging endpoint, so "staged" means: run `cargo publish --dry-run --locked` + * (packages AND compiles from the packaged sources — the real verification), + * produce the `.crate`, and record its sha256 as the digest a downstream + * `--approve` integrity-gates against. THIS IS THE DEFAULT path. Nothing is + * public. In CI the workflow handles provenance/attestation. + * --approve Local, human-gated PERMANENT promote: re-pack + sha256-verify + * against the staged digest, confirm, then `cargo publish --locked`, then + * create the git tag + GitHub release (the `.crate` + checksums as assets). + * --direct Classic single-step `cargo publish --locked` — build + upload + + * public in one call, no stage/approve. Then tag + release. + * --dry-run Forwarded to the underlying cargo command / bump preview. + * + * crates.io publishing is PERMANENT: a version can only be yanked, never + * re-published or overwritten. The stage/approve split keeps a human gate in + * front of that permanence. + * + * This file is the thin entry: arg parsing + mode dispatch. The implementation + * lives under `publish-infra/`, organized in registry tiers alongside npm: the + * agnostic core (`publish-infra/shared.mts` — spawn/git/JSON helpers, + * `publish-infra/release.mts` — git tag + GitHub release) and the cargo tier + * (`publish-infra/cargo/` — metadata resolution, crates.io reads, + * staged/direct modes, and the approve flow). + */ + +import process from 'node:process' + +import { parseArgs } from '@socketsecurity/lib/argv/parse' + +import { + resolveStagedSha256, + runApprove, +} from './publish-infra/cargo/approve.mts' +import { + crateNameStatus, + fetchPublishedVersion, + isAlreadyPublished, +} from './publish-infra/cargo/registry.mts' +import { + cratePath, + crateSha256, + readCargoPackage, +} from './publish-infra/cargo/shared.mts' +import { + packCrate, + packCrateAssets, + runDirect, + runStaged, +} from './publish-infra/cargo/staged.mts' +import { + ensureTagAndRelease, + extractChangelogSection, +} from './publish-infra/release.mts' +import { logger } from './publish-infra/shared.mts' +import { unknownFlags, unknownFlagsMessage } from './_shared/cli-flags.mts' +import { isMainModule } from './_shared/is-main-module.mts' + +export { + crateNameStatus, + cratePath, + crateSha256, + ensureTagAndRelease, + extractChangelogSection, + fetchPublishedVersion, + isAlreadyPublished, + packCrate, + packCrateAssets, + readCargoPackage, + resolveStagedSha256, +} + +const OPTIONS = { + approve: { default: false, type: 'boolean' }, + direct: { default: false, type: 'boolean' }, + 'dry-run': { default: false, type: 'boolean' }, + help: { default: false, type: 'boolean' }, + // Accepted for signature parity with npm-publish.mts; a no-op on crates.io + // (no OTP on publish). Threaded to runApprove so the parity is honest. + otp: { type: 'string' }, + package: { type: 'string' }, + staged: { default: false, type: 'boolean' }, + yes: { default: false, type: 'boolean' }, +} as const + +async function main(): Promise { + const { values } = parseArgs({ + options: OPTIONS, + allowPositionals: false, + strict: false, + }) + + const unknown = unknownFlags(values, Object.keys(OPTIONS)) + if (unknown.length > 0) { + logger.fail(unknownFlagsMessage(unknown)) + logger.error( + 'Usage: node scripts/socket-release/cargo-publish.mts [--staged | --approve | --direct] [--dry-run] [--package ] [--yes]', + ) + process.exitCode = 2 + return + } + + if (values['help']) { + logger.log( + 'Usage: cargo-publish [--staged | --approve | --direct] [--dry-run] [--package ] [--yes]', + ) + logger.log(' (no mode → --staged, the default publish path)') + logger.log('') + logger.log( + ' --staged verify + package the crate (cargo publish', + ) + logger.log( + ' --dry-run) and record its sha256; nothing is', + ) + logger.log(' uploaded (recommended default)') + logger.log( + ' --approve local: sha256-verify + confirm, then publish', + ) + logger.log(' (PERMANENT), then tag + GitHub release') + logger.log( + ' --direct classic `cargo publish` — public in one step,', + ) + logger.log(' no stage/approve, then tag + release') + logger.log(' --dry-run simulate; no registry writes') + logger.log( + ' --package select one crate in a multi-crate workspace', + ) + logger.log(' --yes approve without the confirmation prompt') + logger.log( + ' --otp accepted for parity; no-op on crates.io (no OTP)', + ) + process.exitCode = 0 + return + } + + const modes = [values['staged'], values['approve'], values['direct']].filter( + Boolean, + ).length + if (modes > 1) { + logger.fail('Pass at most one of --staged / --approve / --direct.') + process.exitCode = 1 + return + } + // Default to staged — the safest path (verified + hashed artifact behind a + // human approval gate before anything permanent goes public). + const mode = values['direct'] + ? 'direct' + : values['approve'] + ? 'approve' + : 'staged' + + const dryRun = !!values['dry-run'] + const packageName = + typeof values['package'] === 'string' ? values['package'] : undefined + const otpFromFlag = + typeof values['otp'] === 'string' ? values['otp'] : undefined + + // The kit defers CI auto-bump (deferral 1): the operator bumps the version + // with the load-bearing `chore: bump version to ` subject, pushes, + // and only then runs a publish mode. No release branch exists to promote or + // discard, so the publish call is the whole body. + if (mode === 'staged') { + await runStaged({ dryRun, packageName }) + } else if (mode === 'direct') { + await runDirect({ dryRun, packageName }) + } else { + await runApprove({ + dryRun, + otpFromFlag, + packageName, + yes: !!values['yes'], + }) + } +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/constants/npm-registry.mts b/release-kit/payload/scripts/socket-release/constants/npm-registry.mts new file mode 100644 index 00000000..6fbbed1a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/constants/npm-registry.mts @@ -0,0 +1,25 @@ +/** + * @file The single canonical npm registry the fleet talks to, plus the two + * derivations every registry caller needs: the packument URL for a package + * and the `.npmrc` auth-token key. The fleet publishes provenance-signed + * tarballs to public npm, so this is npmjs.org — not a Socket-owned + * registry. Change it in ONE place and everything follows. + */ + +export const NPM_REGISTRY_URL = 'https://registry.npmjs.org' + +export const NPM_REGISTRY_HOST = new URL(NPM_REGISTRY_URL).host + +/** + * The packument URL for a package name. encodeURIComponent escapes a scope's + * leading `@` to `%40`, which the registry path rejects, so it is un-escaped + * back — the one subtle rule every registry read shares. + */ +export function packumentUrl(name: string): string { + return `${NPM_REGISTRY_URL}/${encodeURIComponent(name).replace('%40', '@')}` +} + +/** + * The registry-scoped auth-token key npm/pnpm read from and write to `.npmrc`. + */ +export const NPM_AUTH_TOKEN_KEY = `//${NPM_REGISTRY_HOST}/:_authToken` diff --git a/release-kit/payload/scripts/socket-release/create-release.mts b/release-kit/payload/scripts/socket-release/create-release.mts new file mode 100644 index 00000000..cb9d8a35 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/create-release.mts @@ -0,0 +1,367 @@ +/* + * @file Fleet-canonical GitHub Release creator. Companion to + * `scripts/socket-release/npm-publish.mts` — that one handles the npm-registry side + * (`pnpm stage publish` + provenance). This one handles the GitHub-Release + * side: hash artifacts, write a signed checksums manifest, optionally pin + * them into a source-tree `release-assets.json` for downstream consumers, + * then cut the release with the three-step immutable sequence + * (create --draft --verify-tag → upload → edit --draft=false). Trust model: GitHub Releases + * don't get npm-style provenance. Instead the trust comes from two anchors + * that BOTH go into the release: + * + * 1. `checksums.txt` — SHA-256 of every asset, written by + * producer.mts:writeChecksumsFile (deterministic ordering for stable + * diffs). + * 2. (Optional) `release-assets.json` in the source tree — pins the tag + + * per-asset checksum so downstream consumer repos (the ones using + * `release-checksums/consumer.mts`) can verify what they download against + * a checked-in expected value. The pin IS the cross-repo trust contract. + * Per-repo config — drop a `release-assets.config.mts` at the repo root + * that exports `config` of type `ReleaseAssetsConfig`, see below. The + * orchestrator imports it via dynamic import; the config file is per-repo + * not cascaded, the orchestrator is fleet-canonical. + * + * CLI (`node scripts/socket-release/create-release.mts`): default cuts a release; + * `--dry-run` hashes + simulates the `gh release`; `--no-pin` skips the + * source-tree pin update; `--tag ` overrides the computed tag. Produces: + * `/checksums.txt` (SHA-256 manifest), `` (pin + * updated), and the GitHub Release `` with uploaded assets. + */ + +import { existsSync, statSync } from 'node:fs' +import { glob } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import url from 'node:url' + +import { parseArgs } from '@socketsecurity/lib/argv/parse' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +// The release-checksums producer (writeChecksumsFile + updateReleaseAssets) +// lives at a repo-shape-specific location — `scripts/socket-release/build-infra/lib/ +// release-checksums/producer.mts` in a monorepo, elsewhere for a single- +// package producer. A static import here would hard-code the monorepo +// layout and break in any repo without `scripts/socket-release/build-infra/`. Instead each +// producing repo provides a thin `scripts/repo/release-producer.mts` +// re-export, dynamically loaded by loadProducer() below — mirroring the +// per-repo release-assets.config.mts pattern. Non-producing repos simply +// don't ship create-release.mts. +import { REPO_ROOT } from './paths.mts' +import { gitShortSha, runInherit } from './publish-infra/shared.mts' +import { unknownFlags, unknownFlagsMessage } from './_shared/cli-flags.mts' +import { isMainModule } from './_shared/is-main-module.mts' + +const logger = getDefaultLogger() +const rootPath = REPO_ROOT + +/** + * Per-repo release config. Drop one at `/release-assets.config.mts` + * that `export const config: ReleaseAssetsConfig = { … }`. + */ +export interface ReleaseAssetsConfig { + /** + * Build directory containing assets to publish. Hashed in full; the canonical + * orchestrator reads every file matching `assetPatterns`. + */ + buildDir: string + /** + * Glob patterns relative to `buildDir`. Matched files are uploaded to the + * GitHub Release; `checksums.txt` is written next to them and is always + * included regardless of patterns. + */ + assetPatterns: readonly string[] + /** + * Tag for this release. Called once per orchestrator run; receives a date + * string + git short SHA the orchestrator already computed in case the + * producer wants to reuse them. Returning a string commits to that tag for + * the rest of the run. + */ + tag: (ctx: { date: string; shortSha: string }) => string | Promise + /** + * Optional release notes file (markdown). Passed to `gh release create + * --notes-file`. Omit to let the release have no body. + */ + notesFile?: string | undefined + /** + * Optional source-tree pin. When set, the orchestrator updates the named + * `tool` block of `/` with the new tag + + * per-asset checksums, so downstream `release-checksums/ consumer.mts` + * callers can verify their downloads against a checked-in expected value. + */ + pinManifest?: + | { + path: string + tool: string + description?: string | undefined + } + | undefined +} + +interface CliArgs { + dryRun: boolean + noPin: boolean + tagOverride: string | undefined +} + +async function main(): Promise { + const args = parseCli() + const config = await loadConfig() + const { updateReleaseAssets, writeChecksumsFile } = await loadProducer() + + const buildDirAbs = path.resolve(rootPath, config.buildDir) + if (!existsSync(buildDirAbs)) { + logger.fail( + `buildDir does not exist: ${config.buildDir} (resolved to ${buildDirAbs}). Build artifacts first.`, + ) + process.exitCode = 1 + return + } + + // Resolve the tag. Either --tag override wins, or config.tag() + // computes one given the date + short SHA. + const date = new Date().toISOString().slice(0, 10) + const shortSha = await gitShortSha(rootPath) + const tag = + args.tagOverride ?? (await Promise.resolve(config.tag({ date, shortSha }))) + if (!tag) { + logger.fail('Config did not produce a tag (config.tag() returned empty).') + process.exitCode = 1 + return + } + + logger.log(`Release tag: ${tag}`) + logger.log(`Build dir: ${path.relative(rootPath, buildDirAbs)}`) + + // Phase 1: Hash and write checksums.txt. + const checksumsPath = path.join(buildDirAbs, 'checksums.txt') + logger.log('Hashing assets…') + const checksums = await writeChecksumsFile({ + inputDir: buildDirAbs, + outputPath: checksumsPath, + }) + const assetCount = Object.keys(checksums).length + logger.success( + `Wrote ${assetCount} entries to ${path.relative(rootPath, checksumsPath)}`, + ) + + // Phase 2: Update source-tree pin (when configured and --no-pin wasn't passed). + if (config.pinManifest && !args.noPin) { + const manifestAbs = path.resolve(rootPath, config.pinManifest.path) + if (args.dryRun) { + logger.log( + `[dry-run] would update ${path.relative(rootPath, manifestAbs)} tool=${config.pinManifest.tool} tag=${tag}`, + ) + } else { + updateReleaseAssets({ + manifestPath: manifestAbs, + tool: config.pinManifest.tool, + tag, + checksums, + description: config.pinManifest.description, + }) + logger.success(`Updated pin: ${path.relative(rootPath, manifestAbs)}`) + } + } + + // Phase 3: Collect the asset paths the gh release create call needs. + const assetPaths = await collectAssetPaths(buildDirAbs, config.assetPatterns) + // checksums.txt always uploaded so consumers can fetch it without + // pre-knowing where it lives. Add it if not already in the pattern set. + if (!assetPaths.includes(checksumsPath)) { + assetPaths.push(checksumsPath) + } + logger.log(`Uploading ${assetPaths.length} asset(s) to release ${tag}`) + + // Phase 4: the three-step immutable cut. A single + // `gh release create ` publishes the release the instant the + // FIRST asset lands, so every later upload mutates an already-public release + // and a mid-upload failure leaves a published release with a partial asset + // set that consumers may already have fetched. Draft → upload → undraft makes + // the public release atomic: nothing is visible until every asset is in + // place. `--verify-tag` refuses to invent a tag that is not on origin. + const createArgs = ['release', 'create', tag, '--draft', '--verify-tag'] + if (config.notesFile) { + const notesAbs = path.resolve(rootPath, config.notesFile) + if (!existsSync(notesAbs)) { + logger.fail(`Notes file not found: ${config.notesFile}`) + process.exitCode = 1 + return + } + createArgs.push('--notes-file', notesAbs) + } + const uploadArgs = ['release', 'upload', tag, ...assetPaths, '--clobber'] + const undraftArgs = ['release', 'edit', tag, '--draft=false'] + if (args.dryRun) { + logger.log(`[dry-run] gh ${createArgs.join(' ')}`) + logger.log(`[dry-run] gh ${uploadArgs.join(' ')}`) + logger.log(`[dry-run] gh ${undraftArgs.join(' ')}`) + logger.success('Dry-run complete.') + return + } + const steps: Array<{ args: string[]; label: string }> = [ + { args: createArgs, label: 'gh release create --draft' }, + { args: uploadArgs, label: 'gh release upload' }, + { args: undraftArgs, label: 'gh release edit --draft=false' }, + ] + for (let i = 0, { length } = steps; i < length; i += 1) { + const step = steps[i]! + // oxlint-disable-next-line no-await-in-loop -- the three steps are ordered: the draft must exist before assets upload, and every asset must land before the release goes public. + const code = await runInherit('gh', step.args, rootPath) + if (code !== 0) { + logger.fail( + `${step.label} exited ${code}.\n` + + ` Where: release ${tag} in ${rootPath}\n` + + ` Saw: a non-zero exit from step ${i + 1} of 3\n` + + ` Fix: the release is still a DRAFT — inspect it with \`gh release view ${tag}\`, then re-run this script (upload uses --clobber, so a partial upload re-runs cleanly).`, + ) + process.exitCode = code + return + } + } + logger.success(`Released ${tag}`) +} + +const OPTIONS = { + 'dry-run': { default: false, type: 'boolean' }, + 'no-pin': { default: false, type: 'boolean' }, + help: { default: false, type: 'boolean' }, + tag: { type: 'string' }, +} as const + +function parseCli(): CliArgs { + const { values } = parseArgs({ + options: OPTIONS, + allowPositionals: false, + strict: false, + }) + const unknown = unknownFlags(values, Object.keys(OPTIONS)) + if (unknown.length > 0) { + logger.fail(unknownFlagsMessage(unknown)) + logger.error( + 'Usage: node scripts/socket-release/create-release.mts [options]', + ) + process.exitCode = 2 + process.exit(2) + } + if (values['help']) { + logger.log( + 'Usage: node scripts/socket-release/create-release.mts [options]', + ) + logger.log('') + logger.log(' --dry-run hash + simulate; no release is cut') + logger.log(' --no-pin skip source-tree release-assets.json update') + logger.log(' --tag override the tag from config.tag()') + process.exit(0) + } + return { + dryRun: !!values['dry-run'], + noPin: !!values['no-pin'], + tagOverride: typeof values['tag'] === 'string' ? values['tag'] : undefined, + } +} + +/** + * Dynamic-import the per-repo config. We require the file to live at + * `/release-assets.config.mts` so each repo can keep its tag scheme + * \+ asset patterns private without forking this orchestrator. + */ +async function loadConfig(): Promise { + const configPath = path.join(rootPath, 'release-assets.config.mts') + if (!existsSync(configPath)) { + logger.fail( + `Missing release-assets.config.mts at repo root.\n` + + ` Where: ${configPath}\n` + + ` Wanted: a per-repo config exporting \`export const config: ReleaseAssetsConfig = { … }\`.\n` + + ` Fix: create it; the ReleaseAssetsConfig interface is defined in scripts/socket-release/create-release.mts.`, + ) + process.exit(1) + } + const mod = (await import(url.pathToFileURL(configPath).href)) as { + config?: ReleaseAssetsConfig | undefined + } + if (!mod.config) { + logger.fail( + `release-assets.config.mts must \`export const config: ReleaseAssetsConfig = { … }\`.`, + ) + process.exit(1) + } + return mod.config +} + +/** + * The producer functions this orchestrator needs. Structurally typed so the + * shared script doesn't statically depend on the monorepo-only + * scripts/socket-release/build-infra path — each producing repo wires the impl + * via a repo-local scripts/repo/release-producer.mts. + */ +interface ReleaseProducer { + writeChecksumsFile: (options: { + inputDir: string + outputPath: string + }) => Promise> + updateReleaseAssets: (options: { + manifestPath: string + tool: string + tag: string + checksums: Record + description?: string | undefined + }) => void +} + +/** + * Dynamic-import the repo-local producer re-export at + * `/scripts/repo/release-producer.mts`. Keeps create-release.mts + * layout-agnostic: a monorepo re-exports from + * scripts/socket-release/build-infra/lib/ release-checksums/producer.mts; a + * single-package producer points at its own impl. The file is repo-local, not + * cascaded. + */ +async function loadProducer(): Promise { + const producerPath = path.join(rootPath, 'scripts/repo/release-producer.mts') + if (!existsSync(producerPath)) { + logger.fail( + `Missing scripts/repo/release-producer.mts at repo root.\n` + + ` Path: ${producerPath}\n` + + ` Action: create a repo-local re-export of the release-checksums producer. ` + + `In a monorepo: \`export { writeChecksumsFile, updateReleaseAssets } from '../../scripts/socket-release/build-infra/lib/release-checksums/producer.mts'\`.`, + ) + process.exit(1) + } + const mod = (await import( + url.pathToFileURL(producerPath).href + )) as Partial + if (!mod.writeChecksumsFile || !mod.updateReleaseAssets) { + logger.fail( + `scripts/repo/release-producer.mts must re-export \`writeChecksumsFile\` and \`updateReleaseAssets\`.`, + ) + process.exit(1) + } + return mod as ReleaseProducer +} + +async function collectAssetPaths( + buildDir: string, + patterns: readonly string[], +): Promise { + const result: string[] = [] + for (const pattern of patterns) { + // eslint-disable-next-line no-await-in-loop + for await (const match of glob(pattern, { cwd: buildDir })) { + const abs = path.resolve(buildDir, String(match)) + // Skip directories — gh release create wants files only. + if (statSync(abs).isFile()) { + result.push(abs) + } + } + } + return result +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the script. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/github-release.mts b/release-kit/payload/scripts/socket-release/github-release.mts new file mode 100644 index 00000000..402c21c2 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/github-release.mts @@ -0,0 +1,110 @@ +/** + * @file The tag-gap healer: ensure the `v` git tag + immutable + * GitHub release exist for a version that is ALREADY live on its registry. + * ORDER RULE, enforced by `requireRegistryLive`: the immutable release is + * the FINAL marker of a release — it can only follow a live registry + * publish, never precede one. Manual invocation heals a release gap + * (public version, missing tag/release); the github-release.yml workflow's + * `ensure-release` job runs the same path with `GH_TOKEN` for the API tag + * fallback. Dry-run default: without `--release` it reports what it would + * cut and exits. + * Usage: node scripts/socket-release/github-release.mts + * [--tag vX.Y.Z] [--release] [--help] + */ + +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { errorMessage } from '@socketsecurity/lib/errors/message' + +import { isMainModule } from './_shared/is-main-module.mts' +import { resolveReleaseSubject } from './_shared/release-subject.mts' +import { REPO_ROOT } from './paths.mts' +import { isAlreadyPublished } from './publish-infra/npm/registry.mts' +import { + ensureTagAndRelease, + requireRegistryLive, +} from './publish-infra/release.mts' +import { logger } from './publish-infra/shared.mts' + +async function main(): Promise { + let values: { help?: boolean; release?: boolean; tag?: string } + try { + ;({ values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + help: { type: 'boolean' }, + release: { type: 'boolean' }, + tag: { type: 'string' }, + }, + strict: true, + })) + } catch (e) { + logger.fail(errorMessage(e)) + logger.error( + 'Usage: node scripts/socket-release/github-release.mts [--tag vX.Y.Z] [--release]', + ) + process.exitCode = 2 + return + } + if (values.help) { + logger.log( + 'Usage: node scripts/socket-release/github-release.mts [--tag vX.Y.Z] [--release]', + ) + logger.log( + 'Ensures the git tag + immutable GitHub release for an ALREADY-LIVE registry version.', + ) + return + } + + // Subject: the repo's publish subject, with --tag overriding the version. + const subject = resolveReleaseSubject(REPO_ROOT) + let version = subject.version + if (values.tag) { + const m = /^v?(\d+\.\d+\.\d+(?:[-+].*)?)$/.exec(values.tag) + if (!m) { + logger.fail( + `github-release: unparseable tag "${values.tag}" — wanted vX.Y.Z.`, + ) + process.exitCode = 2 + return + } + version = m[1]! + } + const name = subject.name + + // ORDER RULE: registry first. A version that does not resolve on the + // registry gets NO tag and NO release from this tool. + const live = await requireRegistryLive({ + isLive: () => isAlreadyPublished(name, version), + registry: 'npm', + subject: `${name}@${version}`, + }) + if (!live) { + process.exitCode = 1 + return + } + + if (!values.release) { + logger.log( + `[dry-run] ${name}@${version} is live on npm — would ensure tag v${version} ` + + 'and the immutable GitHub release. Re-run with --release to cut them.', + ) + return + } + + const ok = await ensureTagAndRelease({ name, version }) + if (!ok) { + process.exitCode = 1 + return + } + logger.log(`Release marker complete: v${version} tagged and released.`) +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.fail(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/kit-manifest.json b/release-kit/payload/scripts/socket-release/kit-manifest.json new file mode 100644 index 00000000..1f4eda0e --- /dev/null +++ b/release-kit/payload/scripts/socket-release/kit-manifest.json @@ -0,0 +1,491 @@ +{ + "files": [ + { + "channels": ["common"], + "path": "_shared/cli-flags.mts", + "sha256": "21015afa59710c3108905d823f96a7532b3f4e70d707628a45b104e0ce27dbf4" + }, + { + "channels": ["common"], + "path": "_shared/human-gate.mts", + "sha256": "98e800a36b303409b50ecefc5bbcafac9afbea52a9172ac9ab3459e949949008" + }, + { + "channels": ["common"], + "path": "_shared/is-main-module.mts", + "sha256": "f0b0e8d5fbf0e139c52a425f3c4ca7fd60500538e6020b21298b70f7d0042bda" + }, + { + "channels": ["common"], + "path": "_shared/lifecycle-scripts.mts", + "sha256": "7588329136d54326bfac9c630a6b6f3a38c2b6794ae007a3db0ad2d0892aa7c1" + }, + { + "channels": ["common"], + "path": "_shared/mirror-lock.mts", + "sha256": "22bcf60e975f1ef5c439aef340f0418881c1124699922635d66ccccea7e333b5" + }, + { + "channels": ["common"], + "path": "_shared/pack-files.mts", + "sha256": "adaca38f7b5f44b08fab4856b272f5bd54a215393b6deb9e14c930b503c82458" + }, + { + "channels": ["common"], + "path": "_shared/playwright-law.mts", + "sha256": "0778f105bd16e0413e58c41327421a282391de9d0fd2f1181b6344ebdb8ab90f" + }, + { + "channels": ["common"], + "path": "_shared/release-gap-recovery.mts", + "sha256": "2157b3fd82ba70e314ee8781f381e56ccb09f0e79d0489ee884a4a552e5acd94" + }, + { + "channels": ["common"], + "path": "_shared/release-subject.mts", + "sha256": "ef86de701bf58e2a996206340336d9347a3bbaee70ed0ef6c2ccef2a4b36423a" + }, + { + "channels": ["common"], + "path": "_shared/run-main.mts", + "sha256": "fc23aec8a709ba6143738cf656401a25de30321e0c2429e92a3cb76721d99ca9" + }, + { + "channels": ["common"], + "path": "_shared/tar-executable.mts", + "sha256": "dfe20397b072eca1df4d9ef23c6f6b53704c4af55e9b9bf9285ba7b9eea4548d" + }, + { + "channels": ["common"], + "path": "_shared/unix-path.mts", + "sha256": "a59295e274d314c304541c5690f4f951ba9bb8e68c5596b53d8f9f2f366c2db9" + }, + { + "channels": ["common"], + "path": "bootstrap.mts", + "sha256": "849d883409abe79d4e2bae8497566a42d06f77257e501934e4a733dc7e110217" + }, + { + "channels": ["common"], + "path": "bootstrap/config.mts", + "sha256": "55076143a11233152944bde40f24659cfcab6b524d4e98b92aa1fa20373295fd" + }, + { + "channels": ["common"], + "path": "bootstrap/gates.mts", + "sha256": "750f770d1c096c6bbe8a4bc288a093e58ae69522d078fbe4edd07841999ad245" + }, + { + "channels": ["common"], + "path": "bootstrap/plan.mts", + "sha256": "b2e5f693e96171e0e0bce36732c2d16babcb7287556805d9dd3209c6c26ee3b7" + }, + { + "channels": ["common"], + "path": "bootstrap/render.mts", + "sha256": "9691ac5bb249da5f0a9eb9d067d1218a834b9064f12c76e104f7e54a12f3b3fa" + }, + { + "channels": ["common"], + "path": "bootstrap/seams.mts", + "sha256": "4317d2d24284c8508dcf7f31869135bb7295c48b4ef295905fe5bb50667ee137" + }, + { + "channels": ["common"], + "path": "bootstrap/state.mts", + "sha256": "3465e4dda9240a2578c6c53e35dcbe8fa10f7f9ca7ada34bc6dd375b709f5ad0" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/github-env.mts", + "sha256": "0c1d2f9b32aae372cce64c22d80c8449610c7780445542135505ed546259ebb9" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/npm-access-permissive.mts", + "sha256": "38494f213a73517dc4903ad2882c5678ae938f500b2ebf8d70aa6f7a02ad5aea" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/npm-access-staged-only.mts", + "sha256": "2c05d791c7b52caac4cdb4ac7f0cca81fca09a7fa9b959fc6218142faa292c12" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/placeholder.mts", + "sha256": "9f532d8ccb417df772d6c8f16a640dbc903acc5184bf7f888316046a1e3b6712" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/preflight.mts", + "sha256": "853a98c698fa691cdda5e8413bb7fd19c931057660d7a43aa787e0569f6a35b6" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/staged-config.mts", + "sha256": "fe9cbfc632ea94c13ac81abaf0dd269ef8ce02afa22792423a76629e3f869c5d" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/trusted-publisher.mts", + "sha256": "f341d8efbe3190bcb4c2f83efea71da94bbc112f4e55eec3c010792caeb571d1" + }, + { + "channels": ["common"], + "path": "bootstrap/steps/verify.mts", + "sha256": "f8220c22d93f23b4e36fe100c733a0d464dfe3f3a595a2951c8144093ebeac78" + }, + { + "channels": ["brew"], + "path": "brew-publish.mts", + "sha256": "cd06e66accf914fc976752b17dea77fde1139c718fd24d950f39c98cba72e05b" + }, + { + "channels": ["crates"], + "path": "cargo-publish.mts", + "sha256": "af0874198748ba3d44985c96ed7cb36698dff11810b620dd0c9a0d341a1675b0" + }, + { + "channels": ["common"], + "path": "constants/npm-registry.mts", + "sha256": "12ee5b71a86c1fc5eca2ad6fdce2d3db5def3211e93d4854edea994128c66486" + }, + { + "channels": ["github-release"], + "path": "create-release.mts", + "sha256": "d8f8c5d457b49fee899cccd1a97ca3cf09b91be1c0eee7b9e3cb69ae13a4c903" + }, + { + "channels": ["github-release"], + "path": "github-release.mts", + "sha256": "977136498db436a0c13ba8efead5a4e738d35ff15fa9f4e87f2ce525175e487e" + }, + { + "channels": ["brew"], + "path": "lib/commit-via-github-api.mts", + "sha256": "cda30ad2d61cd648c8a8ca357880c259187f82db3780170645f5749a5da0abe8" + }, + { + "channels": ["common"], + "path": "lib/github-git-refs.mts", + "sha256": "bfa1db1c50ca08a36671d8d019324e95c111e53792f3e2f82d3eba0d378ff8ed" + }, + { + "channels": ["common"], + "path": "lib/release-anchor.mts", + "sha256": "4041dd4c53dd631962a398fe464f08af5dcba0b8defd4edd6a432da8b4852e88" + }, + { + "channels": ["github-release"], + "path": "lib/release-checksums/core.mts", + "sha256": "f8f7e3f88f4e73c4947e0ef356a000f7627eb35d751b1f8f09c14d7703ac0bd0" + }, + { + "channels": ["github-release"], + "path": "lib/release-checksums/producer.mts", + "sha256": "ffdf9e5fbf87d8a2ec817eccdff19bd4ea4b880b05eea901e75a4046a18c7755" + }, + { + "channels": ["common"], + "path": "lib/verify-release-hashes.mts", + "sha256": "1a302fb3a92898fee01b3ed6bf2748a615eb7430dc8bf1f6e06e79a958a9107b" + }, + { + "channels": ["common"], + "path": "lib/workspace-yaml.mts", + "sha256": "1791db6097945a31281e8b336c933380b0064d0b85d1420214b95fdf922522b7" + }, + { + "channels": ["npm"], + "path": "npm-publish.mts", + "sha256": "37bc5c5c2e573d9ca27a43c37a8c4315ae8ea302e0b3a3b3f8d6c8c3457e7a09" + }, + { + "channels": ["npm"], + "path": "npm-web-auth.mts", + "sha256": "4a57c4826ec8865948e4bc97c1574401dd0c87cdd67d5a6284fbdd46b5a95ec4" + }, + { + "channels": ["common"], + "path": "paths.mts", + "sha256": "009a9d0faa11030dfc90271dd8d7fa2b771dba541643aa8127d55d9600de5c1c" + }, + { + "channels": ["brew"], + "path": "publish-infra/brew/formula.mts", + "sha256": "eb6034f25b0188094d085215297c4b1ed9cf7670d424ee33116a6bba48573595" + }, + { + "channels": ["brew"], + "path": "publish-infra/brew/shared.mts", + "sha256": "3b95e71abdfc9641a1f450f739039a1c2be14555fa12ef1a405fd258c82864e8" + }, + { + "channels": ["brew"], + "path": "publish-infra/brew/tap.mts", + "sha256": "bf5985a26591ba72d2be7f4432e9ed190eb341f03958b5807599a9d034ab9a49" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/approve.mts", + "sha256": "97294d378a31c63c90fae374f8184ff0f9e331f94e1a6accd5b3622c9a8f1a38" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/placeholder.mts", + "sha256": "b6634fdc7b7fb94ca55884d061ab719a9257525d1de609471097cf29444f9faf" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/registry.mts", + "sha256": "2fb98167e4d6e32b9f6b32b39c625954427d5cc3f07b5cfcdc7e9596874a955f" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/shared.mts", + "sha256": "db7adb6c3ac97392f44818bca1747e268abb8405c92c5d532e51fc6ce2adb3e2" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/staged.mts", + "sha256": "b09e7b2a4726aa00d727ca5e0e702bfb1757ca1905e0389ab3fe573bbff52b27" + }, + { + "channels": ["crates"], + "path": "publish-infra/cargo/trusted-publisher.mts", + "sha256": "3847e21e0c1d763bf85e2859875f2884a88af0a0f470203935b0e5bb3db1410e" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/access-page.mts", + "sha256": "ef352550e5a062206737c5520e0f6482873b7a8f2c54285651738fd3374b9358" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/access-parse.mts", + "sha256": "66380b56762610f648d4c72206a1d8a03471608bba4971aeed20fa8631440068" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/access-plan.mts", + "sha256": "4e02adb274bfd9f2481fd72fd4c299c0c01dfbbaa9caec81019fb144bb692d1d" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/approve.mts", + "sha256": "2703b825b97583b0213af73d0bb1f5e42d076d7426a0f28a51c497a0c508d60e" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/auth-identity.mts", + "sha256": "ec656fe3876d6eb9fc1e1747d2a2de23b625d9dccc45de2858157021c48925b7" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/backfill.mts", + "sha256": "3121377eb042b79aaa5c20e9fb873c40212b6aa25da126992ab88d79c009741c" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/browser-session.mts", + "sha256": "2baa6cc3cb55909e6447d64107353f122c4ac516e4737e743e7bb29e98d0b26f" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/browser-sign-in.mts", + "sha256": "b5491e1c1dedee02e2150d7fa2b9bb5c8118932a9e40081ce4ea9c7c68207443" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/login.mts", + "sha256": "4a1605e06ef014c92bda1f5458083f62ccd3f141ebdd60160120874c6b465581" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/pack-manifest.mts", + "sha256": "4cba521fe2182a457b9985ce3297c39dc90db72b3d754f24231fb16a5ea283ec" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/pack-preflight.mts", + "sha256": "89349332585ae9680c021edff9b8d4f8267ab2e50d031d976244cd2485462605" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/pinned-npm.mts", + "sha256": "5ae63c9db8f435e3055ba9dbb9ba838d19281eb629422e017c1c778be179b845" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/placeholder.mts", + "sha256": "5ed832bc6b78c88380885ef5cdadb46d07ebe6d79d3718033bbbb63fc43f867b" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/provenance.mts", + "sha256": "1060211545f7101f7349b4eeb6a16c0d3b55b93e2b7b5be38a3685cb94aa68e5" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/registry.mts", + "sha256": "0eddfb756a4e6ed1d63314b96c895fe38140e91eb083347ce67cc1013e46d3c6" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/scan.mts", + "sha256": "16a391f5c5b21f7ae08f5d76f337e9e727ae69a881dd935e7fb3167b551d95e0" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/shared.mts", + "sha256": "5eddbf569593b5bda87c42dcc8e618ecc1bb98dd9259bc783613bd9bbac6cd1f" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/staged-browser-parse.mts", + "sha256": "3bddb49a910fbd78288cb35a19fc13fea28198ee814fb9f1dddaae38b94bd0d8" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/staged-browser-read.mts", + "sha256": "fe39db57a89bbea8f25e7150b5619c179122ee1039363dab391e51963202f61c" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/staged-workspace.mts", + "sha256": "1d50706d7b1be06a510fd567ecee6a4f8f1f8a71e6a47861a1cf3b19d9fcc10f" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/staged.mts", + "sha256": "28294e81178bcd08f9bfad4d96c9270a7792724a9d8f3a0308ee57912b6b01ce" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/threat-scan.mts", + "sha256": "e19a8715dbe9d1ed72e192ec4014cd42f364b0e4706ac828e5d279a591a645ec" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/trust-sweep.mts", + "sha256": "7413c12e31553828b5ebbed319deea4796646fe99db1ad8030b815025dfd7042" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/trusted-publisher-browser.mts", + "sha256": "53e36847b3a8b6568711d7c9c43d799690dc51911c97d271567d65b4d8eb412c" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/trusted-publisher-page.mts", + "sha256": "5699f6ff4bcf957776facff4dc7a4a910bebf9028ff50a5dac0cdb9f33a5a2ba" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/trusted-publisher-parse.mts", + "sha256": "ca375765a364c9f3da8ac345d4503f388775001f27783fdb38d84916db87a78a" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/trusted-publisher-plan.mts", + "sha256": "633db22478264448c13bfbe4e27cfd7a1548a789b8f56d037e32172fd8d38a4d" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/workspace-plan.mts", + "sha256": "d64939dd649b7bf17043ee77865a4f85bebf8c03eb90a016379b2cd64638e421" + }, + { + "channels": ["npm"], + "path": "publish-infra/npm/workspace.mts", + "sha256": "3a90ea986083c174eabdb00946a8428303f69c021a2ff31baa6edcf3d8a3bc50" + }, + { + "channels": ["common"], + "path": "publish-infra/pin-readme.mts", + "sha256": "4bac5f2928c3d9cc44ee012c4995a950ae7bc6faafbefc560f1bd87df73df6ee" + }, + { + "channels": ["common"], + "path": "publish-infra/reconcile.mts", + "sha256": "6f96fc78255225984f3e29ca3c45df4ea4eee2904f162004a7ef6f87ffa25bce" + }, + { + "channels": ["common"], + "path": "publish-infra/release.mts", + "sha256": "7c45ee232e2b20ab6357a229cb6841ac6bb7b2d2f701a460d5250c27fb5be1f1" + }, + { + "channels": ["common"], + "path": "publish-infra/shared.mts", + "sha256": "075168aa1abfb30e042c68f4ec4fb17196ccde3515557348d4b0abb918e7b040" + }, + { + "channels": ["npm"], + "path": "publish-infra/socket-oauth.mts", + "sha256": "62f8070f3e27180065ff9255c0b1173ecaa297f8fa0cd25bea2e2006501aa421" + }, + { + "channels": ["github-release"], + "path": "registry-liveness-gate.d.mts", + "sha256": "123aac608cb91504dcfd4400254c68f1f8d2fac858d75cdd689890ff15adaee6" + }, + { + "channels": ["github-release"], + "path": "registry-liveness-gate.mjs", + "sha256": "b050bcc0dc3a33a0b89098a1e8665dd12b16e284ebaebe61f6a8eee88e6b39d6" + }, + { + "channels": ["brew"], + "path": "templates/actions/socket-release-app-token/action.yml", + "sha256": "7c4a695a2e9e5c037b8e774b82b44cb143389d9d81afe86545a9936f294e4305" + }, + { + "channels": ["brew"], + "path": "templates/actions/socket-release-app-token/mint-app-installation-token.mjs", + "sha256": "2f95db9b0dd6aa964cf6b58cab57c805ae61db85c9ff9a506f7385ab4ea3d97e" + }, + { + "channels": ["common"], + "path": "templates/config/socket-release.json", + "sha256": "df46d685481503ce0975a34ef83ee6e6da6d7a46322ed7c1147d8ba75197b306" + }, + { + "channels": ["common"], + "path": "templates/gitignore-block.txt", + "sha256": "2b1ae4294d094204fedf45660f48fa5f3dbfedf45ad93f672cae0a87d3c9ed44" + }, + { + "channels": ["brew"], + "path": "templates/workflows/brew-publish.yml", + "sha256": "4f672bac3d41e5f02c639be985a5e66a0d157091ec0db87ad27220120190c574" + }, + { + "channels": ["crates"], + "path": "templates/workflows/cargo-publish.yml", + "sha256": "e9f54519c6429a95c5a5bf94107ca79bab6e34343d2d197a7f93d587350bcc8f" + }, + { + "channels": ["github-release"], + "path": "templates/workflows/github-release.yml", + "sha256": "5fcfb8711bc244c2b693aadd5573743b3ca78af4c06fec2115116afa0bd6b364" + }, + { + "channels": ["npm"], + "path": "templates/workflows/npm-publish.yml", + "sha256": "0d7150264dacec8cb561e5fb9641c2a126f5245d7c7daefe8a74169a810676e4" + }, + { + "channels": ["common"], + "path": "util/napi-targets.mts", + "sha256": "26bcf30c592fc941f2cad03cef2665cd7bad9fe535ec9da635cca3dafc072214" + }, + { + "channels": ["brew"], + "path": "util/pack-app-triplets.mts", + "sha256": "7db87e35f88fd9ac0c9c7f0b62758eacd7aa9a13ad9ea1c5de4a8757fa547375" + } + ], + "kitVersion": "0.1.0", + "schemaVersion": 1 +} diff --git a/release-kit/payload/scripts/socket-release/lib/commit-via-github-api.mts b/release-kit/payload/scripts/socket-release/lib/commit-via-github-api.mts new file mode 100644 index 00000000..b477e4d0 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/commit-via-github-api.mts @@ -0,0 +1,115 @@ +/** + * @file Create a SIGNED commit on a branch via the GitHub git-objects API (blob + * -> tree -> commit -> ref PATCH). Commits created through the API are + * web-flow-verified ("Verified" / signed) WITHOUT a local GPG or SSH signing + * key — the only way CI can land a commit on a branch whose protection + * requires signed commits (the fleet rule: commits on main must be signed, + * and CI has no signing key). The provenance workflow's bump stage uses this + * to commit the version bump (package.json + CHANGELOG.md); socket-registry's + * local workflow reuses it for its monorepo bump. Generalizes the inline + * "Commit lockfile if updated" step to N files and makes it unit-testable + * (httpJson on Node uses node:http, so nock intercepts it). Pure of git: the + * caller passes the parent commit + base tree SHAs (from `git rev-parse HEAD` + * / `git rev-parse HEAD^{tree}`); this only talks to the API. After it + * returns the new commit SHA the caller resets its checkout to it (`git + * fetch` + `git reset --hard`). + */ + +import { httpJson } from '@socketsecurity/lib/http-request' + +import { updateBranchRef } from './github-git-refs.mts' + +const DEFAULT_API_URL = 'https://api.github.com' + +export interface CommitFile { + // UTF-8 text contents to write at `path`. + readonly content: string + // Repo-relative path, POSIX separators (e.g. 'package.json'). + readonly path: string +} + +export interface CommitViaGithubApiConfig { + // Override the API origin (GitHub Enterprise / tests). Defaults to api.github.com. + readonly apiUrl?: string | undefined + // SHA of the tree to layer the new files onto (usually `HEAD^{tree}`). + readonly baseTreeSha: string + // Branch to advance (e.g. 'main'). + readonly branch: string + // Files to write in the commit. + readonly files: readonly CommitFile[] + // Commit message. + readonly message: string + // Parent commit SHA (usually `HEAD`). + readonly parentSha: string + // Repo in "owner/name" form. + readonly repo: string + // GitHub token (CI: github.token / GH_TOKEN). + readonly token: string +} + +/** + * Build blob -> tree -> commit and advance `branch` to the new commit. Returns + * the new (verified) commit SHA. Throws on any non-2xx API response. + */ +export async function commitViaGithubApi( + config: CommitViaGithubApiConfig, +): Promise { + const cfg = { __proto__: null, ...config } as CommitViaGithubApiConfig + const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL + const git = `${apiUrl}/repos/${cfg.repo}/git` + const headers = { + accept: 'application/vnd.github+json', + authorization: `Bearer ${cfg.token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + } + function post(resource: string, body: unknown): Promise { + return httpJson(`${git}/${resource}`, { + body: JSON.stringify(body), + headers, + method: 'POST', + timeout: 30_000, + }) + } + + // 1. One blob per file (base64 so binary-safe). + const tree: Array<{ + mode: string + path: string + sha: string + type: string + }> = [] + for (let i = 0, { length } = cfg.files; i < length; i += 1) { + const file = cfg.files[i]! + // oxlint-disable-next-line no-await-in-loop -- blobs must exist before the tree references them; the file count is tiny (a bump touches 1-2 files). + const blob = await post<{ sha: string }>('blobs', { + content: Buffer.from(file.content, 'utf8').toString('base64'), + encoding: 'base64', + }) + tree.push({ mode: '100644', path: file.path, sha: blob.sha, type: 'blob' }) + } + + // 2. Tree layered on the base tree. + const newTree = await post<{ sha: string }>('trees', { + base_tree: cfg.baseTreeSha, + tree, + }) + + // 3. Commit (API-created => verified/signed). + const commit = await post<{ sha: string }>('commits', { + message: cfg.message, + parents: [cfg.parentSha], + tree: newTree.sha, + }) + + // 4. Fast-forward the branch ref to the new commit. + await updateBranchRef({ + apiUrl, + branch: cfg.branch, + repo: cfg.repo, + sha: commit.sha, + token: cfg.token, + }) + + return commit.sha +} diff --git a/release-kit/payload/scripts/socket-release/lib/github-git-refs.mts b/release-kit/payload/scripts/socket-release/lib/github-git-refs.mts new file mode 100644 index 00000000..64426962 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/github-git-refs.mts @@ -0,0 +1,140 @@ +/** + * @file GitHub git-refs REST helpers — create, fast-forward, and delete a branch + * ref. The publish pipeline's branch-based bump uses these: bump commits land + * on a throwaway `-publish-v` branch, and only a SUCCESSFUL + * publish fast-forwards `main` to that branch tip (same SHA) then deletes it. + * A rejected publish deletes the branch, so `main` never sees the bump — no + * version creep, and no direct write to a branch-protected `main`. `httpJson` + * throws `HttpResponseError` on non-2xx and JSON-parses the body; a `DELETE` + * ref returns 204 with an empty body, so that path uses `httpText`. All three + * go over node:http, so nock intercepts them in tests. + */ + +import { + httpJson, + HttpResponseError, + httpText, +} from '@socketsecurity/lib/http-request' + +const DEFAULT_API_URL = 'https://api.github.com' + +export interface GitRefConfig { + // Override the API origin (GitHub Enterprise / tests). Defaults to api.github.com. + readonly apiUrl?: string | undefined + // Short branch name without the `refs/heads/` prefix (e.g. 'npm-publish-v1.4.3'). + readonly branch: string + // Repo in "owner/name" form. + readonly repo: string + // GitHub token with contents:write (the release App token in CI). + readonly token: string +} + +export interface CreateOrUpdateRefConfig extends GitRefConfig { + // Optional for `updateBranchRef`: allow a non-fast-forward advance. Defaults to + // false so GitHub rejects (422) anything that would rewrite history. + readonly force?: boolean | undefined + // Commit SHA the ref should point at. + readonly sha: string +} + +function refHeaders(token: string): Record { + return { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + } +} + +/** + * Create `refs/heads/` pointing at `sha`. Throws `HttpResponseError` on + * a non-2xx response — including 422 when the ref already exists (the caller + * decides whether to force-update it instead). + */ +export async function createBranchRef( + config: CreateOrUpdateRefConfig, +): Promise { + const cfg = { __proto__: null, ...config } as CreateOrUpdateRefConfig + const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL + await httpJson(`${apiUrl}/repos/${cfg.repo}/git/refs`, { + body: JSON.stringify({ ref: `refs/heads/${cfg.branch}`, sha: cfg.sha }), + headers: refHeaders(cfg.token), + method: 'POST', + timeout: 30_000, + }) +} + +/** + * Advance `refs/heads/` to `sha`. With `force` false, the default, a + * non-fast-forward advance is rejected by GitHub (422) — the fast-forward is + * what lets `main` inherit the release branch's exact commit SHA. Throws + * `HttpResponseError` on any non-2xx response. + */ +export async function updateBranchRef( + config: CreateOrUpdateRefConfig, +): Promise { + const cfg = { __proto__: null, ...config } as CreateOrUpdateRefConfig + const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL + await httpJson(`${apiUrl}/repos/${cfg.repo}/git/refs/heads/${cfg.branch}`, { + body: JSON.stringify({ force: cfg.force ?? false, sha: cfg.sha }), + headers: refHeaders(cfg.token), + method: 'PATCH', + timeout: 30_000, + }) +} + +export interface CreateTagRefConfig { + // Override the API origin (GitHub Enterprise / tests). Defaults to api.github.com. + readonly apiUrl?: string | undefined + // Repo in "owner/name" form. + readonly repo: string + // Commit SHA the tag should mark. + readonly sha: string + // Short tag name without the `refs/tags/` prefix, e.g. 'v1.4.3'. + readonly tag: string + // GitHub token with contents:write (the release App token in CI). + readonly token: string +} + +/** + * Create `refs/tags/` pointing at `sha`. The release stage's tag push + * runs in a checkout with `persist-credentials: false`, so a plain + * `git push origin ` has no credential and exits 128 — this API route + * uses the same App token the branch-based bump already holds, so the tag + * lands even when git itself cannot push. Throws `HttpResponseError` on a + * non-2xx response — including 422 when the tag already exists (the caller + * treats an existing tag as success via its own ls-remote check). + */ +export async function createTagRef(config: CreateTagRefConfig): Promise { + const cfg = { __proto__: null, ...config } as CreateTagRefConfig + const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL + await httpJson(`${apiUrl}/repos/${cfg.repo}/git/refs`, { + body: JSON.stringify({ ref: `refs/tags/${cfg.tag}`, sha: cfg.sha }), + headers: refHeaders(cfg.token), + method: 'POST', + timeout: 30_000, + }) +} + +/** + * Delete `refs/heads/`. Idempotent: a 404/422, the ref is already gone + * is swallowed so cleanup after a failed or re-run publish never itself throws. + * Any other non-2xx (e.g. 401/403 auth) propagates. + */ +export async function deleteBranchRef(config: GitRefConfig): Promise { + const cfg = { __proto__: null, ...config } as GitRefConfig + const apiUrl = cfg.apiUrl ?? DEFAULT_API_URL + try { + await httpText(`${apiUrl}/repos/${cfg.repo}/git/refs/heads/${cfg.branch}`, { + headers: refHeaders(cfg.token), + method: 'DELETE', + timeout: 30_000, + }) + } catch (e) { + const status = + e instanceof HttpResponseError ? e.response.status : undefined + if (status !== 404 && status !== 422) { + throw e + } + } +} diff --git a/release-kit/payload/scripts/socket-release/lib/release-anchor.mts b/release-kit/payload/scripts/socket-release/lib/release-anchor.mts new file mode 100644 index 00000000..0a0937f8 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/release-anchor.mts @@ -0,0 +1,17 @@ +/** + * @file Type shim for the release-anchor chain. The kit defers CI auto-bump + * (deferral 1), so the anchor RESOLVER — the tag → bump-commit → publish-date + * walk that decides which commit a release sits on — does not ship. Two + * registry readers still import its result type, so only the type lives here. + * Node strips a type-only import at runtime, but the typecheck gate needs the + * file to exist. + */ + +/** + * What a registry answered when asked for a package's latest version. + * `reachable: true, latest: undefined` is a definitive "never published"; + * `reachable: false` is "we do not know" and must never be read as unpublished. + */ +export type RegistryLatestRead = + | { latest: string | undefined; reachable: true } + | { reachable: false } diff --git a/release-kit/payload/scripts/socket-release/lib/release-checksums/core.mts b/release-kit/payload/scripts/socket-release/lib/release-checksums/core.mts new file mode 100644 index 00000000..f6223fdc --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/release-checksums/core.mts @@ -0,0 +1,259 @@ +/* + * Release-checksum core: format primitives + embedded-checksum loader + verify. + * + * This file is the **shared core** used by every fleet repo that publishes + * artifacts whose integrity is gated by a checksum. It contains no network + * code and no producer code — see `consumer.mts` for the network fetch path, + * and `producer.mts` for the writer side. + * + * Two checksum formats meet here, deliberately kept apart: + * + * - `release-assets.json` pins (`ToolConfig.checksums`) are SRI integrity strings + * (`sha256-`, forward-compatible with sha384/sha512) — the same shape + * the fleet verifies with elsewhere (`@socketsecurity/lib`'s `integrity` + * module, `external-tools.json`). + * - `checksums.txt`, the release asset every tool publishes, stays sha256-hex — + * the ecosystem convention `shasum -c` expects. + * + * `parseChecksums` reads the hex transport format; `verifyReleaseChecksum` + * bridges it to the SRI pin via `@socketsecurity/lib/integrity`. + * + * Fleet-canonical: byte-identical across every repo that ships + * `scripts/socket-release/build-infra/lib/release-checksums/`. Drift caught by + * sync-scaffolding. + */ + +import crypto from 'node:crypto' +import { createReadStream, readFileSync } from 'node:fs' +import path from 'node:path' + +import type { Hash, HashAlgorithm } from '@socketsecurity/lib/integrity' +import { equalHashes, parseHash } from '@socketsecurity/lib/integrity' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { findUpPackageJson } from '@socketsecurity/lib/packages/find' + +const logger = getDefaultLogger() + +// --------------------------------------------------------------------------- +// Public types — match the JSON Schema at scripts/socket-release/build-infra/release-assets.schema.json. +// --------------------------------------------------------------------------- + +export interface ToolConfig { + description?: string | undefined + tag: string + // SRI integrity strings (`sha256-`, sha384/sha512 accepted). + checksums: Record +} + +export type EmbeddedChecksums = Record + +export interface VerifyResult { + actual?: string | undefined + expected?: string | undefined + source?: string | undefined + skipped?: boolean | undefined + valid: boolean +} + +// --------------------------------------------------------------------------- +// Embedded loader. +// +// Reads `scripts/socket-release/build-infra/release-assets.json` from the repo root. +// Lazy + cached: file is read at most once per process. The `null` sentinel +// distinguishes "tried and failed" from "not yet tried" so we don't retry +// on every call. +// --------------------------------------------------------------------------- + +let embeddedChecksums: EmbeddedChecksums | undefined | null + +/** + * Compute a hash of a file as lowercase hex, streamed so the whole file never + * loads into memory. Defaults to sha256 — the `checksums.txt` / `shasum -a + * 256` digest. `@socketsecurity/lib/integrity` has no streaming primitive (its + * one-shot `computeHash` docs itself defer chunked input back to + * `crypto.createHash`), so this stays a thin hand-rolled wrapper; convert the + * result to SRI with `parseHash(hex).sri` rather than hand-rolling that step. + */ +export async function computeFileHash( + filePath: string, + algorithm: HashAlgorithm = 'sha256', +): Promise { + const hash = crypto.createHash(algorithm) + const stream = createReadStream(filePath) + for await (const chunk of stream) { + hash.update(chunk) + } + return hash.digest('hex') +} + +export function getEmbeddedChecksum( + tool: string, + assetName: string, +): { checksum: string; tag: string } | undefined { + const embedded = getEmbeddedChecksums() + if (!embedded) { + return undefined + } + const toolConfig = embedded[tool] + if (!toolConfig?.checksums) { + return undefined + } + const checksum = toolConfig.checksums[assetName] + if (!checksum) { + return undefined + } + return { checksum, tag: toolConfig.tag } +} + +export function getEmbeddedChecksums(): EmbeddedChecksums | undefined { + if (embeddedChecksums === null) { + return undefined + } + if (embeddedChecksums === undefined) { + try { + const checksumPath = path.join( + path.dirname(findUpPackageJson(import.meta)), + 'release-assets.json', + ) + embeddedChecksums = JSON.parse( + readFileSync(checksumPath, 'utf8'), + ) as EmbeddedChecksums + } catch { + embeddedChecksums = undefined + return undefined + } + } + return embeddedChecksums +} + +/** + * Parse `checksums.txt` content into a map. + * + * Format: one entry per line, ` ` (two spaces or any + * whitespace between hash and name). Blank lines are skipped. Lines that don't + * match the expected shape are silently ignored — defensive against tools that + * prepend a header or comments. + */ +export function parseChecksums(content: string): Record { + const checksums: Record = { __proto__: null as never } + const lines = content.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! + const trimmed = line.trim() + if (!trimmed) { + continue + } + // Match a SHA-256 checksum line: 64 lowercase hex digits, one or more + // whitespace characters, then the filename extending to end of line. + const match = trimmed.match(/^([a-f0-9]{64})\s+(.+)$/) + if (match) { + checksums[match[2]!] = match[1]! + } + } + return checksums +} + +export interface VerifyConfig { + filePath: string + assetName: string + tool: string + quiet?: boolean | undefined + // When a tool has no checksums in release-assets.json at all, verification + // fails closed (`valid: false`) by default — an unverified download must + // not silently pass an integrity gate. Set `allowUnlisted: true` to opt a + // not-yet-tracked tool back into the old skip behavior (`valid: true, + // skipped: true`); use it only where downloading an untracked tool is + // intentional, and prefer adding the tool to release-assets.json instead. + allowUnlisted?: boolean | undefined +} + +/** + * Verify a downloaded file against the embedded SRI pin in + * `release-assets.json`. + * + * Embedded checksums are the source of truth. Five outcomes: + * + * 1. Embedded match found and the digest agrees → `{ valid: true }`. + * 2. Embedded match found but the digest disagrees → `{ valid: false }` with + * `actual` + `expected` populated. **Fail loudly.** + * 3. Embedded match found but the pin isn't a recognized SRI/hex string → `{ + * valid: false }`. The pin itself is malformed; fix it in + * `release-assets.json`. + * 4. Tool is in `release-assets.json` but `assetName` isn't listed → return `{ + * valid: false }`. The likely cause is a stale embedded manifest; bump `tag` + * + `checksums` in `release-assets.json` and re-run. + * 5. Tool isn't in `release-assets.json` at all → fail CLOSED: return `{ valid: + * false }` with a warning. An untracked tool is an unverified download, so + * it must not pass the integrity gate by default. Add the tool to + * `release-assets.json`, or pass `allowUnlisted: true` to opt a + * deliberately-untracked tool back into `{ valid: true, skipped: true }`. + */ +export async function verifyReleaseChecksum( + config: VerifyConfig, +): Promise { + const { + assetName, + filePath, + quiet = false, + tool, + } = { __proto__: null, ...config } as typeof config + + const embedded = getEmbeddedChecksum(tool, assetName) + if (embedded) { + let expectedHash: Hash + try { + expectedHash = parseHash(embedded.checksum) + } catch { + if (!quiet) { + logger.fail( + `Malformed checksum pin for ${assetName} in release-assets.json (tool: ${tool})`, + ) + logger.fail( + `Saw "${embedded.checksum}" — wanted a sha256/384/512 SRI string or hex digest. Fix the pin in release-assets.json.`, + ) + } + return { + expected: embedded.checksum, + source: 'embedded', + valid: false, + } + } + const actual = await computeFileHash(filePath, expectedHash.algorithm) + return { + actual, + expected: embedded.checksum, + source: 'embedded', + valid: equalHashes(actual, expectedHash), + } + } + + const embeddedData = getEmbeddedChecksums() + const toolBlock = embeddedData?.[tool] + if (toolBlock?.checksums && Object.keys(toolBlock.checksums).length > 0) { + if (!quiet) { + logger.fail( + `No embedded checksum for ${assetName} in release-assets.json (tool: ${tool})`, + ) + logger.fail(`Bump the tag + checksums in release-assets.json to update`) + } + return { source: 'embedded', valid: false } + } + + if (config.allowUnlisted) { + if (!quiet) { + logger.warn( + `No checksums found for ${tool}; allowUnlisted set, skipping verification`, + ) + } + return { skipped: true, valid: true } + } + // Fail closed: an untracked tool is unverified, so it must not pass. + if (!quiet) { + logger.fail( + `No checksums found for ${tool} in release-assets.json — refusing to ` + + `treat the download as verified. Add ${tool} to release-assets.json, ` + + `or pass allowUnlisted to skip intentionally.`, + ) + } + return { skipped: true, valid: false } +} diff --git a/release-kit/payload/scripts/socket-release/lib/release-checksums/producer.mts b/release-kit/payload/scripts/socket-release/lib/release-checksums/producer.mts new file mode 100644 index 00000000..e89a7f04 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/release-checksums/producer.mts @@ -0,0 +1,186 @@ +/** + * Release-checksum producer: write `checksums.txt` for a directory of + * artifacts; update a `release-assets.json` block. + * + * Use this when your repo _produces_ releases (e.g. socket-btm builds `.node` + * binaries and ships them to GH Releases). The output of `writeChecksumsFile()` + * is what consumers download and verify against via `consumer.mts`. + * + * `writeChecksumsFile` writes sha256-hex — `checksums.txt` stays the + * ecosystem `shasum -c` transport format. `updateReleaseAssets` re-encodes + * that same hex map to SRI (`@socketsecurity/lib/integrity`'s `parseHash`) + * before embedding it as the `release-assets.json` pin; a caller that already + * hands it an SRI string is untouched (`parseHash` is idempotent on SRI + * input). + * + * Repos that only consume releases don't need this file — see `consumer.mts`. + * + * Fleet-canonical: byte-identical across every repo that ships + * `scripts/socket-release/build-infra/lib/release-checksums/`. + */ + +import { promises as fs, readFileSync } from 'node:fs' +import path from 'node:path' + +import { parseHash } from '@socketsecurity/lib/integrity' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +import { computeFileHash } from './core.mts' +import { + withMirrorLockLifted, + writeThroughMirrorLock, +} from '../../_shared/mirror-lock.mts' + +import type { EmbeddedChecksums } from './core.mts' + +const logger = getDefaultLogger() + +/** + * Walk a directory and compute SHA-256 hashes for every regular file in it. + * + * Sub-paths are relative to `dir`. Symlinks and directories are not recursed — + * pass a flat directory of artifacts. + */ +export async function hashDirectory( + dir: string, +): Promise> { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const out: Record = { __proto__: null as never } + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + if (!entry.isFile()) { + continue + } + const filePath = path.join(dir, entry.name) + out[entry.name] = await computeFileHash(filePath) + } + return out +} + +export interface UpdateAssetsConfig { + /** + * Path to `release-assets.json`. + */ + manifestPath: string + /** + * Tool key inside the manifest (e.g. `lief`, `opentui`). + */ + tool: string + /** + * Release tag, e.g. `lief-20260507-76c1796`. + */ + tag: string + /** + * Asset → hash map (typically the sha256-hex return value of + * `writeChecksumsFile`; an SRI string is accepted too). Re-encoded to SRI + * before being written to `release-assets.json`. + */ + checksums: Record + /** + * Optional human-readable description for the tool block. + */ + description?: string | undefined +} + +/** + * Update a tool's block in `release-assets.json` in place. + * + * Reads the existing manifest, replaces the block for `tool` with the new + * `tag` + `checksums` (re-encoded to SRI via `parseHash().sri`), and writes + * the result back. Other tool blocks are preserved untouched. + * + * The manifest's $schema field, if present, is preserved. + */ +export function updateReleaseAssets(config: UpdateAssetsConfig): void { + const { checksums, description, manifestPath, tag, tool } = { + __proto__: null, + ...config, + } as typeof config + + let manifest: EmbeddedChecksums & { + $schema?: string | undefined + $comment?: string | undefined + } = { __proto__: null as never } as never + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch { + // New file — start fresh. + } + + const sriChecksums: Record = { __proto__: null as never } + const assetNames = Object.keys(checksums) + for (let i = 0, { length } = assetNames; i < length; i += 1) { + const assetName = assetNames[i]! + sriChecksums[assetName] = parseHash(checksums[assetName]!).sri + } + + manifest[tool] = { + ...(description !== undefined ? { description } : {}), + tag, + checksums: sriChecksums, + } + + writeThroughMirrorLock(manifestPath, JSON.stringify(manifest, null, 2) + '\n') +} + +export interface WriteChecksumsConfig { + /** + * Directory containing the artifacts to hash. + */ + inputDir: string + /** + * Path of the `checksums.txt` to write. + */ + outputPath: string + /** + * Optional ordering. If omitted, entries are sorted alphabetically. + */ + order?: 'alphabetical' | readonly string[] | undefined + /** + * Suppress info logging, errors still log. + */ + quiet?: boolean | undefined +} + +/** + * Write a `checksums.txt` file from a directory of artifacts. + * + * Output format: ` \n`, matching the format + * `consumer.mts:parseChecksums` expects. Filenames are sorted alphabetically by + * default for stable diffs. + */ +export async function writeChecksumsFile( + config: WriteChecksumsConfig, +): Promise> { + const { + inputDir, + order = 'alphabetical', + outputPath, + quiet = false, + } = { __proto__: null, ...config } as typeof config + + const checksums = await hashDirectory(inputDir) + const names = + order === 'alphabetical' ? Object.keys(checksums).toSorted() : [...order] + + const lines: string[] = [] + for (let i = 0, { length } = names; i < length; i += 1) { + const name = names[i]! + const hash = checksums[name] + if (!hash) { + if (!quiet) { + logger.warn(`No file matched ordering entry: ${name}`) + } + continue + } + lines.push(`${hash} ${name}`) + } + // POSIX-style trailing newline. + await withMirrorLockLifted(outputPath, () => + fs.writeFile(outputPath, lines.join('\n') + '\n', 'utf8'), + ) + if (!quiet) { + logger.info(`Wrote ${lines.length} checksums to ${outputPath}`) + } + return checksums +} diff --git a/release-kit/payload/scripts/socket-release/lib/verify-release-hashes.mts b/release-kit/payload/scripts/socket-release/lib/verify-release-hashes.mts new file mode 100644 index 00000000..9c7d4c69 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/verify-release-hashes.mts @@ -0,0 +1,295 @@ +/** + * @file Three-way release-tarball hash gate. Assert the LOCAL packed tarball, + * the GitHub Release asset, and the npm registry entry carry the same digest + * before an operator promotes a staged publish to public. A same-run + * `pnpm pack` feeds all three, so the bytes should match exactly; any + * divergence means a wrong-artifact upload, a stale asset, or tampering — a + * hard stop, never a logged hint (the fleet "fail LOUD" rule). The release + * orchestrator runs this immediately before `publish.mts --approve`. Registry + * and GitHub access are injected so the comparison logic unit-tests without a + * network or `gh`. + */ + +import crypto from 'node:crypto' +import { mkdtempSync, readdirSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { fetchVersionTrustInfo } from '../publish-infra/npm/registry.mts' + +const WIN32 = process.platform === 'win32' + +/** + * Options for the default GitHub-asset digest fetcher. `assetName` is the + * release asset filename (the same basename the local `pnpm pack` produced). + */ +export interface GitHubAssetDigestConfig { + assetName: string + cwd: string + tag: string +} + +/** + * The outcome of comparing hash sources. `algorithm` names which axis actually + * verified — `integrity` (sha512 SRI, preferred) when every source carries it, + * else `shasum` (sha1) as a fallback, else `undefined` when no single axis is + * present on every source (insufficient to verify → not ok). + */ +export interface HashComparison { + algorithm: 'integrity' | 'shasum' | undefined + digest: string | undefined + disagreeing: readonly string[] + ok: boolean + reason: string | undefined +} + +/** + * One artifact's digest, labeled by origin. A source may omit a field: a staged + * (not-yet-approved) npm version exposes only `shasum` via `pnpm stage list`, + * not `integrity`. + */ +export interface HashSource { + integrity: string | undefined + label: string + shasum: string | undefined +} + +/** + * A tarball's npm-shaped digests: `integrity` in SRI form (`sha512-`, + * matching npm `dist.integrity`) and `shasum` as sha1 hex (matching npm + * `dist.shasum`). + */ +export interface TarballDigest { + integrity: string + shasum: string +} + +export interface VerifyReleaseHashesConfig { + cwd: string + fetchGitHubAssetDigest?: + | ((options: GitHubAssetDigestConfig) => Promise) + | undefined + fetchRegistryDigest?: + | ((name: string, version: string) => Promise) + | undefined + hashLocalTarball?: ((filePath: string) => TarballDigest) | undefined + localTarball: string + name: string + tag: string + version: string +} + +/** + * Thrown by `verifyReleaseHashes` when the three sources are not proven + * identical. Carries the structured `comparison` so a caller can render its own + * report; the message is already fail-loud (What / Where / Saw-vs-wanted / + * Fix). + */ +export class ReleaseHashMismatchError extends Error { + readonly comparison: HashComparison + constructor(message: string, comparison: HashComparison) { + super(message) + this.name = 'ReleaseHashMismatchError' + this.comparison = comparison + } +} + +/** + * Compute the npm-shaped digests of a buffer: sha512 SRI + sha1 hex. + */ +export function hashBuffer(buffer: Buffer): TarballDigest { + return { + integrity: `sha512-${crypto.createHash('sha512').update(buffer).digest('base64')}`, + shasum: crypto.createHash('sha1').update(buffer).digest('hex'), + } +} + +/** + * Read a tarball off disk and return its npm-shaped digests. + */ +export function hashTarball(filePath: string): TarballDigest { + return hashBuffer(readFileSync(filePath)) +} + +/** + * Compare hash sources for byte-identity. Prefers sha512 `integrity` when every + * source carries it (a mismatch there is a hard fail — it never falls through + * to the weaker sha1), else falls back to `shasum` when every source carries + * that, else reports insufficient. The first source is the reference. + */ +export function compareHashSources( + sources: readonly HashSource[], +): HashComparison { + if (sources.length < 2) { + return { + algorithm: undefined, + digest: undefined, + disagreeing: [], + ok: false, + reason: `need at least 2 hash sources to compare, got ${sources.length}`, + } + } + const axes = ['integrity', 'shasum'] as const + for (let i = 0, { length } = axes; i < length; i += 1) { + const axis = axes[i]! + if (!sources.every(source => source[axis])) { + continue + } + const reference = sources[0]![axis]! + const disagreeing = sources + .filter(source => source[axis] !== reference) + .map(source => source.label) + return { + algorithm: axis, + digest: reference, + disagreeing, + ok: disagreeing.length === 0, + reason: + disagreeing.length === 0 + ? undefined + : `${axis} of ${disagreeing.join(', ')} differs from ${sources[0]!.label}`, + } + } + return { + algorithm: undefined, + digest: undefined, + disagreeing: sources + .filter(source => !source.integrity && !source.shasum) + .map(source => source.label), + ok: false, + reason: + 'no single hash algorithm is present on every source (need integrity OR shasum on all)', + } +} + +/** + * Verify the local tarball, the GitHub release asset, and the npm registry + * entry are byte-identical. Resolves with the passing `HashComparison`; throws + * `ReleaseHashMismatchError` on any divergence or insufficiency. Network and + * `gh` access default to the real fetchers below but are injectable for tests + * and for the pre-approve path (which supplies the staged shasum from `pnpm + * stage list`, since a staged version is not yet in the public packument). + */ +export async function verifyReleaseHashes( + config: VerifyReleaseHashesConfig, +): Promise { + const cfg = { __proto__: null, ...config } as VerifyReleaseHashesConfig + const hashLocal = cfg.hashLocalTarball ?? hashTarball + const fetchGitHub = + cfg.fetchGitHubAssetDigest ?? defaultFetchGitHubAssetDigest + const fetchRegistry = cfg.fetchRegistryDigest ?? defaultFetchRegistryDigest + const local = hashLocal(cfg.localTarball) + const [github, registry] = await Promise.all([ + fetchGitHub({ + assetName: path.basename(cfg.localTarball), + cwd: cfg.cwd, + tag: cfg.tag, + }), + fetchRegistry(cfg.name, cfg.version), + ]) + const sources: HashSource[] = [ + { integrity: local.integrity, label: 'local pack', shasum: local.shasum }, + github, + registry, + ] + const comparison = compareHashSources(sources) + if (!comparison.ok) { + throw new ReleaseHashMismatchError( + buildMismatchMessage(cfg, sources, comparison), + comparison, + ) + } + return comparison +} + +function buildMismatchMessage( + config: VerifyReleaseHashesConfig, + sources: readonly HashSource[], + comparison: HashComparison, +): string { + const cfg = { __proto__: null, ...config } as typeof config + const axis = comparison.algorithm ?? 'integrity/shasum' + const rows = sources + .map( + source => + ` ${source.label}: ${source.integrity ?? source.shasum ?? '(none)'}`, + ) + .join('\n') + return ( + `Release hash verification failed for ${cfg.name}@${cfg.version}.\n` + + ` Where: comparing local pack vs GitHub release ${cfg.tag} vs npm registry (${axis}).\n` + + ` Saw vs wanted: ${comparison.reason ?? 'sources disagree'}; sources:\n${rows}\n` + + ` Fix: reject the staged publish (node scripts/socket-release/npm-web-auth.mts stage reject ) and re-run the release — never approve a divergent artifact.` + ) +} + +/** + * Default registry digest: reads the PUBLIC packument, so it sees a version + * only after it is approved/public. The pre-approve gate injects its own + * fetcher backed by `pnpm stage list --json`. + */ +async function defaultFetchRegistryDigest( + name: string, + version: string, +): Promise { + const info = await fetchVersionTrustInfo(name, 'abbreviated') + const entry = info[version] + return { + integrity: entry?.integrity, + label: 'npm registry', + shasum: entry?.shasum, + } +} + +async function defaultFetchGitHubAssetDigest( + config: GitHubAssetDigestConfig, +): Promise { + const cfg = { __proto__: null, ...config } as GitHubAssetDigestConfig + const dir = mkdtempSync(path.join(os.tmpdir(), 'release-verify-')) + const result = await spawn( + 'gh', + [ + 'release', + 'download', + cfg.tag, + '--pattern', + cfg.assetName, + '--dir', + dir, + '--clobber', + ], + { + cwd: cfg.cwd, + shell: WIN32, + stdio: ['ignore', 'pipe', 'pipe'], + stdioString: true, + }, + ).catch((e: unknown) => ({ code: 1, stderr: errorMessage(e) })) + const code = (result as { code?: number | null | undefined }).code ?? 1 + if (code !== 0) { + throw new Error( + `Could not download the GitHub release asset for hash verification.\n` + + ` Where: gh release download ${cfg.tag} --pattern ${cfg.assetName}\n` + + ` Saw: gh exited ${code}\n` + + ` Fix: confirm the release ${cfg.tag} exists and carries the asset ${cfg.assetName}.`, + ) + } + const files = readdirSync(dir) + const downloaded = files.includes(cfg.assetName) ? cfg.assetName : files[0] + if (!downloaded) { + throw new Error( + `The GitHub release download produced no file.\n` + + ` Where: ${dir} after gh release download ${cfg.tag}\n` + + ` Saw: empty directory\n` + + ` Fix: confirm the asset ${cfg.assetName} is attached to release ${cfg.tag}.`, + ) + } + return { + label: 'GitHub release', + ...hashTarball(path.join(dir, downloaded)), + } +} diff --git a/release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts b/release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts new file mode 100644 index 00000000..391f7893 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts @@ -0,0 +1,248 @@ +/** + * @file Fleet-canonical pnpm-workspace.yaml string helpers. Pure functions — + * no FS reads, no side effects. All parsing is line-anchored to preserve + * hand-written comments (a proper YAML round-trip would drop them). + * Exported from here, the fleet-canonical home, and re-exported by + * scripts/repo/sync-scaffolding/manifest/catalog.mts + + * scripts/repo/sync-scaffolding/fix-workspace-yaml-splicers.mts for + * back-compat with their existing importers. + */ + +/** + * Parse a named block of `: ` entries from a pnpm-workspace.yaml + * string. Defaults to the `catalog:` block; pass `options.blockKey` to target + * another block (e.g. `'catalogOptional'` or `'overrides'`). + * + * Returns `{ '': '' }`. Tolerant of quoted vs + * unquoted keys and trailing comments. + */ +export function parseCatalogBlock( + content: string, + options?: { blockKey?: string | undefined } | undefined, +): Record { + const opts = Object.assign(Object.create(null) as Record, { + blockKey: 'catalog', + ...options, + }) as { blockKey: string } + const blockHeader = `${opts.blockKey}:` + const out: Record = {} + const lines = content.split('\n') + let inBlock = false + for (let i = 0; i < lines.length; i += 1) { + const ln = lines[i]! + if (ln.trimEnd() === blockHeader) { + inBlock = true + continue + } + if (!inBlock) { + continue + } + if (ln === '' || (ln.length > 0 && !/^\s/.test(ln))) { + break + } + const m = + // Parse a key: value line with optional surrounding quotes and trailing comment. + // `^\s*` — leading whitespace; `['"]?` — optional opening quote; + // `([^'":]+)` — group 1: key chars (no quote/colon); `['"]?` — optional closing quote; + // `\s*:\s*` — colon separator with optional spaces; + // `['"]?([^'"#\s]+)['"]?` — group 2: unquoted value (no quote/hash/space); + // `\s*(?:#.*)?$` — optional trailing comment to end of line. + /^\s*['"]?([^'":]+)['"]?\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/.exec(ln) + if (m?.[1] && m[2]) { + out[m[1]] = m[2] + } + } + return out +} + +/** + * Parse the `packages:` list (or any `- 'value'` bullet block) from a + * pnpm-workspace.yaml string. Handles single-quoted, double-quoted, and + * unquoted values. Negation patterns (leading `!`) are returned as-is. + * Comment lines are skipped. + */ +export function parseListBlock( + content: string, + config: { blockKey: string }, +): string[] { + const cfg = Object.assign(Object.create(null), config) as { + blockKey: string + } + const blockHeader = `${cfg.blockKey}:` + const results: string[] = [] + const lines = content.split('\n') + let inBlock = false + for (let i = 0; i < lines.length; i += 1) { + const ln = lines[i]! + if (ln.trimEnd() === blockHeader) { + inBlock = true + continue + } + if (!inBlock) { + continue + } + if (ln === '' || (ln.length > 0 && !/^\s/.test(ln))) { + break + } + const trimmed = ln.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + // Match `- 'value'`, `- "value"`, or `- value`, with optional trailing comment. + const m = /^-\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/.exec(trimmed) + if (m?.[1]) { + results.push(m[1].trim()) + } + } + return results +} + +/** + * Parse the `catalogs:` block, named catalogs, from a pnpm-workspace.yaml + * string. Returns a two-level map: `{ '': { '': '' + * } }`. Used for diagnosis only — named-catalog refs that have no matching + * sub-block are reported as unfixable. + */ +export function parseNamedCatalogs( + content: string, +): Record> { + const result: Record> = {} + const lines = content.split('\n') + let inCatalogsBlock = false + let currentName: string | undefined + for (let i = 0; i < lines.length; i += 1) { + const ln = lines[i]! + if (ln.trimEnd() === 'catalogs:') { + inCatalogsBlock = true + currentName = undefined + continue + } + if (!inCatalogsBlock) { + continue + } + // Top-level key ends the catalogs block. + if (ln.length > 0 && !/^\s/.test(ln)) { + break + } + if (ln === '') { + continue + } + // Two-space indent = named catalog sub-key, e.g. ` react17:` + const subKeyMatch = /^ {2}['"]?([^'":]+)['"]?\s*:$/.exec(ln) + if (subKeyMatch?.[1]) { + currentName = subKeyMatch[1] + result[currentName] = {} + continue + } + // Four-space indent = entry under the current named catalog. + if (currentName !== undefined) { + const entryMatch = + /^ {4}['"]?([^'":]+)['"]?\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/.exec( + ln, + ) + if (entryMatch?.[1] && entryMatch[2]) { + result[currentName]![entryMatch[1]] = entryMatch[2] + } + } + } + return result +} + +/** + * Insert `'': ` into the `catalog:` block, sorted + * alphabetically. Creates the block if absent. + */ +export function spliceCatalogEntry( + content: string, + name: string, + version: string, +): string { + const newLine = ` '${name}': ${version}` + const lines = content.split('\n') + const catalogIdx = lines.findIndex(line => line.trimEnd() === 'catalog:') + + if (catalogIdx === -1) { + return `catalog:\n${newLine}\n\n${content}` + } + + // Walk the block: each entry starts with leading whitespace and + // contains `:`. Stop at a blank line, EOF, or a top-level key. + let end = catalogIdx + 1 + while (end < lines.length) { + const ln = lines[end] + if (ln === undefined) { + break + } + if (ln === '' || (ln.length > 0 && !/^\s/.test(ln))) { + break + } + end += 1 + } + const blockLines = lines.slice(catalogIdx + 1, end) + // Entry already present, drift bump: rewrite the value in place, keeping + // the original line's whitespace + key quoting. No-op if already current. + const needle = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const dupRe = new RegExp(`^\\s*['"]?${needle}['"]?\\s*:`) + const existingIdx = blockLines.findIndex(line => dupRe.test(line)) + if (existingIdx !== -1) { + const existing = blockLines[existingIdx]! + const rewritten = existing.replace(/(:\s*).*$/, `$1${version}`) + if (rewritten === existing) { + return content + } + const next = [...lines] + next[catalogIdx + 1 + existingIdx] = rewritten + return next.join('\n') + } + // Insert alphabetically by package name. Existing entries may be + // quoted (`'@types/node':`) or bare (`micromark:`); compare on the + // un-quoted name. + const nameOf = (line: string): string => { + const m = /^\s*['"]?([^'":]+)['"]?\s*:/.exec(line) + return m ? m[1]! : '' + } + const target = name + let insertAt = catalogIdx + 1 + for (let i = 0; i < blockLines.length; i += 1) { + if (target.localeCompare(nameOf(blockLines[i]!)) < 0) { + insertAt = catalogIdx + 1 + i + break + } + insertAt = catalogIdx + 1 + i + 1 + } + const next = [...lines] + next.splice(insertAt, 0, newLine) + return next.join('\n') +} + +/** + * Remove the `'': ` entry from the `catalog:` block, keyed on + * the NAME only — any version/spec matches, quoted or bare. No-op when the + * block or the entry is absent. + */ +export function removeCatalogEntry(content: string, name: string): string { + const lines = content.split('\n') + const catalogIdx = lines.findIndex(line => line.trimEnd() === 'catalog:') + if (catalogIdx === -1) { + return content + } + let end = catalogIdx + 1 + while (end < lines.length) { + const ln = lines[end] + if (ln === undefined || ln === '' || (ln.length > 0 && !/^\s/.test(ln))) { + break + } + end += 1 + } + const needle = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const entryRe = new RegExp(`^\\s*['"]?${needle}['"]?\\s*:`) + const idx = lines.findIndex( + (line, i) => i > catalogIdx && i < end && entryRe.test(line), + ) + if (idx === -1) { + return content + } + const next = [...lines] + next.splice(idx, 1) + return next.join('\n') +} diff --git a/release-kit/payload/scripts/socket-release/npm-publish.mts b/release-kit/payload/scripts/socket-release/npm-publish.mts new file mode 100644 index 00000000..d06587f2 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/npm-publish.mts @@ -0,0 +1,341 @@ +/* + * @file Fleet-canonical publish runner. Three modes: --staged Upload this + * package's tarball to npm staging via `pnpm stage publish`. Designed to run + * in CI under the OIDC trusted-publisher token. Nothing publicly visible + * until --approve runs. Adds `--provenance` automatically when GITHUB_ACTIONS + * is set. THIS IS THE DEFAULT path — staging gives `pnpm stage reject` a + * server-side rescue for botched uploads (wrong file, wrong checksum, wrong + * version) before anything goes public. --approve Interactive multi-select + * over the user's currently-staged packages, then batch `pnpm stage approve + * ` with a single shared 2FA OTP. Designed to run locally. OTP resolution + * order: + * + * 1. `--otp ` flag (CI / scripted use). + * 2. `--yes` with no `--otp` → skip the prompt; the registry challenge drives + * pnpm's web-OTP flow directly (browser window to npmjs.com per approve + * call) — the agent-driveable path: no TTY needed, the human authenticates + * in the browser. + * 3. Interactive `password` prompt (lib/stdio/prompts). + * 4. Empty prompt input → pnpm's per-call web-OTP flow (registry challenge opens + * a browser window to npmjs.com per approve call). --direct Classic + * single-step `pnpm publish` — uploads + makes public in one call, no + * stage/approve. Escape hatch for environments where the stage endpoint is + * unreachable (e.g. an SFW build without the `/-/stage/*` endpoint + * allowlist). Same provenance + OIDC token shape as --staged when + * GITHUB_ACTIONS is set. Trades server-side rejectability for fewer hops; + * only use when the stage path can't reach npm. Prefer --staged whenever + * the network allows it. --dry-run Forwarded to the underlying pnpm + * command. Used to preview the tarball + manifest without registry writes. + * The staged/approve split is a hard requirement of npm's staged-publish + * flow: the stage upload uses an OIDC token from CI; the approve step + * requires human 2FA. Combining them in one mode would either leak the OTP + * into CI logs or require a human at the CI keyboard. Repos with bespoke + * publish pipelines (socket-addon's 9-package OIDC + .node verification, + * socket-registry's monorepo package-npm-publish delegation, etc.) keep + * their own publish.mts and don't adopt this canonical version. Repos with + * simple single-package publishing consume this one byte-identical via the + * sync-scaffolding cascade. + * + * This file is the thin entry: arg parsing + mode dispatch. The + * implementation lives under `publish-infra/`, organized in registry tiers + * so a future `cargo-publish.mts` slots in beside npm: the agnostic core + * (`publish-infra/shared.mts` — spawn/git/JSON helpers, + * `publish-infra/release.mts` — git tag + GitHub release) and the npm tier + * (`publish-infra/npm/` — registry reads, staged/direct modes, and the + * approve flow). + */ + +import process from 'node:process' + +import { parseArgs } from '@socketsecurity/lib/argv/parse' +import { getCI } from '@socketsecurity/lib/env/ci' + +import { runApprove } from './publish-infra/npm/approve.mts' +import { + backfillFlagConflict, + runBackfillGate, +} from './publish-infra/npm/backfill.mts' +import { + fetchPublishedVersion, + findPublishedBaseSha, + rebaseOntoPublishedBase, + syncFromOriginMain, +} from './publish-infra/reconcile.mts' +import { + isStagingExpected, + parseStageListJson, + readKitDistTag, + readPackageJson, + readStagedShasum, +} from './publish-infra/npm/shared.mts' +import { + runDirect, + runStaged, + verifyStagedEntry, +} from './publish-infra/npm/staged.mts' +import { + ensureTagAndRelease, + extractChangelogSection, +} from './publish-infra/release.mts' +import { logger, rootPath } from './publish-infra/shared.mts' +import { + unexpectedPositionalsMessage, + unknownFlags, + unknownFlagsMessage, +} from './_shared/cli-flags.mts' +import { isMainModule } from './_shared/is-main-module.mts' + +export { + ensureTagAndRelease, + extractChangelogSection, + isStagingExpected, + parseStageListJson, + readStagedShasum, + verifyStagedEntry, +} + +const OPTIONS = { + approve: { default: false, type: 'boolean' }, + backfill: { type: 'string' }, + 'checkout-ref': { type: 'string' }, + direct: { default: false, type: 'boolean' }, + 'dry-run': { default: false, type: 'boolean' }, + help: { default: false, type: 'boolean' }, + 'no-reconcile': { default: false, type: 'boolean' }, + 'no-release': { default: false, type: 'boolean' }, + 'no-scan': { default: false, type: 'boolean' }, + otp: { type: 'string' }, + staged: { default: false, type: 'boolean' }, + tag: { type: 'string' }, + yes: { default: false, type: 'boolean' }, +} as const + +export function parsePublishArgv( + args: readonly string[] = process.argv.slice(2), +): { positionals: string[]; values: Record } { + const parsed = parseArgs({ + args, + options: OPTIONS, + allowPositionals: false, + strict: false, + configuration: { 'boolean-negation': false }, + }) + return { + positionals: parsed.positionals as string[], + values: parsed.values as Record, + } +} + +export function parsePublishArgs( + args: readonly string[] = process.argv.slice(2), +): Record { + return parsePublishArgv(args).values +} + +async function main(): Promise { + const { positionals, values } = parsePublishArgv() + + if (positionals.length > 0) { + logger.fail(unexpectedPositionalsMessage(positionals)) + logger.error( + 'Usage: node scripts/socket-release/npm-publish.mts [--staged | --approve | --direct] [--dry-run] [--otp ] [--yes]', + ) + process.exitCode = 2 + return + } + + const unknown = unknownFlags(values, Object.keys(OPTIONS)) + if (unknown.length > 0) { + logger.fail(unknownFlagsMessage(unknown)) + logger.error( + 'Usage: node scripts/socket-release/npm-publish.mts [--staged | --approve | --direct] [--dry-run] [--otp ] [--yes]', + ) + process.exitCode = 2 + return + } + + if (values['help']) { + logger.log( + 'Usage: node scripts/socket-release/npm-publish.mts [--staged | --approve | --direct] [--dry-run] [--otp ] [--yes]', + ) + logger.log(' (no mode → --staged, the default publish path)') + logger.log('') + logger.log( + ' --staged CI: upload to npm staging via OIDC (recommended)', + ) + logger.log(' --approve local: multi-select + 2FA promote') + logger.log( + ' --direct classic `pnpm publish` — public in one step,', + ) + logger.log( + ' no stage/approve. Escape hatch when the stage', + ) + logger.log( + ' endpoint is unreachable (errors if staging is', + ) + logger.log(' available — use --staged instead).') + logger.log(' --dry-run simulate; no registry writes') + logger.log( + ' --otp pre-supply 2FA (skips OTP prompt on --approve)', + ) + logger.log( + ' --yes approve all staged non-interactively; with no', + ) + logger.log( + ' --otp, 2FA runs in the browser (web-OTP)', + ) + logger.log( + ' --no-scan skip the pre-approve Socket full-scan gate', + ) + logger.log( + ' --no-release with --approve: skip the tag + GitHub release', + ) + logger.log( + ' (cut them later with github-release.mts --tag vX.Y.Z --release)', + ) + logger.log( + ' --no-reconcile local: skip the once-published reconcile (rebase', + ) + logger.log( + ' our commits onto the newly-published base + ff-pull', + ) + logger.log( + ' origin main). Runs by DEFAULT after --approve', + ) + logger.log( + ' (fails loud on conflict); CI --staged never does.', + ) + logger.log( + ' --tag dist-tag for --staged (default: npm.distTag from', + ) + logger.log( + ' .config/socket-release.json, else latest)', + ) + logger.log( + ' --backfill CI: stage a never-published GAP version of prior', + ) + logger.log( + ' content. Bypasses the version-order gate behind', + ) + logger.log( + ' hard guards; requires --checkout-ref + a', + ) + logger.log( + ' non-latest --tag. See publish-infra/npm/backfill.mts', + ) + logger.log( + ' --checkout-ref the content ref a --backfill republishes', + ) + process.exitCode = 0 + return + } + + const modes = [values['staged'], values['approve'], values['direct']].filter( + Boolean, + ).length + if (modes > 1) { + logger.fail( + 'Pass at most one of --staged / --approve / --direct.\n' + + ' Fix: pick one mode; a bare invocation defaults to --staged.', + ) + process.exitCode = 2 + return + } + // Default to staged — the safest publish path (server-side rejectable before + // anything goes public). A bare `pnpm publish` uploads to staging. + const mode = values['direct'] + ? 'direct' + : values['approve'] + ? 'approve' + : 'staged' + + const dryRun = !!values['dry-run'] + // The staged dist-tag: an explicit --tag wins, else the kit config's + // npm.distTag, else npm's own 'latest'. Resolving here keeps the config knob + // live instead of silently discarding it for a hard-coded default. + const tag = + typeof values['tag'] === 'string' && values['tag'] + ? values['tag'] + : (readKitDistTag(rootPath) ?? 'latest') + const otpFromFlag = + typeof values['otp'] === 'string' ? values['otp'] : undefined + // Reconcile is the DEFAULT once published (local, not a flag — a flag is + // forgotten and local main drifts from the release). Gated OFF in CI: + // `--staged` runs on a clean OIDC checkout and must never touch git. + // `--no-reconcile` is the deliberate local opt-out. + const reconcile = !getCI() && !values['no-reconcile'] + const backfillVersion = + typeof values['backfill'] === 'string' && values['backfill'] + ? values['backfill'] + : undefined + const checkoutRef = + typeof values['checkout-ref'] === 'string' && values['checkout-ref'] + ? values['checkout-ref'] + : undefined + // The kit defers CI auto-bump (`--bump` / `--release-as`), so those two + // conflict arms are structurally unreachable here — the flags do not exist. + // The gate still runs: `--checkout-ref` without `--backfill`, and + // `--backfill` outside the staged path, are the live arms. + const flagConflict = backfillFlagConflict({ + backfillVersion, + bump: false, + checkoutRef, + mode, + releaseAs: undefined, + }) + if (flagConflict) { + logger.fail(flagConflict) + process.exitCode = 2 + return + } + // Backfill: the ONLY sanctioned path to a version below registry latest. + // The version-order gate is bypassed — the backfill guards replace it — + // and on a pass the publish continues through the normal staged path with + // the checked-out content as-is. + if (backfillVersion) { + const allowed = await runBackfillGate({ + backfillVersion, + checkoutRef, + distTag: tag, + }) + if (!allowed) { + process.exitCode = 1 + return + } + } + if (mode === 'staged') { + await runStaged(tag, { dryRun }) + } else if (mode === 'direct') { + await runDirect(tag, { dryRun }) + } else { + await runApprove({ + dryRun, + noScan: !!values['no-scan'], + otpFromFlag, + skipRelease: !!values['no-release'], + yes: !!values['yes'], + }) + // Reconcile ONCE PUBLISHED: approve just made the version public and the + // operator's bump commit is on origin. Rebase our remaining local commits + // onto that freshly-published base, then ff-pull so local main matches the + // now-updated origin. Fail-loud on a conflict — never guess a lineage. + if (reconcile && !dryRun) { + // The PUBLISH SUBJECT's name — the root for a plain repo, the + // redirected subject for a publishConfig.directory monorepo, the MAIN + // package for a multi-package workspace (a private root has no + // registry history to reconcile against). + const pkgName = readPackageJson().name + const published = await fetchPublishedVersion(pkgName) + const baseSha = await findPublishedBaseSha(rootPath, published) + await rebaseOntoPublishedBase(rootPath, baseSha) + await syncFromOriginMain(rootPath) + } + } +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/npm-web-auth.mts b/release-kit/payload/scripts/socket-release/npm-web-auth.mts new file mode 100644 index 00000000..36c72ec0 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/npm-web-auth.mts @@ -0,0 +1,475 @@ +#!/usr/bin/env node +/* + * @file PTY wrapper for npm's browser-based 2FA web-auth flow, for `npm + * publish|login|deprecate|owner|access|...` run from a NON-interactive agent + * shell. WHY THIS EXISTS. npm's write operations require 2FA. On a real + * terminal npm runs a web-auth flow: it prints "Authenticate your account + * at:" plus a `https://www.npmjs.com/auth/cli/` URL, opens the browser, + * and polls the registry until the human approves. Two things break that flow + * under an agent: + * + * 1. NO TTY. The agent's Bash channel is not a terminal, so npm decides it + * cannot do an interactive/web flow and errors `EOTP` instead of opening + * the browser and staying alive to poll. + * 2. MASKED OUTPUT. The agent harness redacts the auth URL in displayed tool + * output as `auth/cli/***`, so the URL can never be relayed by reading + * what the terminal shows. THE FIX. Run npm under a pseudo-terminal so it + * believes it has a TTY and performs its native open-and-poll web flow, + * staying alive until the human authenticates. `script -q /dev/null npm + * ...` is the zero-dependency PTY on macOS and BSD; util-linux `script -q + * -c '' /dev/null` is the Linux form. We stream npm's output straight + * through to the caller AND watch the RAW process stream for the auth URL. + * Reading the URL off the raw stream sidesteps the harness masking + * entirely: the URL flows into the platform opener as an argument, and is + * ALSO printed — the opener fails silently on some setups, the sessions + * expire in minutes, and an operator fishing the URL out of task files by + * hand loses that race (2026-07-31). A harness may mask the displayed + * form; the operator's terminal and task files carry it whole. On first + * match we spawn `open` / `xdg-open` / `start` on it, then keep the + * process alive until npm exits and propagate npm's exit code. NO-OP PASSTHROUGH. When a real TTY is present npm + * handles its own flow, and when `--otp=` is already supplied no + * browser is needed, so in both cases this wrapper execs the tool directly + * with inherited stdio and does nothing else. TOOL SELECTION. `login` and + * `adduser` run through `pnpm login` when pnpm is on PATH: pnpm 11 drives + * the SAME browser web-auth flow (verified 2026-07-28 — it prints + * "Authenticate your account at:" + an npmjs.com/login URL this watcher + * already matches) and, being pnpm, it passes the `devEngines` gate that + * makes bare `npm login` fail EBADDEVENGINES inside every pnpm-enforced + * fleet repo (the odai 0.0.1 release hit exactly that). The tokens SPLIT, + * though: pnpm 11's web login keeps its token in pnpm's own config, and + * bare npm keeps reading ~/.npmrc — a green pnpm login can leave every + * npm op 401ing minutes later (three trust-sweep rounds, 2026-07-31). + * The split-token guard below makes one `login` mean BOTH tools hold a + * live token. `--npm` forces npm (stripped before exec), and an + * `--otp` run stays on npm since pnpm login takes no OTP flag. Every + * other operation stays on npm. Usage: node + * scripts/socket-release/npm-web-auth.mts + * [args] [--npm] + */ + +// oxlint-disable-next-line socket/prefer-async-spawn -- PTY streaming + detached opener + exact exit-code propagation need raw child_process control; see the per-call rationale on runUnderPty/runInherit/openInBrowser. +import { spawn as nodeSpawn } from 'node:child_process' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { spawnSync } from '@socketsecurity/lib/process/spawn/child' + +import { NPM_AUTH_TOKEN_KEY } from './constants/npm-registry.mts' +import { isMainModule } from './_shared/is-main-module.mts' +import { runMain } from './_shared/run-main.mts' +import { npmScratchCwd } from './publish-infra/npm/shared.mts' + +const logger = getDefaultLogger() + +// Operations that carry NO package/repo context — auth and registry-settings +// ops that work identically from any directory. These default to +// npmScratchCwd(): run from a fleet repo they otherwise die on the +// devEngines pnpm veto before ever reaching auth (burned two sweep rounds, +// 2026-07-31). Package-context ops (publish, deprecate, access, owner…) keep +// the caller's cwd — publish MUST run where the package lives. +const CWD_FREE_OPS: ReadonlySet = new Set([ + 'adduser', + 'login', + 'logout', + 'stage', + 'token', + 'trust', + 'whoami', +]) + +/** + * The cwd an operation runs from: an explicit caller cwd always wins; a + * cwd-free op falls back to the scratch dir; package-context ops keep the + * process cwd. Pure; exported for tests. + */ +export function resolveOpCwd( + operation: string | undefined, + injected: string | undefined, +): string | undefined { + if (injected !== undefined) { + return injected + } + return operation !== undefined && CWD_FREE_OPS.has(operation) + ? npmScratchCwd() + : undefined +} + +// The npm subcommands whose write path triggers the 2FA web-auth flow. Used by +// --help text and the sibling npm-2fa-needs-pty-guard; kept here so the one list +// of auth-gated operations lives beside the runner that services them. +export const AUTH_OPERATIONS: readonly string[] = [ + 'access', + 'deprecate', + 'login', + 'owner', + 'publish', + 'unpublish', +] + +// The two prompt phrases npm prints immediately before the web-auth URL: the +// happy path and the error/authorize path. Detection is anchored on one of +// these so arbitrary npmjs.com URLs elsewhere in the output can never be +// mistaken for the auth URL. +const AUTH_PROMPT_RE = + /Authenticate your account at:|Open this URL in your browser to authenticate:/i + +// A concrete npm web-auth URL: an npmjs.com host with an `/auth/cli/` or a +// `/login` path. The character classes EXCLUDE `*` on purpose — the agent +// harness renders the redacted URL as `auth/cli/***`, so a masked display can +// never satisfy this pattern. We only ever extract from the raw process stream, +// and this makes that guarantee structural rather than incidental. +const NPM_AUTH_URL_RE = + /https?:\/\/[a-z0-9.-]*npmjs\.com\/(?:auth\/cli\/[a-z0-9._-]+|login[a-z0-9._~:/?#[\]@!$&'()+,;=%-]*)/i + +/** + * Extract the npm web-auth URL from a chunk of npm output. Returns the FIRST + * `https://www.npmjs.com/auth/cli/` or login URL that appears after one of + * npm's auth prompts, or `undefined` when no prompt-anchored, unmasked URL is + * present. ANSI/spinner noise around the phrase and URL is tolerated because + * the scan is a forward regex from the prompt, not a line-exact parse. + */ +export function extractNpmAuthUrl(text: string): string | undefined { + const prompt = AUTH_PROMPT_RE.exec(text) + if (!prompt) { + return undefined + } + const after = text.slice(prompt.index) + return NPM_AUTH_URL_RE.exec(after)?.[0] +} + +/** + * The platform command that opens a URL in the default browser: `open` on + * macOS, `start` on Windows, `xdg-open` elsewhere. + */ +export function pickOpenCommand(platform: NodeJS.Platform): string { + if (platform === 'darwin') { + return 'open' + } + if (platform === 'win32') { + return 'start' + } + return 'xdg-open' +} + +/** + * True when the npm args already carry an `--otp` flag (`--otp ` or + * `--otp=`). With an OTP supplied npm needs no browser, so the wrapper + * passes straight through to npm. + */ +export function hasOtpFlag(args: readonly string[]): boolean { + return args.some(a => a === '--otp' || a.startsWith('--otp=')) +} + +export interface AuthToolPlan { + readonly tool: 'npm' | 'pnpm' + readonly args: readonly string[] +} + +/** + * Pick the tool that runs this operation. `login`/`adduser` and the staged- + * publish ops (`stage list|approve|reject`) go through pnpm when it is + * available — same browser web-auth flow, and pnpm 11 keeps its OWN valid + * token in config.yaml while a stale ~/.npmrc entry can leave bare npm + * 401ing (the odai 0.0.1 release hit exactly that: pnpm whoami answered + * while npm whoami failed). pnpm is also immune to the `devEngines` gate + * that fails bare npm inside a fleet repo. `--npm` forces npm, stripped + * from the args before exec, and an `--otp` run stays on npm, which owns + * that flag. Everything else stays npm. Pure; exported for tests. + */ +export function resolveAuthTool( + argv: readonly string[], + config: { pnpmAvailable: boolean }, +): AuthToolPlan { + const opts = { __proto__: null, ...config } as { pnpmAvailable: boolean } + const forceNpm = argv.includes('--npm') + const args = argv.filter(a => a !== '--npm') + const operation = args[0] + const isLogin = operation === 'adduser' || operation === 'login' + const isStage = operation === 'stage' + if ( + (isLogin || isStage) && + opts.pnpmAvailable && + !forceNpm && + !hasOtpFlag(args) + ) { + return { + tool: 'pnpm', + args: isLogin ? ['login', ...args.slice(1)] : args, + } + } + return { tool: 'npm', args } +} + +export interface PtyInvocation { + readonly command: string + readonly args: readonly string[] +} + +/** + * Build the `script`-based PTY invocation that runs `npm ` under a + * pseudo-terminal. macOS/BSD `script` takes the command as trailing args after + * the typescript file; util-linux `script` takes it via `-c`. Returns + * `undefined` on platforms without `script` (Windows), where the caller falls + * back to running npm directly. + */ +export function buildPtyInvocation( + platform: NodeJS.Platform, + npmArgs: readonly string[], + tool: 'npm' | 'pnpm' = 'npm', +): PtyInvocation | undefined { + if (platform === 'win32') { + return undefined + } + if (platform === 'linux') { + const inner = [tool, ...npmArgs].map(quoteForShell).join(' ') + return { command: 'script', args: ['-q', '-c', inner, '/dev/null'] } + } + // macOS + the BSDs: `script -q /dev/null `. + return { command: 'script', args: ['-q', '/dev/null', tool, ...npmArgs] } +} + +// Minimal single-quote shell escaping for the Linux `script -c` command string. +// Array-based spawn args stay unquoted; only the Linux `-c` path joins into one +// string, so this is the one place a token needs escaping. +function quoteForShell(token: string): string { + return `'${token.replaceAll("'", `'\\''`)}'` +} + +export interface RunConfig { + readonly argv: readonly string[] + readonly platform: NodeJS.Platform + readonly isTty: boolean + readonly env: NodeJS.ProcessEnv + // Working directory for the npm child. Programmatic callers (the + // placeholder reservation script publishes from an assembled temp dir) set + // it; the CLI leaves it undefined = the caller's cwd. + readonly cwd?: string | undefined +} + +/** + * True when the wrapper should stay out of the way and exec npm directly: a + * real TTY is present, npm drives its own flow, or an `--otp` is already + * supplied, no browser needed. + */ +export function isPassthrough(config: { + readonly isTty: boolean + readonly args: readonly string[] +}): boolean { + return config.isTty || hasOtpFlag(config.args) +} + +// Spawn the platform opener on the URL, detached, with all stdio discarded. The +// URL is passed ONLY as an argument (data-flow), never written to our stdout, so +// the harness masking of displayed output is irrelevant. +function openInBrowser(url: string, platform: NodeJS.Platform): void { + try { + const opener = pickOpenCommand(platform) + const child = nodeSpawn(opener, [url], { + detached: true, + stdio: 'ignore', + // `start` is a cmd.exe builtin, not an executable. + shell: platform === 'win32', + }) + child.on('error', () => {}) + child.unref() + } catch { + // Opening the browser is best-effort — the URL still streams through to the + // caller, who can open it by hand. + } +} + +// Run `cmd args` inheriting all stdio and resolve with its exit code. The direct +// (non-PTY) path for the TTY / --otp passthrough and for platforms without +// `script`. +// oxlint-disable-next-line socket/prefer-async-spawn -- streaming passthrough: stdio is inherited and the exact child exit code is propagated. +function runInherit( + cmd: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + cwd?: string | undefined, +): Promise { + return new Promise(resolve => { + const child = nodeSpawn(cmd, [...args], { stdio: 'inherit', env, cwd }) + child.on('error', () => resolve(1)) + child.on('exit', code => resolve(code ?? 1)) + }) +} + +// Run npm under the PTY: stream npm's output through to the caller while +// watching the raw stream for the auth URL, opening it on first match. Resolves +// with npm's exit code. +// oxlint-disable-next-line socket/prefer-async-spawn -- PTY web-auth requires streaming stdio and a live URL watcher on the raw child stream. +function runUnderPty(pty: PtyInvocation, config: RunConfig): Promise { + return new Promise(resolve => { + const child = nodeSpawn(pty.command, [...pty.args], { + stdio: ['inherit', 'pipe', 'pipe'], + env: config.env, + cwd: config.cwd, + }) + let buffer = '' + let opened = false + const watch = (chunk: Buffer) => { + process.stdout.write(chunk) + if (opened) { + return + } + buffer += chunk.toString('utf8') + const url = extractNpmAuthUrl(buffer) + if (url) { + opened = true + openInBrowser(url, config.platform) + // ALSO print the URL. The opener fails silently on some setups (no + // tab ever surfaced, 2026-07-31), and these login sessions expire in + // minutes — the operator manually fishing the URL out of task-output + // files lost the race repeatedly. An agent harness may MASK the + // displayed form (auth/cli/***), but the operator's own terminal and + // task files carry it whole, and a masked print still tells the + // human a URL exists and where to find it. + logger.log(`Auth page (if no tab appeared, open this yourself): ${url}`) + logger.log('These sessions expire in minutes — open it promptly.') + } + } + child.stdout?.on('data', watch) + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk)) + child.on('error', () => resolve(1)) + child.on('exit', code => resolve(code ?? 1)) + }) +} + +/** + * Run an npm auth operation, choosing the passthrough or PTY path. Pure w.r.t. + * its `config` argument so a test can drive it with an injected platform / TTY + * state / env. Resolves with the exit code to propagate. + */ +export async function runNpmWebAuth(config: RunConfig): Promise { + const plan = resolveAuthTool(config.argv, { pnpmAvailable: pnpmOnPath() }) + const args = [...plan.args] + const cwd = resolveOpCwd(args[0], config.cwd) + const cfg = { __proto__: null, ...config, cwd } as RunConfig + let code: number + if (isPassthrough({ isTty: cfg.isTty, args })) { + code = await runInherit(plan.tool, args, cfg.env, cfg.cwd) + } else { + const pty = buildPtyInvocation(cfg.platform, args, plan.tool) + code = pty + ? await runUnderPty(pty, cfg) + : await runInherit(plan.tool, args, cfg.env, cfg.cwd) + } + // SPLIT-TOKEN GUARD. A pnpm-routed login keeps its token in pnpm's own + // config while bare npm keeps reading ~/.npmrc — a "successful" login can + // leave every npm op (whoami, trust, publish) 401ing minutes later, which + // burned three trust-sweep rounds on 2026-07-31. One `login` must mean + // BOTH tools hold a live token. The tokens are interchangeable bearer + // tokens, so the fix is a BRIDGE, not a second login: copy pnpm's token + // into the user npmrc and re-probe. npm's own web login stays the last + // resort — its /login/cli handshake was observed rejecting fresh sessions + // outright ("Invalid or Expired Token" seconds after mint, 2026-07-31) + // while pnpm's flow completed fine in the same browser. + if ( + code === 0 && + plan.tool === 'pnpm' && + plan.args[0] === 'login' && + !npmWhoamiAlive(cfg.env) + ) { + if (bridgePnpmTokenToNpm(cfg.env) && npmWhoamiAlive(cfg.env)) { + logger.log( + "bridged pnpm's registry token into the user npmrc — bare npm is " + + 'live without a second login.', + ) + return 0 + } + logger.log( + 'pnpm login is live, but bare npm still 401s (split tokens) and the ' + + "token bridge did not take — running npm's own web login.", + ) + return runNpmWebAuth({ ...cfg, argv: ['login', '--npm'] }) + } + return code +} + +// Copy pnpm's registry bearer token into npm's user config. The token value +// flows process-to-process as an argument and is never logged. Sync by +// design: one cheap hop on the login path. +function bridgePnpmTokenToNpm(env: NodeJS.ProcessEnv | undefined): boolean { + try { + // oxlint-disable-next-line socket/prefer-async-spawn -- one-shot sync config read on the login path. + const read = spawnSync('pnpm', ['config', 'get', NPM_AUTH_TOKEN_KEY], { + cwd: npmScratchCwd(), + env, + }) + const token = String(read.stdout ?? '').trim() + if (read.status !== 0 || !token || token === 'undefined') { + return false + } + // oxlint-disable-next-line socket/prefer-async-spawn -- one-shot sync config write on the login path. + const write = spawnSync( + 'npm', + ['config', 'set', `${NPM_AUTH_TOKEN_KEY}=${token}`, '--location=user'], + { cwd: npmScratchCwd(), env, stdio: 'ignore' }, + ) + return write.status === 0 + } catch { + return false + } +} + +// True when bare npm can answer whoami — the post-login liveness probe for +// the split-token guard. Sync by design: one cheap gate on the login path. +function npmWhoamiAlive(env: NodeJS.ProcessEnv | undefined): boolean { + try { + // oxlint-disable-next-line socket/prefer-async-spawn -- one-shot sync liveness probe on the login path. + const result = spawnSync('npm', ['whoami'], { + cwd: npmScratchCwd(), + env, + stdio: 'ignore', + }) + return result.status === 0 + } catch { + return false + } +} + +// True when pnpm resolves on PATH — the impure availability probe behind +// resolveAuthTool's pure planning. A miss quietly keeps the npm path. +function pnpmOnPath(): boolean { + try { + // oxlint-disable-next-line socket/prefer-async-spawn -- one-shot sync availability probe before the exec path is chosen. + const result = spawnSync('pnpm', ['--version'], { stdio: 'ignore' }) + return result.status === 0 + } catch { + return false + } +} + +function usage(): string { + return [ + 'Usage: npm-web-auth [args...]', + '', + 'Runs `npm [args...]` under a PTY so npm performs its native', + 'browser 2FA web-auth flow from a non-interactive agent shell, and', + 'auto-opens the auth URL read from the raw process stream.', + '', + `Auth-gated operations: ${AUTH_OPERATIONS.join(', ')}.`, + '', + 'Passes straight through to npm when a real TTY is present or when', + '--otp= is already supplied.', + ].join('\n') +} + +async function main(): Promise { + const argv = process.argv.slice(2) + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + logger.log(usage()) + return argv.length === 0 ? 2 : 0 + } + return runNpmWebAuth({ + argv, + platform: process.platform, + isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY), + env: process.env, + }) +} + +if (isMainModule(import.meta.url)) { + runMain(main) +} diff --git a/release-kit/payload/scripts/socket-release/paths.mts b/release-kit/payload/scripts/socket-release/paths.mts new file mode 100644 index 00000000..93bf34cf --- /dev/null +++ b/release-kit/payload/scripts/socket-release/paths.mts @@ -0,0 +1,25 @@ +/** + * @file Repo-root resolution for the release kit. A consumer repo needs + * exactly one fact: where its root is. The kit always installs at + * `/scripts/socket-release/`, so the root is two directories up from + * this file — no `process.cwd()`, no upward `package.json` hunt, no + * dependence on where the operator's shell happened to be. Anchoring on + * `import.meta.url` is what makes every kit CLI runnable from any cwd. + */ + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Resolve the consumer repo root from this module's own location. The kit + * lives exactly two levels below the root (`/scripts/socket-release/`). + */ +export function resolveRepoRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') +} + +/** + * The consumer repo root. Computed once at module load; every kit CLI reads + * paths relative to this instead of the process cwd. + */ +export const REPO_ROOT = resolveRepoRoot() diff --git a/release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts b/release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts new file mode 100644 index 00000000..26bf9adc --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts @@ -0,0 +1,187 @@ +/** + * @file Pure formula rendering/parsing/planning for the binary-download + * Homebrew model (no bottles, no source build): one `Formula/.rb` + * whose four platform blocks each pin an exact + * `releases/download/v/` URL (release-pins-are-canonical — + * never `latest`) and the sha256 the release's own checksums.txt vouched + * for. `parseFormula` returns undefined on anything it cannot read — + * callers treat that as replace-whole-file, never a crash — and + * `planFormulaBump` answers create/update/unchanged so an identical bump + * is a structural no-op. + */ + +export const FORMULA_PLATFORMS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', +] as const + +export type FormulaPlatform = (typeof FORMULA_PLATFORMS)[number] + +export interface FormulaSpec { + className: string + desc: string + homepage: string + license: string + name: string + platforms: Record +} + +/** + * Render the exact managed formula. Byte-stable: same spec, same bytes. + */ +export function renderFormula(spec: FormulaSpec): string { + const p = spec.platforms + return `# Managed by socket-release-kit (scripts/socket-release/brew-publish.mts). +# Do not hand-edit: the next formula bump rewrites this file from the +# release's own checksums.txt. +class ${spec.className} < Formula + desc "${spec.desc}" + homepage "${spec.homepage}" + version "${versionFromUrl(p['darwin-arm64'].url) ?? ''}" + license "${spec.license}" + + on_macos do + on_arm do + url "${p['darwin-arm64'].url}" + sha256 "${p['darwin-arm64'].sha256}" + end + on_intel do + url "${p['darwin-x64'].url}" + sha256 "${p['darwin-x64'].sha256}" + end + end + + on_linux do + on_arm do + url "${p['linux-arm64'].url}" + sha256 "${p['linux-arm64'].sha256}" + end + on_intel do + url "${p['linux-x64'].url}" + sha256 "${p['linux-x64'].sha256}" + end + end + + def install + bin.install "${spec.name}" + end + + test do + assert_match version.to_s, shell_output("#{bin}/${spec.name} --version") + end +end +` +} + +/** + * The `v` segment of an exact release-download URL. + */ +export function versionFromUrl(url: string): string | undefined { + const m = /\/releases\/download\/v([^/]+)\//.exec(url) + return m?.[1] +} + +export interface ParsedFormula { + className: string | undefined + name: string | undefined + platforms: Partial> + version: string | undefined +} + +/** + * Parse a managed (or foreign) formula. Returns undefined when the file + * cannot be read as a formula at all — the caller treats that as + * replace-whole-file, never a crash. + */ +export function parseFormula(raw: string): ParsedFormula | undefined { + const cls = /class\s+([A-Za-z0-9]+)\s*<\s*Formula/.exec(raw) + if (!cls) { + return undefined + } + const version = /^\s*version\s+"([^"]+)"/m.exec(raw)?.[1] + const name = /bin\.install\s+"([^"]+)"/.exec(raw)?.[1] + const platforms: ParsedFormula['platforms'] = {} + const os: Array<['on_macos' | 'on_linux', 'darwin' | 'linux']> = [ + ['on_macos', 'darwin'], + ['on_linux', 'linux'], + ] + for (let i = 0, { length } = os; i < length; i += 1) { + const [marker, prefix] = os[i]! + const blockStart = raw.indexOf(marker) + if (blockStart === -1) { + continue + } + const nextOs = os[1 - i]![0] + const blockEnd = + raw.indexOf(nextOs, blockStart + 1) === -1 + ? raw.length + : raw.indexOf(nextOs, blockStart + 1) + const block = + blockEnd > blockStart + ? raw.slice(blockStart, blockEnd) + : raw.slice(blockStart) + const arch: Array<['on_arm' | 'on_intel', 'arm64' | 'x64']> = [ + ['on_arm', 'arm64'], + ['on_intel', 'x64'], + ] + for (let a = 0, { length: al } = arch; a < al; a += 1) { + const [archMarker, archName] = arch[a]! + const archStart = block.indexOf(archMarker) + if (archStart === -1) { + continue + } + const other = arch[1 - a]![0] + const otherAt = block.indexOf(other, archStart + 1) + const archBlock = block.slice( + archStart, + otherAt === -1 ? undefined : otherAt, + ) + const url = /url\s+"([^"]+)"/.exec(archBlock)?.[1] + const sha256 = /sha256\s+"([0-9a-f]{64})"/.exec(archBlock)?.[1] + if (url && sha256) { + platforms[`${prefix}-${archName}` as FormulaPlatform] = { sha256, url } + } + } + } + return { className: cls[1], name, platforms, version } +} + +export interface FormulaBumpPlan { + action: 'create' | 'unchanged' | 'update' + rendered: string +} + +/** + * Plan the bump: no current file → create; identical version + all four + * url/sha256 pairs → unchanged; anything else (including an unparseable + * current file) → update, replace-whole-file. + */ +export function planFormulaBump( + current: string | undefined, + desired: FormulaSpec, +): FormulaBumpPlan { + const rendered = renderFormula(desired) + if (current === undefined) { + return { action: 'create', rendered } + } + if (current === rendered) { + return { action: 'unchanged', rendered } + } + const parsed = parseFormula(current) + if (parsed) { + const desiredVersion = versionFromUrl(desired.platforms['darwin-arm64'].url) + const samePlatforms = FORMULA_PLATFORMS.every(p => { + const cur = parsed.platforms[p] + const want = desired.platforms[p] + return ( + cur !== undefined && cur.url === want.url && cur.sha256 === want.sha256 + ) + }) + if (parsed.version === desiredVersion && samePlatforms) { + return { action: 'unchanged', rendered } + } + } + return { action: 'update', rendered } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts b/release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts new file mode 100644 index 00000000..da22f4cb --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts @@ -0,0 +1,111 @@ +/** + * @file Pure brew-tier helpers: tap-slug normalization, formula path/class + * naming, asset-name templating, and the checksums.txt parser. The sha256 + * AUTHORITY is the release's own `checksums.txt` — the brew tools never + * hash assets themselves — so this parser accepts BOTH grammars that + * manifest appears in: plain ` ` lines (shasum, the + * github-release producer) and the kit release tail's prefixed + * `sha256: ` lines. No I/O anywhere in this module. + */ + +/** + * Normalize a tap reference: `SocketDev/socket` and + * `SocketDev/homebrew-socket` both mean the repo + * `SocketDev/homebrew-socket` with brew slug `SocketDev/socket`. + */ +export function normalizeTap(input: string): { repo: string; slug: string } { + const m = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/.exec(input.trim()) + if (!m) { + throw new Error( + [ + `Unrecognized tap "${input}".`, + ' Where: .config/socket-release.json brew.tap (or --tap)', + ` Saw: ${input}`, + ' Wanted: / in either form', + ' Fix: use the brew slug form (SocketDev/socket) or the repo form (SocketDev/homebrew-socket).', + ].join('\n'), + ) + } + const owner = m[1]! + const name = m[2]! + const bare = name.startsWith('homebrew-') + ? name.slice('homebrew-'.length) + : name + return { repo: `${owner}/homebrew-${bare}`, slug: `${owner}/${bare}` } +} + +/** + * The unsharded formula path inside the tap repo. + */ +export function formulaPath(name: string): string { + return `Formula/${name}.rb` +} + +/** + * The Ruby class name Homebrew derives from a formula name: split on + * `[-_.]`, capitalize each token, join. A digit-leading token throws — + * Homebrew class names cannot start with a digit. + */ +export function formulaClassName(name: string): string { + const tokens = name.split(/[-_.]/).filter(t => t.length > 0) + if (tokens.length === 0) { + throw new Error( + `formula name "${name}" has no tokens to build a class name from`, + ) + } + if (/^\d/.test(tokens[0]!)) { + throw new Error('Homebrew class names cannot start with a digit') + } + return tokens.map(t => `${t[0]!.toUpperCase()}${t.slice(1)}`).join('') +} + +/** + * Expand the asset-name template for every triplet. Placeholders: ``, + * ``, ``. + */ +export function assetNamesForTriplets( + name: string, + version: string, + template: string, + triplets: readonly string[], +): Array<{ asset: string; triplet: string }> { + return triplets.map(triplet => ({ + asset: template + .replaceAll('', name) + .replaceAll('', triplet) + .replaceAll('', version), + triplet, + })) +} + +const PLAIN_LINE = /^([0-9a-f]{64})\s+(\S+)$/ +const PREFIXED_LINE = /^sha256: ([0-9a-f]{64})\s+(\S+)$/ + +/** + * Parse a release `checksums.txt` into filename → sha256-hex. Accepts BOTH + * grammars (plain shasum lines and the kit's `sha256:`-prefixed lines); + * every other line (sha1:/sha512-base64:/blank/comment) is ignored. A + * duplicate filename with a DIFFERING hex throws — a self-contradictory + * manifest must never pick a winner silently. + */ +export function parseChecksumsTxt(text: string): Map { + const map = new Map() + const lines = text.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]!.trim() + const m = PLAIN_LINE.exec(line) ?? PREFIXED_LINE.exec(line) + if (!m) { + continue + } + const hex = m[1]! + const file = m[2]! + const existing = map.get(file) + if (existing !== undefined && existing !== hex) { + throw new Error( + `checksums.txt names ${file} twice with differing sha256 values (${existing} vs ${hex}) — refusing a self-contradictory manifest.`, + ) + } + map.set(file, hex) + } + return map +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/brew/tap.mts b/release-kit/payload/scripts/socket-release/publish-infra/brew/tap.mts new file mode 100644 index 00000000..47764c99 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/brew/tap.mts @@ -0,0 +1,223 @@ +/** + * @file Tap-repo effects behind `BrewSeams`: read the current formula off + * the tap via the GitHub contents API, commit the bumped formula with a + * GitHub-signed API commit (no GPG) DIRECT to the tap's default branch — + * never a PR (the version-bump-PR shape is guard-blocked fleet-wide) — + * and re-read + parse the committed bytes so success is the registry's + * answer, never the click. Every function takes the seams so tests drive + * fakes; `resolveBrewSeams()` returns the real `gh`/token-backed + * implementations. + */ + +import process from 'node:process' + +import { commitViaGithubApi } from '../../lib/commit-via-github-api.mts' +import { runCapture } from '../shared.mts' +import { parseFormula } from './formula.mts' +import type { ParsedFormula } from './formula.mts' + +export interface BrewReleaseView { + assets: string[] + exists: boolean + isDraft: boolean +} + +export interface BrewSeams { + commitFile(config: { + content: string + message: string + path: string + repo: string + }): Promise + downloadChecksums(tag: string, repo: string): Promise + ghApiJson(path: string): Promise<{ body: unknown; code: number }> + ghReleaseView(tag: string, repo: string): Promise + readTapFile( + repo: string, + path: string, + ): Promise<{ content: string; sha: string } | undefined> +} + +/** + * Read + parse the tap's current formula. 404 → undefined (create); + * unparseable content still returns the raw bytes (the planner treats it as + * replace-whole-file). + */ +export async function readTapFormula( + seams: BrewSeams, + repo: string, + path: string, +): Promise<{ parsed: ParsedFormula | undefined; raw: string } | undefined> { + const file = await seams.readTapFile(repo, path) + if (file === undefined) { + return undefined + } + return { parsed: parseFormula(file.content), raw: file.content } +} + +/** + * Commit the bumped formula direct to the tap default branch, then re-read: + * the committed bytes must equal what was sent, or the caller reports + * saved-state unproven. + */ +export async function commitFormula( + seams: BrewSeams, + config: { + content: string + formulaName: string + path: string + repo: string + version: string + }, +): Promise<{ verified: boolean }> { + const cfg = { __proto__: null, ...config } as typeof config + await seams.commitFile({ + content: cfg.content, + message: `chore: bump ${cfg.formulaName} to ${cfg.version}`, + path: cfg.path, + repo: cfg.repo, + }) + const reread = await seams.readTapFile(cfg.repo, cfg.path) + return { verified: reread !== undefined && reread.content === cfg.content } +} + +/** + * The real seams: `gh api` for reads (ambient gh auth), the GitHub-signed + * API commit for the write (GH_TOKEN in CI — minted by the co-located + * socket-release-app-token composite — or ambient `gh auth token` locally). + */ +export function resolveBrewSeams(cwd: string): BrewSeams { + async function ghJson( + apiPath: string, + ): Promise<{ body: unknown; code: number }> { + const { code, stdout } = await runCapture('gh', ['api', apiPath], cwd) + let body: unknown + try { + body = JSON.parse(stdout) + } catch { + body = undefined + } + return { body, code } + } + async function token(): Promise { + const envToken = process.env['GH_TOKEN'] || process.env['GITHUB_TOKEN'] + if (envToken) { + return envToken + } + const { code, stdout } = await runCapture('gh', ['auth', 'token'], cwd) + if (code !== 0 || !stdout.trim()) { + throw new Error( + 'no GitHub token: set GH_TOKEN (CI mints one via the socket-release-app-token composite) or run `gh auth login`.', + ) + } + return stdout.trim() + } + return { + commitFile: async cfg => { + const ghToken = await token() + const [repoRead, refRead] = await Promise.all([ + ghJson(`repos/${cfg.repo}`), + ghJson(`repos/${cfg.repo}/git/ref/heads/main`).then(async r => + r.code === 0 + ? r + : await ghJson(`repos/${cfg.repo}/git/ref/heads/master`), + ), + ]) + const defaultBranch = + (repoRead.body as { default_branch?: string | undefined } | undefined) + ?.default_branch ?? 'main' + const ref = + refRead.code === 0 + ? refRead + : await ghJson(`repos/${cfg.repo}/git/ref/heads/${defaultBranch}`) + const parentSha = ( + ref.body as + | { object?: { sha?: string | undefined } | undefined } + | undefined + )?.object?.sha + if (!parentSha) { + throw new Error(`could not resolve ${cfg.repo}'s default branch head.`) + } + const commitRead = await ghJson( + `repos/${cfg.repo}/git/commits/${parentSha}`, + ) + const baseTreeSha = ( + commitRead.body as + | { tree?: { sha?: string | undefined } | undefined } + | undefined + )?.tree?.sha + if (!baseTreeSha) { + throw new Error(`could not resolve ${cfg.repo}'s HEAD tree.`) + } + await commitViaGithubApi({ + baseTreeSha, + branch: defaultBranch, + files: [{ content: cfg.content, path: cfg.path }], + message: cfg.message, + parentSha, + repo: cfg.repo, + token: ghToken, + }) + }, + downloadChecksums: async (tag, repo) => { + const { code, stdout } = await runCapture( + 'gh', + [ + 'release', + 'download', + tag, + '--repo', + repo, + '--pattern', + 'checksums.txt', + '--output', + '-', + ], + cwd, + ) + return code === 0 ? stdout : undefined + }, + ghApiJson: ghJson, + ghReleaseView: async (tag, repo) => { + const { code, stdout } = await runCapture( + 'gh', + ['release', 'view', tag, '--repo', repo, '--json', 'isDraft,assets'], + cwd, + ) + if (code !== 0) { + return { assets: [], exists: false, isDraft: false } + } + try { + const parsed = JSON.parse(stdout) as { + assets?: Array<{ name?: string | undefined }> | undefined + isDraft?: boolean | undefined + } + return { + assets: (parsed.assets ?? []) + .map(a => a.name) + .filter((n): n is string => typeof n === 'string'), + exists: true, + isDraft: parsed.isDraft === true, + } + } catch { + return { assets: [], exists: false, isDraft: false } + } + }, + readTapFile: async (repo, filePath) => { + const { body, code } = await ghJson(`repos/${repo}/contents/${filePath}`) + if (code !== 0) { + return undefined + } + const doc = body as + | { content?: string | undefined; sha?: string | undefined } + | undefined + if (!doc?.content) { + return undefined + } + return { + content: Buffer.from(doc.content, 'base64').toString('utf8'), + sha: doc.sha ?? '', + } + }, + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/approve.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/approve.mts new file mode 100644 index 00000000..e5d3c703 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/approve.mts @@ -0,0 +1,188 @@ +/** + * @file `--approve` mode for cargo: the human-gated, PERMANENT promotion of a + * verified crate to public on crates.io. Simpler than the npm approve flow — + * crates.io has no staging list to enumerate. A pre-approve integrity gate + * re-packs the `.crate` and asserts its sha256 matches the staged digest + * (env CARGO_STAGED_SHA256, or a `.sha256` sidecar) before a + * confirmation gate and `cargo publish --locked`. The script handles no + * tokens: `cargo publish` reads the operator's `cargo login` credentials + * locally, or rides OIDC Trusted Publishing in CI. + */ + +import { existsSync, readFileSync } from 'node:fs' +import process from 'node:process' + +import { confirm } from '@socketsecurity/lib/stdio/prompts' + +import { withPinnedReadme } from '../pin-readme.mts' +import { releaseBehindLiveGate } from '../release.mts' +import { logger, rootPath, runInherit } from '../shared.mts' +import { isAlreadyPublished } from './registry.mts' +import { cratePath, crateSha256, readCargoPackage } from './shared.mts' +import { packCrate, packCrateAssets } from './staged.mts' + +/** + * Resolve the staged `.crate` sha256 recorded at stage time, if discoverable: + * the CARGO_STAGED_SHA256 env the CI stage step exports (preferred), else the + * `.sha256` sidecar runStaged writes. Returns the lowercased hex digest, + * or undefined when neither exists (a first/local approve with nothing to + * compare). The sidecar format is ` ` (mirrors `shasum`). + */ +export function resolveStagedSha256( + name: string, + version: string, +): string | undefined { + const fromEnv = process.env['CARGO_STAGED_SHA256'] + if (typeof fromEnv === 'string' && fromEnv.trim()) { + return fromEnv.trim().toLowerCase() + } + const sidecar = `${cratePath(name, version)}.sha256` + if (existsSync(sidecar)) { + const first = readFileSync(sidecar, 'utf8').trim().split(/\s+/)[0] + if (first) { + return first.toLowerCase() + } + } + return undefined +} + +/** + * `--approve` mode: promote a verified crate to public on crates.io. Refuses an + * already-published version. Runs the pre-approve integrity gate (re-pack + a + * sha256 compare against the staged digest; FAIL LOUD on mismatch, proceed with + * a note when there's no prior digest), then — unless `yes` — a confirmation + * gate for the PERMANENT publish, then `cargo publish --locked`. On success, + * creates the git tag + GitHub release with the `.crate` + checksums as assets. + * `otpFromFlag` is accepted for signature parity with the npm tier but is a + * no-op for crates.io (no OTP on publish). + */ +export async function runApprove(config: { + dryRun: boolean + otpFromFlag?: string | undefined + packageName?: string | undefined + yes: boolean +}): Promise { + const cfg = { __proto__: null, ...config } as { + dryRun: boolean + otpFromFlag?: string | undefined + packageName?: string | undefined + yes: boolean + } + const pkg = await readCargoPackage(cfg.packageName) + logger.log( + `Approving publish of ${pkg.name}@${pkg.version}` + + `${cfg.dryRun ? ' [dry-run]' : ''}`, + ) + + if (await isAlreadyPublished(pkg.name, pkg.version)) { + logger.fail( + `${pkg.name}@${pkg.version} is already published to crates.io. Versions ` + + 'are PERMANENT (yank-only). Bump the version and try again.', + ) + process.exitCode = 1 + return + } + + // The README asset pin must be active for BOTH the integrity re-pack and the + // publish: the staged digest was computed with the pin, so an unpinned re-pack + // would falsely diverge, and the published `.crate` must carry the pinned + // README. Restored after (try/finally in withPinnedReadme). + await withPinnedReadme( + { repository: pkg.repository, rootPath, version: pkg.version }, + async pinned => { + // Pre-approve integrity gate: re-pack the .crate and compare its sha256 to + // the staged digest. Never approve a divergent artifact. + const crate = await packCrate(pkg.name, pkg.version, { + allowDirty: pinned, + locked: true, + }) + if (!crate) { + logger.fail( + `[cargo] could not pack ${pkg.name}@${pkg.version} for the ` + + 'pre-approve integrity gate. Fix the pack, then re-run --approve.', + ) + process.exitCode = 1 + return + } + const localSha = crateSha256(crate) + const stagedSha = resolveStagedSha256(pkg.name, pkg.version) + if (stagedSha === undefined) { + logger.log( + `[cargo] no staged sha256 to compare (first/local approve); ` + + `proceeding with the local pack digest ${localSha}.`, + ) + } else if (stagedSha !== localSha) { + logger.fail( + `Pre-approve verify FAILED for ${pkg.name}@${pkg.version}.\n` + + ` staged: ${stagedSha}\n` + + ` local: ${localSha}\n` + + ' Fix: re-stage the crate; never approve a divergent artifact.', + ) + process.exitCode = 1 + return + } else { + logger.success( + `Pre-approve verify: local pack sha256 matches the staged digest ` + + `(${localSha}).`, + ) + } + + if (cfg.otpFromFlag !== undefined) { + logger.log( + '[cargo] --otp is a no-op for crates.io (no OTP on publish); ' + + 'ignoring.', + ) + } + + if (cfg.dryRun) { + logger.success( + `Dry-run complete for ${pkg.name}@${pkg.version}. Re-run without ` + + '--dry-run to publish (PERMANENT).', + ) + return + } + + // Confirmation gate — crates.io publishing is PERMANENT (yank-only). + if (!cfg.yes) { + const confirmed = (await confirm({ + default: false, + message: + `Publish ${pkg.name}@${pkg.version} to crates.io? This is ` + + 'PERMANENT (a version can only be yanked, never overwritten).', + })) as boolean + if (!confirmed) { + logger.log('Not confirmed; nothing published.') + return + } + } + + // In CI this rides OIDC Trusted Publishing; locally it uses the + // operator's `cargo login` token. Either way the script handles no tokens + // — cargo reads them. + const args = ['publish', '--locked'] + if (pinned) { + args.push('--allow-dirty') + } + const code = await runInherit('cargo', args, rootPath) + if (code !== 0) { + logger.fail(`cargo publish exited ${code}`) + process.exitCode = code + return + } + logger.success(`Published ${pkg.name}@${pkg.version} to crates.io.`) + // The tag + immutable release are the LAST markers: cargo-publish + // success alone is not enough — cut them only once the version is + // actually resolvable in the crates.io index. + const released = await releaseBehindLiveGate({ + isLive: () => isAlreadyPublished(pkg.name, pkg.version), + packAssets: () => + packCrateAssets(pkg.name, pkg.version, { allowDirty: pinned }), + pkg: { name: pkg.name, version: pkg.version }, + registry: 'crates.io', + }) + if (!released) { + process.exitCode = 1 + } + }, + ) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/placeholder.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/placeholder.mts new file mode 100644 index 00000000..19f91449 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/placeholder.mts @@ -0,0 +1,402 @@ +#!/usr/bin/env node +/** + * @file One-time crates.io name-reservation bootstrap. crates.io trusted + * publishing (OIDC) can only be CONFIGURED for a crate name that ALREADY + * EXISTS on the registry — but a brand-new crate has no name to configure the + * trusted publisher against, a chicken-and-egg. This script breaks it: it + * publishes a minimal `0.0.0` reservation (a standalone `Cargo.toml` + a + * one-line `src/lib.rs`, and nothing else) to CLAIM the name, so the OIDC + * trusted publisher can then be wired up on crates.io. Real releases go out via + * CI afterward (verified + attested) — this is the SANCTIONED one-time LOCAL + * publish, the only local-publish carve-out in the cargo flow. + * Each name assembles a fresh STANDALONE crate in a temp dir (outside any + * workspace) containing ONLY a Cargo.toml + src/lib.rs and runs + * `cargo publish --allow-dirty --manifest-path /Cargo.toml` from it. The + * temp dir is not a git repo, so `--allow-dirty` sidesteps cargo's VCS-dirty + * refusal; the build is still verified (no `--no-verify`). crates.io REQUIRES + * `description` + `license`, so the reservation manifest carries both. + * CLI: placeholder [--apply] + * Dry-run by default, prints the plan, publishes nothing; `--apply` performs + * the publish. Per-name isolation: one name failing never aborts the rest, and + * a summary prints at the end. Fail-soft — main() catches, logs, and sets a + * non-zero exit code; it never throws. The script handles no tokens — cargo + * reads `cargo login` / CARGO_REGISTRY_TOKEN / OIDC itself. + * Usage: node scripts/socket-release/publish-infra/cargo/placeholder.mts my-crate\ + * other-crate --apply + */ + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { safeDelete } from '@socketsecurity/lib/fs/safe' + +import { isMainModule } from '../../_shared/is-main-module.mts' +import { logger, runInherit } from '../shared.mts' + +// The reservation version. Deliberately the lowest possible semver so the real +// first release (any 0.0.1+ / 0.1.0) always supersedes it as the latest. +export const PLACEHOLDER_VERSION = '0.0.0' + +// The reservation crate's one-line description. crates.io REQUIRES a +// `description` to publish, so the reservation manifest always carries this. +export const PLACEHOLDER_DESCRIPTION = + 'Placeholder to reserve the name for crates.io trusted publishing. ' + + 'Real releases publish via CI (OIDC).' + +export interface PlaceholderCargoTomlConfig { + // The `description` field (crates.io requires it). + description: string + // The `repository` field, emitted only when provided (crates.io does not + // require it; a standalone reservation for an arbitrary name has no reliable + // repo URL, so it is omitted by default). + repository?: string | undefined +} + +/** + * Shape-check a crates.io token WITHOUT ever printing it. crates.io API + * tokens are single-line ASCII with the `cio` prefix. The incident shape this + * guards: `pbpaste | cargo login` saving chat-copied COMMAND text as the + * token (the operator copied the command after the token, so the clipboard + * held `! pbpaste | cargo login`), then every publish 401ing with a confusing + * "unexpected authentication scheme" server error. Returns a one-line problem + * description, or `undefined` when the token looks plausible. Pure — exported + * for tests. + */ +export function cargoTokenProblem(token: string): string | undefined { + if (token.length === 0) { + return 'is empty' + } + if (token.startsWith('!')) { + return "begins with '!' — a chat-copied shell command, not a token" + } + if (/\s/.test(token)) { + return 'contains whitespace — pasted command text, not a token' + } + if (!token.startsWith('cio')) { + return "lacks the crates.io 'cio' prefix" + } + return undefined +} + +/** + * Resolve the crates.io token the way cargo will: `CARGO_REGISTRY_TOKEN` + * first, else the first `token = "…"` row of `~/.cargo/credentials.toml`. + * Returns `undefined` when neither holds one. Best-effort read-only — an + * unreadable/unparseable credentials file reads as "no token" and cargo then + * reports its own auth error. + */ +export async function resolveCratesToken( + env: NodeJS.ProcessEnv = process.env, + homeDir: string = os.homedir(), +): Promise { + const fromEnv = env['CARGO_REGISTRY_TOKEN'] + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv + } + try { + const raw = await fs.readFile( + path.join(homeDir, '.cargo', 'credentials.toml'), + 'utf8', + ) + // Line-anchored `token = ""` row: optional indent, the key, `=`, + // then a double-quoted TOML string whose body is any run of non-quote, + // non-backslash chars or backslash-escaped pairs (captured as `token`). + return /^\s*token\s*=\s*"(?(?:[^"\\]|\\.)*)"/m.exec(raw)?.groups?.[ + 'token' + ] + } catch { + return undefined + } +} + +export interface PlaceholderArgs { + apply: boolean + names: string[] +} + +export type PlaceholderStatus = 'published' | 'planned' | 'skipped' | 'failed' + +export interface PlaceholderResult { + name: string + status: PlaceholderStatus + detail?: string | undefined +} + +export interface RunPlaceholderOptions { + // The publish executor. Defaults to + // `cargo publish --allow-dirty --manifest-path /Cargo.toml` run from the + // temp dir; injected in tests so no real registry call happens. + publishExec?: ((dir: string) => Promise) | undefined + // Temp-dir assembler; injectable so plan tests can avoid touching disk. + assembleDir?: ((name: string) => Promise) | undefined + // Temp-dir cleanup; injectable for the same reason. + removeDir?: ((dir: string) => Promise) | undefined +} + +/** + * Build the reservation `Cargo.toml` for `name`. Pure — a `[package]` table + * with the name, `0.0.0`, `edition = "2021"`, the required `description` + + * `license = "MIT"`, and an optional `repository`. crates.io requires + * description + license, so both are always present. + */ +export function buildPlaceholderCargoToml( + name: string, + config: PlaceholderCargoTomlConfig, +): string { + const cfg = { __proto__: null, ...config } as PlaceholderCargoTomlConfig + const lines = [ + '[package]', + `name = "${name}"`, + `version = "${PLACEHOLDER_VERSION}"`, + 'edition = "2021"', + `description = "${cfg.description}"`, + 'license = "MIT"', + ] + if (cfg.repository) { + lines.push(`repository = "${cfg.repository}"`) + } + return `${lines.join('\n')}\n` +} + +/** + * The one-line reservation `src/lib.rs` for `name`: only a `//!` inner doc + * comment. An empty library crate builds instantly (crates.io verifies the + * build on publish), and a `//!` comment keeps the file non-empty + + * self-describing. Pure. Trailing newline so the file is POSIX-clean. + */ +export function buildPlaceholderLibRs(name: string): string { + return ( + `//! Placeholder crate reserving \`${name}\` on crates.io for trusted\n` + + '//! publishing. Real releases publish via CI (OIDC trusted publishing).\n' + ) +} + +/** + * A pragmatic crates.io crate-name gate: length 1–64, characters + * `[a-zA-Z0-9_-]`, must start with a letter. The registry is the final arbiter + * (reserved names, keyword collisions, `-`/`_` equivalence) — this only skips + * OBVIOUSLY invalid names before we bother assembling + publishing them. Pure. + */ +export function isValidCrateName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0 || name.length > 64) { + return false + } + if (name.trim() !== name) { + return false + } + return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name) +} + +/** + * Create a fresh temp dir under `tmpBase` (defaults to the OS temp dir) holding + * EXACTLY the reservation's `Cargo.toml` + `src/lib.rs`, and return its path. + * The caller owns cleanup (see runPlaceholder's finally). `tmpBase` is + * injectable for hermetic tests. + */ +export async function assemblePlaceholderDir( + name: string, + tmpBase: string = os.tmpdir(), +): Promise { + const dir = await fs.mkdtemp(path.join(tmpBase, 'socket-cargo-placeholder-')) + await fs.writeFile( + path.join(dir, 'Cargo.toml'), + buildPlaceholderCargoToml(name, { description: PLACEHOLDER_DESCRIPTION }), + 'utf8', + ) + const srcDir = path.join(dir, 'src') + await fs.mkdir(srcDir) + await fs.writeFile( + path.join(srcDir, 'lib.rs'), + buildPlaceholderLibRs(name), + 'utf8', + ) + return dir +} + +// Default publish executor: the sanctioned one-time LOCAL publish. Runs +// `cargo publish --allow-dirty --manifest-path /Cargo.toml` from the +// assembled temp dir with inherited stdio so any auth prompt reaches the +// operator's terminal. `--allow-dirty` sidesteps the VCS-dirty refusal (the +// temp dir is not a git repo); the build is still verified (no `--no-verify`). +async function defaultPublishExec(dir: string): Promise { + return await runInherit( + 'cargo', + [ + 'publish', + '--allow-dirty', + '--manifest-path', + path.join(dir, 'Cargo.toml'), + ], + dir, + ) +} + +async function defaultRemoveDir(dir: string): Promise { + await safeDelete(dir) +} + +/** + * One-line human summary of the run: counts by status, tagged with the mode. + * Pure — exported for tests. + */ +export function formatSummary( + results: readonly PlaceholderResult[], + config: { apply: boolean }, +): string { + const cfg = { __proto__: null, ...config } as { apply: boolean } + const count = (status: PlaceholderStatus): number => + results.filter(r => r.status === status).length + return ( + `Placeholder ${cfg.apply ? 'publish' : 'dry-run'} summary: ` + + `${count('published')} published, ${count('planned')} planned, ` + + `${count('skipped')} skipped, ${count('failed')} failed.` + ) +} + +/** + * Reserve each name, isolated. For every name: validate it (invalid → skipped), + * assemble its temp dir, then either PRINT the plan (dry-run) or run the + * publish (`--apply`). A thrown error or non-zero publish exit for one name is + * recorded as `failed` and never aborts the others; every assembled dir is + * cleaned up. Logs a summary and returns the per-name results (for tests + the + * caller's exit-code decision). + */ +export async function runPlaceholder( + args: PlaceholderArgs, + options?: RunPlaceholderOptions | undefined, +): Promise { + const opts = { __proto__: null, ...options } as RunPlaceholderOptions + const assembleDir = opts.assembleDir ?? assemblePlaceholderDir + const publishExec = opts.publishExec ?? defaultPublishExec + const removeDir = opts.removeDir ?? defaultRemoveDir + const { apply, names } = args + + const results: PlaceholderResult[] = [] + for (let i = 0, { length } = names; i < length; i += 1) { + const name = names[i]! + if (!isValidCrateName(name)) { + logger.warn(`Skipping invalid crate name: ${JSON.stringify(name)}`) + results.push({ + name, + status: 'skipped', + detail: 'invalid crate name', + }) + continue + } + try { + // eslint-disable-next-line no-await-in-loop + const dir = await assembleDir(name) + try { + if (!apply) { + logger.log( + `[dry-run] ${name}@${PLACEHOLDER_VERSION} — would run ` + + `\`cargo publish --allow-dirty\` from ${dir} ` + + `(Cargo.toml + src/lib.rs only). Re-run with --apply to publish.`, + ) + results.push({ name, status: 'planned' }) + continue + } + logger.log( + `Publishing reservation ${name}@${PLACEHOLDER_VERSION} to crates.io…`, + ) + // eslint-disable-next-line no-await-in-loop + const code = await publishExec(dir) + if (code === 0) { + logger.success( + `Reserved ${name}@${PLACEHOLDER_VERSION}. Configure the OIDC ` + + `trusted publisher on crates.io, then release via CI.`, + ) + results.push({ name, status: 'published' }) + } else { + logger.fail(`cargo publish exited ${code} for ${name}.`) + results.push({ + name, + status: 'failed', + detail: `cargo publish exited ${code}`, + }) + } + } finally { + // eslint-disable-next-line no-await-in-loop + await removeDir(dir) + } + } catch (e) { + logger.error(`${name}: ${errorMessage(e)}`) + results.push({ name, status: 'failed', detail: errorMessage(e) }) + } + } + + logger.log('') + logger.log(formatSummary(results, { apply })) + return results +} + +/** + * Parse `placeholder [--apply]`. Dry-run is the default (no + * `--apply`). Positional args are crate names. Exits, usage error, on an + * unknown flag, or when no names are given. (crates.io has no per-package + * access flag, so there is no `--access` here.) + */ +export function parseArgs(argv: readonly string[]): PlaceholderArgs { + let apply = false + const names: string[] = [] + for (let i = 0, { length } = argv; i < length; i += 1) { + const arg = argv[i]! + if (arg === '--apply') { + apply = true + } else if (arg.startsWith('-')) { + logger.fail(`Unknown flag: ${arg}`) + process.exit(1) + } else { + names.push(arg) + } + } + if (names.length === 0) { + logger.fail('Usage: placeholder [--apply]') + process.exit(1) + } + return { apply, names } +} + +export async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + // Auth preflight, apply-mode only: catch a malformed saved token BEFORE the + // per-name publish loop turns it into one opaque 401 per name. + if (args.apply) { + const token = await resolveCratesToken() + const problem = + token === undefined + ? 'is missing (no env token, no credentials.toml row)' + : cargoTokenProblem(token) + if (problem !== undefined) { + logger.fail( + `crates.io auth preflight: the token ${problem}. ` + + `Where: CARGO_REGISTRY_TOKEN, else ~/.cargo/credentials.toml. ` + + `Fix: copy the token from crates.io/settings/tokens as the LAST ` + + `thing on the clipboard (copying a command overwrites it), type ` + + `the login command by hand, and pipe: pbpaste | cargo login.`, + ) + process.exitCode = 1 + return + } + } + logger.log( + `crates.io placeholder reservation — ${args.names.length} name(s)` + + `${args.apply ? ' [apply]' : ' [dry-run]'}`, + ) + const results = await runPlaceholder(args) + if (results.some(r => r.status === 'failed')) { + process.exitCode = 1 + } +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the CLI. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/registry.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/registry.mts new file mode 100644 index 00000000..c564a79b --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/registry.mts @@ -0,0 +1,199 @@ +/** + * @file Crates.io registry reads for the cargo-publish flow: the + * already-published probe, the latest-published-version lookup, and the + * crate-name availability status. Reads need no auth. crates.io REQUIRES a + * descriptive `User-Agent` header (it 403s requests without one), so every + * GET carries one. The cargo analog of npm/registry.mts. + */ + +import { logger, rootPath, runCapture } from '../shared.mts' + +import type { RegistryLatestRead } from '../../lib/release-anchor.mts' + +const CRATES_IO_API = 'https://crates.io/api/v1' + +// crates.io rejects requests without a descriptive User-Agent (HTTP 403); this +// identifies the fleet publish tooling per their crawler policy. +const USER_AGENT_HEADER = + 'User-Agent: socket-release-kit-publish (github.com/SocketDev/sauce)' + +/** + * GET a crates.io API path with the required User-Agent, a 20s timeout, and no + * fail-on-http-error (so a 404 body is still returned for shape inspection). + * Returns the raw stdout + curl exit code. + */ +async function cratesIoGet( + apiPath: string, +): Promise<{ code: number; stdout: string }> { + return await runCapture( + 'curl', + ['-sS', '-m', '20', '-H', USER_AGENT_HEADER, `${CRATES_IO_API}${apiPath}`], + rootPath, + ) +} + +/** + * Whether `name@version` already exists on crates.io. crates.io NEVER allows + * re-publishing a version, a version can only be yanked, never overwritten, so + * this must be surfaced before any publish attempt. HTTP 200 returns a + * `version` object; a 404 returns an `errors` array. Network / parse failure is + * treated as "unknown" ⇒ false (mirrors npm's isAlreadyPublished tolerance) but + * logs a warning so a false-green is visible. + */ +export async function isAlreadyPublished( + name: string, + version: string, +): Promise { + const { code, stdout } = await cratesIoGet(`/crates/${name}/${version}`) + if (code !== 0) { + logger.warn( + `[cargo] crates.io check for ${name}@${version} failed (curl exit ` + + `${code}); treating as not-published.`, + ) + return false + } + try { + const parsed = JSON.parse(stdout) as { + version?: { num?: unknown | undefined } | undefined + } + return !!parsed.version && typeof parsed.version === 'object' + } catch { + logger.warn( + `[cargo] could not parse crates.io response for ${name}@${version}; ` + + 'treating as not-published.', + ) + return false + } +} + +/** + * Classify a crates.io `/crates/{name}` response into a `RegistryLatestRead`: + * a crate object carries the latest version (`crate.max_stable_version` + * preferred, else `crate.newest_version`); an `errors` body is crates.io + * answering "never published" — a readable ledger; a failed curl or an + * unrecognized body means the registry could not be consulted, which is NEVER + * treated as unpublished. Pure — exported for tests. + */ +export function classifyCrateLatest( + code: number, + stdout: string, +): RegistryLatestRead { + if (code !== 0) { + return { reachable: false } + } + let parsed: { + crate?: + | { + max_stable_version?: unknown | undefined + newest_version?: unknown | undefined + } + | undefined + errors?: unknown | undefined + } + try { + parsed = JSON.parse(stdout) as typeof parsed + } catch { + return { reachable: false } + } + const crate = parsed.crate + if (crate && typeof crate === 'object') { + if ( + typeof crate.max_stable_version === 'string' && + crate.max_stable_version + ) { + return { latest: crate.max_stable_version, reachable: true } + } + if (typeof crate.newest_version === 'string' && crate.newest_version) { + return { latest: crate.newest_version, reachable: true } + } + return { latest: undefined, reachable: true } + } + if (parsed.errors) { + return { latest: undefined, reachable: true } + } + return { reachable: false } +} + +/** + * The latest published version of `name` on crates.io, distinguishing "the + * registry answered: never published" from "crates.io could not be consulted" + * (see `classifyCrateLatest`). The changelog anchor derivation hard-stops on + * `reachable: false`: offline, the released base cannot be confirmed and a + * stale local tag would silently widen the range. + */ +export async function fetchPublishedVersionChecked( + name: string, +): Promise { + const { code, stdout } = await cratesIoGet(`/crates/${name}`) + return classifyCrateLatest(code, stdout) +} + +/** + * The latest published version of `name` on crates.io: + * `crate.max_stable_version` (preferred) or `crate.newest_version`. Returns + * undefined when the crate is unpublished or the lookup failed — the tolerant + * twin of `fetchPublishedVersionChecked` for callers that only display or + * compare a best-effort latest. + */ +export async function fetchPublishedVersion( + name: string, +): Promise { + const read = await fetchPublishedVersionChecked(name) + return read.reachable ? read.latest : undefined +} + +/** + * The crates.io publish timestamp for `name@version` — `version.created_at` + * from `/crates/{name}/{version}`, an ISO 8601 string — or undefined when the + * version is unknown or the lookup failed. crates.io's publish ledger is + * PERMANENT, a version can be yanked but its record remains, so this is the + * last anchor link for a release whose tag and bump commit are both gone. + */ +export async function fetchPublishedAt( + name: string, + version: string, +): Promise { + const { code, stdout } = await cratesIoGet(`/crates/${name}/${version}`) + if (code !== 0) { + return undefined + } + try { + const parsed = JSON.parse(stdout) as { + version?: { created_at?: unknown | undefined } | undefined + } + const createdAt = parsed.version?.created_at + return typeof createdAt === 'string' && createdAt ? createdAt : undefined + } catch { + return undefined + } +} + +/** + * Whether the crate `name` is `'available'` (404 — free to claim), + * `'published'` (200 — already on crates.io, presumably ours), or `'unknown'` + * (network / parse failure). Used to warn before a first publish that the name + * is free or ours. + */ +export async function crateNameStatus( + name: string, +): Promise<'available' | 'published' | 'unknown'> { + const { code, stdout } = await cratesIoGet(`/crates/${name}`) + if (code !== 0) { + return 'unknown' + } + try { + const parsed = JSON.parse(stdout) as { + crate?: unknown | undefined + errors?: unknown | undefined + } + if (parsed.crate && typeof parsed.crate === 'object') { + return 'published' + } + if (parsed.errors) { + return 'available' + } + return 'unknown' + } catch { + return 'unknown' + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/shared.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/shared.mts new file mode 100644 index 00000000..d120860b --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/shared.mts @@ -0,0 +1,176 @@ +/** + * @file Cargo (crates.io) metadata resolution for the cargo-publish flow: read + * the publishable package's name/version/repository/manifest from + * `cargo metadata`, resolve the packaged `.crate` artifact path, and hash the + * packaged bytes. The cargo analog of npm/shared.mts's package.json reader; + * the registry-agnostic spawn/git/JSON helpers live in ../shared.mts. + */ + +import crypto from 'node:crypto' +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { logApproveHandoff, rootPath, runCapture } from '../shared.mts' + +// The approve leg an operator runs after a cargo staging run. `cargo:publish` +// is the channel-enforced script for every crates-registry member. +export const CARGO_APPROVE_COMMAND = 'pnpm run cargo:publish -- --approve' + +// Who owns the promotion, stated once so nobody reads cargo/approve.mts to find +// out. It runs `cargo publish --locked` itself, then cuts the tag + release. +export const CARGO_APPROVE_OWNERSHIP = + 'That command performs the crates.io publish itself: it runs `cargo publish ' + + '--locked` from your machine, then creates the git tag and GitHub release ' + + 'once the version resolves as live. Publishing is PERMANENT — a version can ' + + 'only be yanked, never overwritten.' + +/** + * Print the staged-to-approve handoff for crates.io. Called ONCE at the end of + * a staging run so the actionable command is the last thing on screen. + */ +export function logCargoApproveHandoff(): void { + logApproveHandoff(CARGO_APPROVE_COMMAND, CARGO_APPROVE_OWNERSHIP) +} + +export interface CargoPackage { + name: string + version: string + repository?: string | undefined + manifestPath: string +} + +// A raw `cargo metadata` package entry, projected to the fields we read. +interface RawCargoPackage { + manifest_path?: unknown | undefined + name?: unknown | undefined + publish?: unknown | undefined + repository?: unknown | undefined + version?: unknown | undefined +} + +/** + * Whether a `cargo metadata` `publish` value means the package may be + * published. cargo emits `null` for the Cargo.toml default (publishable + * anywhere), `[]` for `publish = false`, never publish, and a non-empty array + * (e.g. `["crates-io"]`) for an allowlist, still publishable. Only an explicit + * empty array opts out — so we treat null/undefined and any non-empty allowlist + * as publishable. + */ +export function isPublishable(publish: unknown): boolean { + if (publish === null || publish === undefined) { + return true + } + return Array.isArray(publish) && publish.length > 0 +} + +/** + * Every publishable package in the workspace at `cwd`, projected to + * `CargoPackage`, from `cargo metadata --format-version 1 --no-deps`. Defaults + * to this checkout; a caller configuring another repo (the trusted-publisher + * CLI's `--path `) passes that repo's root so crate discovery reads the + * workspace it is actually targeting. Returns `[]` when nothing is publishable + * (every package sets `publish = false`). Throws LOUD when `cargo metadata` + * fails, its JSON can't be parsed, or a publishable package is missing a field. + * The version-discipline checks iterate every entry (a workspace can publish + * several crates); the publish path (`readCargoPackage`) selects one. + */ +export async function readPublishableCargoPackages( + cwd: string = rootPath, +): Promise { + const { code, stdout } = await runCapture( + 'cargo', + ['metadata', '--format-version', '1', '--no-deps'], + cwd, + ) + if (code !== 0) { + throw new Error( + `[cargo] \`cargo metadata\` exited ${code} — is this a cargo workspace?`, + ) + } + let parsed: { packages?: RawCargoPackage[] | undefined } + try { + parsed = JSON.parse(stdout) as { packages?: RawCargoPackage[] | undefined } + } catch { + throw new Error('[cargo] could not parse `cargo metadata` JSON output.') + } + const packages = Array.isArray(parsed.packages) ? parsed.packages : [] + const out: CargoPackage[] = [] + for (let i = 0, { length } = packages; i < length; i += 1) { + const p = packages[i]! + if (!isPublishable(p.publish)) { + continue + } + const name = typeof p.name === 'string' ? p.name : undefined + const version = typeof p.version === 'string' ? p.version : undefined + const manifestPath = + typeof p.manifest_path === 'string' ? p.manifest_path : undefined + if (!name || !version || !manifestPath) { + throw new Error( + '[cargo] a publishable package is missing name/version/manifest_path ' + + 'in `cargo metadata` output.', + ) + } + out.push({ + manifestPath, + name, + version, + ...(typeof p.repository === 'string' && p.repository + ? { repository: p.repository } + : {}), + }) + } + return out +} + +/** + * Resolve the single publishable package. Fails LOUD when nothing is + * publishable (every package sets `publish = false`) or when more than one is + * (ambiguous — pass `packageName`, wired to the `--package` selector, to + * disambiguate). Returns the package's name/version/repository/manifest_path. + */ +export async function readCargoPackage( + packageName?: string | undefined, +): Promise { + const publishable = await readPublishableCargoPackages() + if (publishable.length === 0) { + throw new Error( + '[cargo] no publishable package found (every package sets ' + + '`publish = false`). Nothing to publish.', + ) + } + const names = publishable.map(p => p.name).join(', ') + if (packageName) { + const match = publishable.find(p => p.name === packageName) + if (!match) { + throw new Error( + `[cargo] --package ${packageName} is not a publishable package. ` + + `Publishable: ${names}.`, + ) + } + return match + } + if (publishable.length > 1) { + throw new Error( + `[cargo] ${publishable.length} publishable packages (${names}); ` + + 'ambiguous. Pass --package to select one.', + ) + } + return publishable[0]! +} + +/** + * The packaged artifact path `cargo package` writes: + * `/target/package/-.crate`. + */ +export function cratePath(name: string, version: string): string { + return path.join(rootPath, 'target', 'package', `${name}-${version}.crate`) +} + +/** + * Sha256 hex of the `.crate` bytes at `filePath` (node:crypto). The staged + * digest the `--approve` integrity gate compares against. + */ +export function crateSha256(filePath: string): string { + const bytes = readFileSync(filePath) + return crypto.createHash('sha256').update(bytes).digest('hex') +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/staged.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/staged.mts new file mode 100644 index 00000000..1aaf029a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/staged.mts @@ -0,0 +1,262 @@ +/** + * @file `--staged` / `--direct` publish modes for cargo, plus the crate-pack + * helpers the release-asset wiring and the `--approve` integrity gate reuse. + * crates.io has NO staging endpoint, so "staged" here means: verify the crate + * builds from its packaged sources (`cargo publish --dry-run`), produce the + * `.crate` artifact, and record its sha256 as the digest a downstream + * `--approve` gate compares against — nothing is uploaded. Publishing is + * PERMANENT, a version can only be yanked, never overwritten. The cargo + * analog of npm/staged.mts. + */ + +import crypto from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { withPinnedReadme } from '../pin-readme.mts' +import { releaseBehindLiveGate } from '../release.mts' +import { logger, rootPath, runCapture, runInherit } from '../shared.mts' +import { writeThroughMirrorLock } from '../../_shared/mirror-lock.mts' +import { isAlreadyPublished } from './registry.mts' +import { + cratePath, + crateSha256, + logCargoApproveHandoff, + readCargoPackage, +} from './shared.mts' + +/** + * Run `cargo package` (with `--locked` unless `locked` is false) and return the + * packaged `.crate` path if it now exists, else undefined, pack failed. + */ +export async function packCrate( + name: string, + version: string, + config: { locked: boolean; allowDirty?: boolean | undefined }, +): Promise { + const { allowDirty, locked } = { __proto__: null, ...config } as { + allowDirty?: boolean | undefined + locked: boolean + } + const args = ['package'] + if (locked) { + args.push('--locked') + } + // Only when a README pin, or another controlled staging step, has dirtied the + // tree — cargo otherwise refuses to package a VCS-dirty repo. + if (allowDirty) { + args.push('--allow-dirty') + } + const { code } = await runCapture('cargo', args, rootPath) + const file = cratePath(name, version) + return code === 0 && existsSync(file) ? file : undefined +} + +/** + * Pack the `.crate` and write a sibling `checksums.txt` (sha1 + sha512 of the + * `.crate`, mirroring the npm release-asset format), returning both paths for + * ensureTagAndRelease to attach to the GitHub release. Returns an empty array + * when the pack fails — the release then ships without assets, the same + * tolerance as release.mts's default (pnpm) packer. + */ +export async function packCrateAssets( + name: string, + version: string, + options?: { allowDirty?: boolean | undefined } | undefined, +): Promise { + const { allowDirty } = { __proto__: null, ...options } as { + allowDirty?: boolean | undefined + } + const crate = await packCrate(name, version, { allowDirty, locked: true }) + if (!crate) { + logger.warn( + `cargo package failed; releasing ${name}@${version} without assets.`, + ) + return [] + } + const bytes = readFileSync(crate) + const sha1 = crypto.createHash('sha1').update(bytes).digest('hex') + const sha512 = crypto.createHash('sha512').update(bytes).digest('base64') + const crateName = path.basename(crate) + const checksumsPath = path.join(path.dirname(crate), 'checksums.txt') + writeThroughMirrorLock( + checksumsPath, + `sha1: ${sha1} ${crateName}\nsha512-base64: ${sha512} ${crateName}\n`, + ) + logger.log( + `Crate sha1 ${sha1} (compare with the crates.io published digest).`, + ) + return [crate, checksumsPath] +} + +/** + * `--staged` mode: verify + package the crate without uploading anything. + * + * Reads the publishable package, refuses an already-published version + * (crates.io never allows a re-publish — surfaced before the network call), + * then runs `cargo publish --dry-run --locked` — which packages AND compiles + * from the packaged sources, the real verification that the uploaded bytes + * build. On success, and not a bare dry-run, packs the `.crate` and records + * its sha256 in a `.sha256` sidecar so `--approve` can integrity-gate + * against it. In CI the workflow — not this script — handles + * provenance/attestation. + */ +export async function runStaged(config: { + dryRun: boolean + packageName?: string | undefined +}): Promise { + const cfg = { __proto__: null, ...config } as { + dryRun: boolean + packageName?: string | undefined + } + const pkg = await readCargoPackage(cfg.packageName) + logger.log( + `Staging ${pkg.name}@${pkg.version}${cfg.dryRun ? ' [dry-run]' : ''}`, + ) + + if (await isAlreadyPublished(pkg.name, pkg.version)) { + logger.fail( + `${pkg.name}@${pkg.version} is already published to crates.io. Versions ` + + 'are PERMANENT (a version can only be yanked, never re-published or ' + + 'overwritten). Bump the version and try again.', + ) + process.exitCode = 1 + return + } + + // Pin the README's relative asset paths to the release tag in the packaged + // `.crate` (crates.io + docs.rs 404 on relative refs), restored after. + await withPinnedReadme( + { repository: pkg.repository, rootPath, version: pkg.version }, + async pinned => { + // cargo refuses a VCS-dirty tree; the pinned README is the sole dirty + // file, so allow it — and no wider — only when a pin was written. + const dirty = pinned ? ['--allow-dirty'] : [] + // `cargo publish --dry-run` packages the crate AND compiles it from the + // packaged sources — the real verification. Nothing is uploaded + // (crates.io has no staging endpoint). + const code = await runInherit( + 'cargo', + ['publish', '--dry-run', '--locked', ...dirty], + rootPath, + ) + if (code !== 0) { + logger.fail(`cargo publish --dry-run exited ${code}`) + process.exitCode = code + return + } + if (cfg.dryRun) { + logger.success( + `Dry-run complete for ${pkg.name}@${pkg.version}. Re-run without ` + + '--dry-run to produce the staged artifact.', + ) + return + } + + // Produce the .crate and record its sha256 as the staged digest so + // --approve can integrity-gate against it. + const crate = await packCrate(pkg.name, pkg.version, { + allowDirty: pinned, + locked: true, + }) + if (!crate) { + logger.fail( + `cargo package did not produce ${cratePath(pkg.name, pkg.version)}.`, + ) + process.exitCode = 1 + return + } + const sha256 = crateSha256(crate) + const sidecar = `${crate}.sha256` + writeThroughMirrorLock(sidecar, `${sha256} ${path.basename(crate)}\n`) + logger.log(`Staged crate sha256 ${sha256} (recorded at ${sidecar}).`) + if (process.env['GITHUB_ACTIONS'] === 'true') { + logger.log( + '[cargo] CI: provenance/attestation is handled by the publish ' + + 'workflow (this script does not attest the artifact itself).', + ) + } + logger.success( + `Verified + packaged ${pkg.name}@${pkg.version}. NOTHING is public ` + + 'yet — crates.io has no staging endpoint, so this is a verified, ' + + 'hashed artifact awaiting a downstream `--approve`.', + ) + logCargoApproveHandoff() + }, + ) +} + +/** + * `--direct` mode: classic single-step `cargo publish --locked` — build + + * upload + make public in one call, no stage/approve. Refuses an + * already-published version. On a real (non-dry-run) success, creates the git + * tag + GitHub release with the `.crate` + checksums as assets (see + * ensureTagAndRelease). + */ +export async function runDirect(config: { + dryRun: boolean + packageName?: string | undefined +}): Promise { + const cfg = { __proto__: null, ...config } as { + dryRun: boolean + packageName?: string | undefined + } + const pkg = await readCargoPackage(cfg.packageName) + logger.log( + `Direct-publishing ${pkg.name}@${pkg.version}` + + `${cfg.dryRun ? ' [dry-run]' : ''}`, + ) + + if (await isAlreadyPublished(pkg.name, pkg.version)) { + logger.fail( + `${pkg.name}@${pkg.version} is already published to crates.io. Versions ` + + 'are PERMANENT (yank-only). Bump the version and try again.', + ) + process.exitCode = 1 + return + } + + // README asset paths pinned to the release tag for the published `.crate` + + // the GitHub release asset, restored after (see runStaged). + await withPinnedReadme( + { repository: pkg.repository, rootPath, version: pkg.version }, + async pinned => { + const args = ['publish', '--locked'] + if (pinned) { + args.push('--allow-dirty') + } + if (cfg.dryRun) { + args.push('--dry-run') + } + const code = await runInherit('cargo', args, rootPath) + if (code !== 0) { + logger.fail(`cargo publish exited ${code}`) + process.exitCode = code + return + } + if (cfg.dryRun) { + logger.success( + `Dry-run complete for ${pkg.name}@${pkg.version}. Re-run without ` + + '--dry-run to publish (PERMANENT).', + ) + return + } + logger.success( + `Published ${pkg.name}@${pkg.version} to crates.io directly.`, + ) + // The tag + immutable release are the LAST markers: cut them only once + // the version is actually resolvable in the crates.io index. + const released = await releaseBehindLiveGate({ + isLive: () => isAlreadyPublished(pkg.name, pkg.version), + packAssets: () => + packCrateAssets(pkg.name, pkg.version, { allowDirty: pinned }), + pkg: { name: pkg.name, version: pkg.version }, + registry: 'crates.io', + }) + if (!released) { + process.exitCode = 1 + } + }, + ) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/cargo/trusted-publisher.mts b/release-kit/payload/scripts/socket-release/publish-infra/cargo/trusted-publisher.mts new file mode 100644 index 00000000..2031ca06 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/cargo/trusted-publisher.mts @@ -0,0 +1,899 @@ +#!/usr/bin/env node +/** + * @file Crates.io Trusted Publishing (GitHub Actions OIDC) configuration for + * the workspace's crates. A crates.io trusted publisher binds a crate to one + * `owner/repo` + workflow filename + CI environment; the publish job then + * exchanges its OIDC token for a short-lived registry token instead of + * carrying a long-lived secret. The registry only accepts the exchange when + * the claim matches a stored config EXACTLY, so a config naming a workflow + * the repo does not have fails at publish time, not at configure time. Every + * field is therefore DERIVED, never assumed: the `owner/repo` comes from the + * checkout's `origin` remote, and the workflow filename + environment come + * from the repo's ACTUAL cargo-publish workflow (the `environment:` key, + * including the fleet's `${{ inputs.publish == true && 'cargo-publish' || '' + * }}` conditional form). The npm twin shipped hard-coded names once and + * configured every package to trust a workflow that did not exist; the OIDC + * exchange then 404'd on the first real publish. CLI: trusted-publisher + * […] [--apply] [--path ] [--repo ] [--workflow + * ] [--environment ] With no crate names, every publishable + * crate in the workspace is targeted. `--path ` points all three + * derivations — the `origin` slug read, the + * `.github/workflows/cargo-publish.{yml,yaml}` lookup, and `cargo metadata` + * crate discovery — at another checkout, so one copy of this script can + * configure any repo; it defaults to the checkout the script lives in. + * `--repo ` is the separate override for the GitHub slug a config + * is stored under, and each flag REFUSES a value shaped like the other's so a + * mix-up says which flag to use instead of resolving somewhere unrelated. + * Dry-run by default, prints the plan, writes nothing; `--apply` creates the + * missing configs. Per-crate isolation: one crate failing never aborts the + * rest, and a summary prints at the end. Fail-soft — main() catches, logs, + * and sets a non-zero exit code; it never throws. Auth: a crates.io API token + * carrying the `trusted-publishing` endpoint scope, read from + * `CARGO_REGISTRY_TOKEN` or `~/.cargo/credentials.toml`. The token is sent in + * the `authorization` header and never printed or passed on a command line. + * Usage: node + * scripts/socket-release/publish-infra/cargo/trusted-publisher.mts [--path + * ] --apply. + */ + +import { promises as fs, statSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { httpJson, HttpResponseError } from '@socketsecurity/lib/http-request' +import { normalizePath } from '@socketsecurity/lib/paths/normalize' + +import { isMainModule } from '../../_shared/is-main-module.mts' +import { parseGitHubSlug } from '../pin-readme.mts' +import { logger, rootPath, runCapture } from '../shared.mts' +import { cargoTokenProblem, resolveCratesToken } from './placeholder.mts' +import { readPublishableCargoPackages } from './shared.mts' + +// The trusted-publisher collection endpoint. GET lists a crate's configs, POST +// creates one; both take the API token in the `authorization` header and +// require the `trusted-publishing` endpoint scope plus crate ownership. +export const TRUSTPUB_GITHUB_CONFIGS_URL = + 'https://crates.io/api/v1/trusted_publishing/github_configs' + +// crates.io rejects requests without a descriptive User-Agent (HTTP 403); this +// identifies the fleet publish tooling per their crawler policy. +const USER_AGENT = 'socket-release-kit-publish (github.com/SocketDev/sauce)' + +const REQUEST_TIMEOUT_MS = 20_000 + +// The printable characters crates.io rejects inside an environment name — they +// would break the claim matching it does at OIDC-exchange time. The registry +// also rejects C0 + DEL control characters, which `environmentProblem` checks +// by code point rather than spelling a control byte into this source file. +const REJECTED_ENVIRONMENT_CHARS = '\'"`,;\\' + +// The workflow basenames that carry the fleet's cargo publish job. crates.io +// stores a BASENAME, it rejects a path, so the config's `workflow_filename` is +// exactly one of these. +export const CARGO_PUBLISH_WORKFLOW_BASENAMES = [ + 'cargo-publish.yaml', + 'cargo-publish.yml', +] + +export interface TrustedPublisherTarget { + // The CI environment gating the publish job, or undefined when the job runs + // ungated (crates.io stores `null` for that). + environment?: string | undefined + repositoryName: string + repositoryOwner: string + workflowFilename: string +} + +// A stored config as crates.io returns it (snake_case, `environment` nullable). +export interface GitHubConfigRow { + crate: string + environment?: string | null | undefined + id: number + repository_name: string + repository_owner: string + workflow_filename: string +} + +export type TrustedPublisherStatus = + | 'created' + | 'failed' + | 'planned' + | 'skipped' + | 'unchanged' + +export interface TrustedPublisherResult { + crate: string + detail?: string | undefined + status: TrustedPublisherStatus +} + +export interface TrustedPublisherArgs { + apply: boolean + crates: string[] + environment?: string | undefined + // The `--path ` checkout override, exactly as typed (absolute or + // relative); `resolveInspectedRoot` resolves it against the caller's cwd. + path?: string | undefined + // The `--repo ` GitHub slug the stored config names. + repo?: string | undefined + workflow?: string | undefined +} + +export interface WorkflowSurface { + environment?: string | undefined + workflowFilename: string +} + +export interface RunTrustedPublisherOptions { + // Lists a crate's stored configs. Injected in tests so no network call + // happens. + listConfigs?: + | ((crate: string, token: string) => Promise) + | undefined + // Creates one config. Injected in tests for the same reason. + createConfig?: + | (( + crate: string, + target: TrustedPublisherTarget, + token: string, + ) => Promise) + | undefined +} + +/** + * Unwrap a YAML `environment:` value into the environment NAME. Handles the + * three shapes a fleet workflow uses: a plain scalar (`cargo-publish`), a + * quoted scalar, and the conditional expression + * `${{ inputs.publish == true && 'cargo-publish' || '' }}` — whose environment + * is the first NON-EMPTY quoted literal (the empty literal is the ungated + * dry-run arm). Returns undefined when no name can be read. Pure — exported + * for tests. + */ +export function unwrapEnvironmentValue(rawValue: string): string | undefined { + const raw = rawValue.trim() + if (raw === '' || raw.startsWith('#')) { + return undefined + } + if (raw.startsWith('${{')) { + // Every single- or double-quoted literal in the expression, in order. The + // body of each holds no quote of its own kind, which is all a workflow + // environment expression ever contains. + const literals = raw.match(/'[^']*'|"[^"]*"/g) ?? [] + for (let i = 0, { length } = literals; i < length; i += 1) { + const body = literals[i]!.slice(1, -1).trim() + if (body !== '') { + return body + } + } + return undefined + } + // A quoted scalar with an optional trailing `# comment`. The back-reference + // keeps the closing quote the same kind as the opening one. + const quoted = /^(?['"])(?.*)\k[ \t]*(?:#.*)?$/.exec(raw) + if (quoted) { + const body = (quoted.groups?.['body'] ?? '').trim() + return body === '' ? undefined : body + } + // A plain scalar, dropping any trailing ` # comment`. + const plain = raw.replace(/[ \t]+#.*$/, '').trim() + return plain === '' ? undefined : plain +} + +/** + * The CI environment a workflow's job runs in, read from its first + * job-level `environment:` key. Accepts the inline form + * (`environment: cargo-publish`, quoted or a `${{ … }}` expression) and the + * block form (`environment:` then a more-indented `name: cargo-publish`). + * Returns undefined when the workflow gates on no environment. Pure — + * exported for tests. + */ +export function extractWorkflowEnvironment( + workflowText: string, +): string | undefined { + const lines = workflowText.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + // The `environment:` key with its indent and whatever follows on the line. + const keyMatch = /^(?[ \t]*)environment:(?.*)$/.exec( + lines[i]!, + ) + if (!keyMatch) { + continue + } + const inline = unwrapEnvironmentValue(keyMatch.groups?.['rest'] ?? '') + if (inline !== undefined) { + return inline + } + // Block form: scan the more-indented lines beneath the key for `name:`, + // stopping as soon as the indent returns to the key's level or shallower. + const indent = (keyMatch.groups?.['indent'] ?? '').length + for (let j = i + 1; j < length; j += 1) { + const next = lines[j]! + if (next.trim() === '') { + continue + } + if (next.length - next.trimStart().length <= indent) { + break + } + // The `name:` child key of an `environment:` block. + const nameMatch = /^[ \t]*name:(?.*)$/.exec(next) + if (nameMatch) { + return unwrapEnvironmentValue(nameMatch.groups?.['value'] ?? '') + } + } + } + return undefined +} + +/** + * The cargo-publish workflow basename among `filenames`, or undefined when the + * repo has none. Sorted so the choice is deterministic when a repo somehow + * carries both the `.yml` and `.yaml` spelling. Pure — exported for tests. + */ +export function pickCargoPublishWorkflow( + filenames: readonly string[], +): string | undefined { + return filenames + .filter(f => CARGO_PUBLISH_WORKFLOW_BASENAMES.includes(f)) + .toSorted()[0] +} + +/** + * Whether `filename` is storable as a crates.io `workflow_filename`, mirroring + * the registry's own validator: non-empty, at most 255 characters, a `.yml` or + * `.yaml` suffix, and a BASENAME (no `/`). Pure — exported for tests. + */ +export function isValidWorkflowFilename(filename: string): boolean { + if (filename.length === 0 || filename.length > 255) { + return false + } + if (!filename.endsWith('.yml') && !filename.endsWith('.yaml')) { + return false + } + return !filename.includes('/') +} + +/** + * A one-line problem with an environment NAME, or undefined when it is + * storable. Mirrors the registry's validator: non-empty, at most 255 + * characters, no leading/trailing whitespace, and none of the control + * characters or punctuation in REJECTED_ENVIRONMENT_CHARS. Pure — exported for + * tests. + */ +export function environmentProblem(environment: string): string | undefined { + if (environment.length === 0) { + return 'is empty (omit it instead to configure an ungated publish)' + } + if (environment.length > 255) { + return 'is longer than 255 characters' + } + if (environment.trimStart() !== environment) { + return 'starts with whitespace' + } + if (environment.trimEnd() !== environment) { + return 'ends with whitespace' + } + for (let i = 0, { length } = environment; i < length; i += 1) { + const code = environment.charCodeAt(i) + if (code <= 0x1f || code === 0x7f) { + return 'contains a control character' + } + if (REJECTED_ENVIRONMENT_CHARS.includes(environment[i]!)) { + return `contains ${environment[i]}, which crates.io rejects` + } + } + return undefined +} + +/** + * Whether a stored config already IS the desired target — same repo, workflow, + * and environment. crates.io stores an ungated publish as `null`, which this + * treats as equal to an undefined desired environment. Pure — exported for + * tests. + */ +export function matchesTarget( + row: GitHubConfigRow, + target: TrustedPublisherTarget, +): boolean { + const storedEnvironment = row.environment ?? undefined + return ( + row.repository_owner === target.repositoryOwner && + row.repository_name === target.repositoryName && + row.workflow_filename === target.workflowFilename && + storedEnvironment === target.environment + ) +} + +/** + * A stored config rendered for the plan output. Pure — exported for tests. + */ +export function formatConfig( + crate: string, + target: TrustedPublisherTarget, +): string { + const environment = target.environment ?? '(none)' + return ( + `${crate} → ${target.repositoryOwner}/${target.repositoryName} ` + + `· ${target.workflowFilename} · environment ${environment}` + ) +} + +/** + * The `detail` string crates.io puts in its `{ "errors": [{ "detail": … }] }` + * error body, or undefined when the body is not that shape. Pure — exported + * for tests. + */ +export function extractCratesIoErrorDetail( + bodyText: string, +): string | undefined { + let parsed: { + errors?: Array<{ detail?: unknown | undefined }> | undefined + } + try { + parsed = JSON.parse(bodyText) as typeof parsed + } catch { + return undefined + } + const detail = parsed.errors?.[0]?.detail + return typeof detail === 'string' && detail ? detail : undefined +} + +/** + * Turn a crates.io HTTP failure into an actionable one-liner: What went wrong, + * what the registry said, and the fix. The 403s are the ones worth naming — + * crates.io returns the same status for "no token reached us" and "your token + * lacks the trusted-publishing scope", and only the second is fixable by + * minting a new token. Pure — exported for tests. + */ +export function describeHttpFailure( + status: number, + detail: string | undefined, +): string { + const said = detail ?? `HTTP ${status}` + if (status === 403 && detail?.includes('required permissions')) { + return ( + `crates.io refused the token: ${said}. Fix: mint a token at ` + + 'crates.io/settings/tokens with the `trusted-publishing` scope (and a ' + + 'crate scope covering this crate), then re-run.' + ) + } + if (status === 401 || status === 403) { + return ( + `crates.io refused the request: ${said}. Fix: confirm ` + + 'CARGO_REGISTRY_TOKEN (or ~/.cargo/credentials.toml) holds a current ' + + 'crates.io token with the `trusted-publishing` scope.' + ) + } + if (status === 400) { + return ( + `crates.io rejected the request: ${said}. Fix: confirm the crate exists ` + + 'and that the token owner is an owner of it.' + ) + } + if (status === 404) { + return ( + `crates.io has no such crate: ${said}. Fix: reserve the name first ` + + '(scripts/socket-release/publish-infra/cargo/placeholder.mts), then configure ' + + 'the trusted publisher.' + ) + } + if (status === 429) { + return `crates.io rate-limited the request: ${said}. Fix: wait, re-run.` + } + return `crates.io returned HTTP ${status}: ${said}` +} + +// The headers every authenticated crates.io call carries. crates.io takes the +// raw token in `authorization` (no `Bearer` prefix) and 403s a request with no +// descriptive User-Agent. +function authHeaders(token: string): Record { + return { + accept: 'application/json', + authorization: token, + 'user-agent': USER_AGENT, + } +} + +// Re-throw an HTTP failure as an actionable Error; anything else passes +// through unchanged so a network error keeps its own message. +function rethrowActionable(e: unknown): never { + if (e instanceof HttpResponseError) { + const detail = extractCratesIoErrorDetail(e.response.body.toString('utf8')) + throw new Error(describeHttpFailure(e.response.status, detail)) + } + throw e +} + +/** + * Every trusted-publisher config crates.io stores for `crate`. Requires a token + * with the `trusted-publishing` scope and ownership of the crate. + */ +export async function listGitHubConfigs( + crate: string, + token: string, +): Promise { + const url = `${TRUSTPUB_GITHUB_CONFIGS_URL}?crate=${encodeURIComponent(crate)}` + try { + const json = await httpJson<{ + github_configs?: GitHubConfigRow[] | undefined + }>(url, { headers: authHeaders(token), timeout: REQUEST_TIMEOUT_MS }) + return Array.isArray(json.github_configs) ? json.github_configs : [] + } catch (e) { + return rethrowActionable(e) + } +} + +/** + * Store one trusted-publisher config for `crate`. crates.io caps a crate at 5 + * configs and rejects a duplicate, so callers list first. + */ +export async function createGitHubConfig( + crate: string, + target: TrustedPublisherTarget, + token: string, +): Promise { + try { + const json = await httpJson<{ + github_config?: GitHubConfigRow | undefined + }>(TRUSTPUB_GITHUB_CONFIGS_URL, { + body: JSON.stringify({ + github_config: { + crate, + // crates.io models "no environment gate" as an explicit JSON null; + // omitting the key is a different request to the registry, whose + // own schema types this field as string|null. + // oxlint-disable-next-line socket/prefer-undefined-over-null -- registry wire schema + environment: target.environment ?? null, + repository_name: target.repositoryName, + repository_owner: target.repositoryOwner, + workflow_filename: target.workflowFilename, + }, + }), + headers: { ...authHeaders(token), 'content-type': 'application/json' }, + method: 'POST', + timeout: REQUEST_TIMEOUT_MS, + }) + if (!json.github_config) { + throw new Error( + 'crates.io accepted the request but returned no `github_config`.', + ) + } + return json.github_config + } catch (e) { + return rethrowActionable(e) + } +} + +/** + * The checkout every derivation reads: the `--path` value resolved against the + * caller's `cwd`, so a relative path means what the operator typed it from, or + * this script's own repo root when `--path` is absent. Cascaded copies pass + * nothing and keep inspecting their own checkout. Pure — exported for tests. + */ +export function resolveInspectedRoot( + pathArg: string | undefined, + cwd: string, +): string { + return pathArg === undefined ? rootPath : path.resolve(cwd, pathArg) +} + +/** + * Whether `value` is shaped like a GitHub `owner/name` slug: two path-free + * segments, each starting alphanumeric. The leading-character rule is what + * separates a slug from `./widgets`, `../widgets`, `~/widgets`, and `/widgets`. + * Pure — exported for tests. + */ +export function isGitHubSlugShape(value: string): boolean { + return /^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*$/.test(normalizePath(value)) +} + +/** + * The refusal for a `--repo` value that is really a filesystem path, or + * undefined when the value can be read as a GitHub slug. `--repo` names the + * `owner/name` a stored config points at; `--path` names the checkout to + * inspect. A path handed to `--repo` would store a config whose OIDC claim + * nothing ever matches, so it refuses instead. The caller answers the + * directory-existence question, which keeps this pure — exported for tests. + */ +export function repoFlagMisuse( + value: string, + options?: { isExistingDir?: boolean | undefined } | undefined, +): string | undefined { + const opts = { __proto__: null, ...options } as { + isExistingDir?: boolean | undefined + } + const normalized = normalizePath(value) + const looksLikePath = + normalized.startsWith('.') || + normalized.startsWith('/') || + normalized.startsWith('~') + if (!looksLikePath && !opts.isExistingDir) { + return undefined + } + return ( + '[cargo-trustpub] --repo takes a GitHub owner/name, not a filesystem ' + + `path. Where: the --repo argument. Saw: ${value} ` + + `(${looksLikePath ? 'a path-shaped value' : 'an existing directory'}); ` + + 'wanted: owner/name, for example acme/widgets. Fix: pass ' + + `--path ${value} to inspect that checkout instead.` + ) +} + +/** + * The refusal for a `--path` value that is really a GitHub slug, or undefined + * when the value can be read as a directory. A slug handed to `--path` would + * resolve to an unrelated directory under the caller's cwd, so it refuses. A + * value that is neither an existing directory nor slug-shaped passes through to + * the workflow-directory reader, which already names the path it could not + * read. The caller answers the directory-existence question, which keeps this + * pure — exported for tests. + */ +export function pathFlagMisuse( + value: string, + options?: { isExistingDir?: boolean | undefined } | undefined, +): string | undefined { + const opts = { __proto__: null, ...options } as { + isExistingDir?: boolean | undefined + } + if (opts.isExistingDir || !isGitHubSlugShape(value)) { + return undefined + } + return ( + '[cargo-trustpub] --path takes a directory, not a GitHub owner/name. ' + + `Where: the --path argument. Saw: ${value} (slug-shaped, and no such ` + + 'directory); wanted: the checkout to inspect, for example ' + + `../widgets. Fix: pass --repo ${value} to override the stored ` + + 'owner/name instead.' + ) +} + +/** + * The `owner/repo` slug of the checkout at `cwd`, read from its `origin` + * remote. Throws LOUD when git fails or the remote is not a GitHub URL — a + * guessed slug would store a config that silently never matches an OIDC claim. + */ +export async function resolveRepoSlug(cwd: string): Promise { + const { code, stdout } = await runCapture( + 'git', + ['remote', 'get-url', 'origin'], + cwd, + ) + const slug = code === 0 ? parseGitHubSlug(stdout.trim()) : undefined + if (!slug) { + throw new Error( + '[cargo-trustpub] could not resolve the GitHub repository. Where: the ' + + `\`origin\` remote of ${cwd}. Saw: ` + + `${code === 0 ? `a non-GitHub remote (${stdout.trim() || 'empty'})` : `git exited ${code}`}; ` + + 'wanted: a github.com owner/repo URL. Fix: point --path at the ' + + 'right checkout, or pass --repo .', + ) + } + return slug +} + +/** + * The workflow filename + environment the repo's cargo-publish job actually + * uses. Throws LOUD when the repo has no cargo-publish workflow — configuring + * a trusted publisher against a workflow that does not exist produces a config + * whose OIDC exchange fails at publish time, long after this script reported + * success. + */ +export async function readCargoPublishSurface( + workflowsDir: string, +): Promise { + let entries: string[] + try { + entries = await fs.readdir(workflowsDir) + } catch { + throw new Error( + '[cargo-trustpub] could not read the workflows directory. Where: ' + + `${workflowsDir}. Saw: missing or unreadable; wanted: a ` + + 'cargo-publish workflow to derive the trusted-publisher target from. ' + + 'Fix: point --path at a repo that has ' + + '.github/workflows/cargo-publish.yml, or pass --workflow ' + + '--environment .', + ) + } + const workflowFilename = pickCargoPublishWorkflow(entries) + if (!workflowFilename) { + throw new Error( + '[cargo-trustpub] this repo has no cargo-publish workflow. Where: ' + + `${workflowsDir}. Saw: none of ` + + `${CARGO_PUBLISH_WORKFLOW_BASENAMES.join(' / ')}; wanted: the ` + + 'workflow whose OIDC claim crates.io will match. Fix: cascade the ' + + 'cargo-publish workflow into that repo first, point --path at ' + + 'the repo you meant, or pass --workflow ' + + '--environment .', + ) + } + const workflowText = await fs.readFile( + path.join(workflowsDir, workflowFilename), + 'utf8', + ) + return { + environment: extractWorkflowEnvironment(workflowText), + workflowFilename, + } +} + +/** + * Assemble the target every crate is configured against, from the repo slug and + * the workflow surface, with any CLI override applied last. Throws LOUD when a + * field would not survive crates.io's own validators. Pure — exported for + * tests. + */ +export function buildTrustedPublisherTarget( + slug: string, + surface: WorkflowSurface, + overrides?: + | { environment?: string | undefined; workflow?: string | undefined } + | undefined, +): TrustedPublisherTarget { + const over = { __proto__: null, ...overrides } as { + environment?: string | undefined + workflow?: string | undefined + } + const [repositoryOwner, repositoryName] = slug.split('/') + if (!repositoryOwner || !repositoryName) { + throw new Error( + `[cargo-trustpub] the repository slug is malformed. Saw: ${slug}; ` + + 'wanted: owner/name. Fix: pass --repo .', + ) + } + const workflowFilename = over.workflow ?? surface.workflowFilename + if (!isValidWorkflowFilename(workflowFilename)) { + throw new Error( + '[cargo-trustpub] the workflow filename is not storable on crates.io. ' + + `Saw: ${workflowFilename}; wanted: a bare basename ending in .yml or ` + + '.yaml. Fix: pass --workflow .', + ) + } + const environment = over.environment ?? surface.environment + if (environment !== undefined) { + const problem = environmentProblem(environment) + if (problem !== undefined) { + throw new Error( + `[cargo-trustpub] the environment name ${problem}. Saw: ` + + `${JSON.stringify(environment)}; wanted: the CI environment the ` + + 'publish job runs in. Fix: pass --environment .', + ) + } + } + return { environment, repositoryName, repositoryOwner, workflowFilename } +} + +/** + * One-line human summary of the run: counts by status, tagged with the mode. + * Pure — exported for tests. + */ +export function formatSummary( + results: readonly TrustedPublisherResult[], + config: { apply: boolean }, +): string { + const cfg = { __proto__: null, ...config } as { apply: boolean } + const count = (status: TrustedPublisherStatus): number => + results.filter(r => r.status === status).length + return ( + `Trusted-publisher ${cfg.apply ? 'apply' : 'dry-run'} summary: ` + + `${count('created')} created, ${count('planned')} planned, ` + + `${count('unchanged')} unchanged, ${count('skipped')} skipped, ` + + `${count('failed')} failed.` + ) +} + +/** + * Configure each crate, isolated. For every crate: list its stored configs (an + * exact match → unchanged), then either PRINT the plan (dry-run) or create the + * config (`--apply`). A thrown error for one crate is recorded as `failed` and + * never aborts the others. Logs a summary and returns the per-crate results + * (for tests + the caller's exit-code decision). + */ +export async function runTrustedPublisher( + crates: readonly string[], + target: TrustedPublisherTarget, + config: { apply: boolean; token: string }, + options?: RunTrustedPublisherOptions | undefined, +): Promise { + const cfg = { __proto__: null, ...config } as { + apply: boolean + token: string + } + const opts = { __proto__: null, ...options } as RunTrustedPublisherOptions + const listConfigs = opts.listConfigs ?? listGitHubConfigs + const createConfig = opts.createConfig ?? createGitHubConfig + + const results: TrustedPublisherResult[] = [] + for (let i = 0, { length } = crates; i < length; i += 1) { + const crate = crates[i]! + try { + // eslint-disable-next-line no-await-in-loop + const rows = await listConfigs(crate, cfg.token) + if (rows.some(row => matchesTarget(row, target))) { + logger.substep(`${formatConfig(crate, target)} — already configured`) + results.push({ crate, status: 'unchanged' }) + continue + } + if (!cfg.apply) { + logger.substep(`[dry-run] would create ${formatConfig(crate, target)}`) + results.push({ crate, status: 'planned' }) + continue + } + // eslint-disable-next-line no-await-in-loop + const created = await createConfig(crate, target, cfg.token) + logger.success( + `Configured ${formatConfig(crate, target)} (config #${created.id}).`, + ) + results.push({ crate, status: 'created' }) + } catch (e) { + logger.error(`${crate}: ${errorMessage(e)}`) + results.push({ crate, status: 'failed', detail: errorMessage(e) }) + } + } + + logger.log('') + logger.log(formatSummary(results, { apply: cfg.apply })) + return results +} + +// The value-taking flags, so the parser reads one argument after each. +const VALUE_FLAGS = ['--environment', '--path', '--repo', '--workflow'] + +/** + * Parse `trusted-publisher […] [--apply] [--path ] + * [--repo ] [--workflow ] [--environment ]`. + * Dry-run is the default (no `--apply`). `--path` is the checkout to inspect; + * `--repo` overrides the owner/name the config is stored under. Positional args + * are crate names; with none, the caller targets every publishable crate in the + * workspace. Exits, usage error, on an unknown flag or a value-taking flag with + * no value. + */ +export function parseArgs(argv: readonly string[]): TrustedPublisherArgs { + let apply = false + let environment: string | undefined + let repoPath: string | undefined + let repo: string | undefined + let workflow: string | undefined + const crates: string[] = [] + for (let i = 0, { length } = argv; i < length; i += 1) { + const arg = argv[i]! + if (arg === '--apply') { + apply = true + continue + } + if (VALUE_FLAGS.includes(arg)) { + const value = argv[i + 1] + if (value === undefined || value.startsWith('-')) { + logger.fail(`Flag ${arg} needs a value.`) + process.exit(1) + } + if (arg === '--environment') { + environment = value + } else if (arg === '--path') { + repoPath = value + } else if (arg === '--repo') { + repo = value + } else { + workflow = value + } + i += 1 + continue + } + if (arg.startsWith('-')) { + logger.fail(`Unknown flag: ${arg}`) + process.exit(1) + } + crates.push(arg) + } + return { apply, crates, environment, path: repoPath, repo, workflow } +} + +/** + * Whether `candidate` names an existing DIRECTORY — the metadata bit is the + * point, so this stats rather than testing existence. The filesystem half of + * the `--path` / `--repo` misuse refusals, kept out of the pure matchers so + * those stay testable without touching disk. + */ +function isExistingDirectory(candidate: string): boolean { + try { + return statSync(candidate).isDirectory() + } catch { + return false + } +} + +export async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + // Usage preflight: `--path` and `--repo` are one keystroke apart in intent, + // and a value handed to the wrong one resolves somewhere unrelated instead of + // failing. Refuse first, before auth spends a round trip. + const misuse = + (args.repo === undefined + ? undefined + : repoFlagMisuse(args.repo, { + isExistingDir: isExistingDirectory(args.repo), + })) ?? + (args.path === undefined + ? undefined + : pathFlagMisuse(args.path, { + isExistingDir: isExistingDirectory(args.path), + })) + if (misuse !== undefined) { + logger.fail(misuse) + process.exitCode = 1 + return + } + // Auth preflight for BOTH modes: the dry-run reads the registry too, so a + // malformed saved token would turn into one opaque 403 per crate. + const token = await resolveCratesToken() + const problem = + token === undefined + ? 'is missing (no env token, no credentials.toml row)' + : cargoTokenProblem(token) + if (problem !== undefined || token === undefined) { + logger.fail( + `crates.io auth preflight: the token ${problem}. ` + + 'Where: CARGO_REGISTRY_TOKEN, else ~/.cargo/credentials.toml. ' + + 'Fix: mint a token at crates.io/settings/tokens carrying the ' + + '`trusted-publishing` scope, copy it as the LAST thing on the ' + + 'clipboard (copying a command overwrites it), and pipe: ' + + 'pbpaste | cargo login.', + ) + process.exitCode = 1 + return + } + + // The caller's cwd is the anchor ON PURPOSE here: a relative `--path` means + // what the operator typed it from, and `resolveInspectedRoot` falls back to + // this script's own root whenever `--path` is absent. + // oxlint-disable-next-line socket/no-process-cwd-in-scripts-hooks -- resolves the operator-typed relative --path argument from the directory the CLI was invoked in + const root = resolveInspectedRoot(args.path, process.cwd()) + const slug = args.repo ?? (await resolveRepoSlug(root)) + const surface = args.workflow + ? { environment: args.environment, workflowFilename: args.workflow } + : await readCargoPublishSurface(path.join(root, '.github', 'workflows')) + const target = buildTrustedPublisherTarget(slug, surface, { + environment: args.environment, + workflow: args.workflow, + }) + + const crates = args.crates.length + ? args.crates + : (await readPublishableCargoPackages(root)).map(p => p.name) + if (crates.length === 0) { + logger.fail( + `[cargo-trustpub] no crates to configure. Where: ${root}. Saw: ` + + 'no publishable package in `cargo metadata`; wanted: at least one. ' + + 'Fix: name the crates explicitly, point --path at the ' + + 'workspace you meant, or drop `publish = false`.', + ) + process.exitCode = 1 + return + } + + logger.log( + `crates.io trusted publishing — ${crates.length} crate(s)` + + `${args.apply ? ' [apply]' : ' [dry-run]'}`, + ) + logger.substep(`path: ${root}`) + logger.substep( + `target: ${target.repositoryOwner}/${target.repositoryName} · ` + + `${target.workflowFilename} · environment ` + + `${target.environment ?? '(none)'}`, + ) + const results = await runTrustedPublisher(crates, target, { + apply: args.apply, + token, + }) + if (results.some(r => r.status === 'failed')) { + process.exitCode = 1 + } +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the CLI. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/access-page.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-page.mts new file mode 100644 index 00000000..d2de7558 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-page.mts @@ -0,0 +1,121 @@ +/** + * @file Page-level playwright I/O for the publishing-access toggles — the + * package-settings block that decides whether DIRECT and/or STAGED + * publishes are accepted. Same split as the trusted-publisher driver: the + * signed-in access-page read feeds the pure parser + * (`access-parse.mts`), the drive fills the two checkboxes and clicks + * Save, and the post-save verify RE-READS until the page itself reports + * the desired shape — success is the page's answer, never the click. The + * session comes from the ONE sanctioned launch site + * (`browser-session.mts`), and the bootstrap reaches this module only + * through its seams so every test drives a fake. + */ + +import type { Page } from 'playwright-core' + +import { optIntoChallengeCooldown, sleep } from './browser-session.mts' +import { parsePublishingAccess } from './access-parse.mts' +import type { PublishingAccessRead } from './access-parse.mts' +import { accessMatchesDesired, diffPublishingAccess } from './access-plan.mts' +import type { PublishingAccessDesired } from './access-plan.mts' +import { accessUrl } from './trusted-publisher-page.mts' +import { classifyAccessPage } from './trusted-publisher-parse.mts' + +// Post-save verify budget: the operator may be answering a 2FA challenge in +// the window, so the re-read polls patiently rather than failing fast. +const SAVE_VERIFY_POLL_MS = 3000 +const SAVE_VERIFY_TIMEOUT_MS = 3 * 60_000 + +// Fetch the access page HTML in the page's MAIN world (the page's cookies +// authenticate it; cache no-store so a post-save re-read never sees stale +// pre-mutation HTML). +async function fetchAccessPage( + page: Page, + pkg: string, +): Promise<{ body: string; status: number }> { + try { + return await page.evaluate(async fetchUrl => { + // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- runs in the npm page's MAIN world via page.evaluate; only the page's cookies authenticate this request. + const r = await fetch(fetchUrl, { + cache: 'no-store', + credentials: 'same-origin', + headers: { accept: 'text/html' }, + method: 'GET', + }) + return { body: await r.text(), status: r.status } + }, accessUrl(pkg)) + } catch { + return { body: '', status: 0 } + } +} + +/** + * Read one package's publishing-access toggles off the signed-in access + * page. An auth/challenge/error page reads as `state: 'unknown'` — the + * refusal shape the pure layer defines, never a default classification. + */ +export async function readPublishingAccessInPage( + page: Page, + pkg: string, +): Promise { + const { body, status } = await fetchAccessPage(page, pkg) + const pageState = classifyAccessPage({ body, status }) + if ( + pageState === 'auth' || + pageState === 'challenge' || + pageState === 'error' + ) { + return { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + } + } + return parsePublishingAccess(body) +} + +/** + * Drive the publishing-access checkboxes to `desired`, click Save, then poll + * the RE-READ until the page reports the desired shape or the budget + * elapses. Returns the final read plus whether it matched — the caller + * treats a non-match as saved-state-unproven, never as success. + */ +export async function drivePublishingAccess( + page: Page, + pkg: string, + desired: PublishingAccessDesired, +): Promise<{ ok: boolean; read: PublishingAccessRead }> { + await page.goto(accessUrl(pkg), { waitUntil: 'domcontentloaded' }) + await optIntoChallengeCooldown(page) + const before = await readPublishingAccessInPage(page, pkg) + // diffPublishingAccess throws on an unknown read — refuse, never drive + // blind edits against a page the parser could not read. + const edits = diffPublishingAccess(before, desired) + for (let i = 0, { length } = edits; i < length; i += 1) { + const edit = edits[i]! + const box = page.locator(`input[name="${edit.checkbox}"]`).first() + // eslint-disable-next-line no-await-in-loop -- serial form drive on one live page. + await box.setChecked(edit.to, { timeout: 10_000 }) + } + if (edits.length > 0) { + const save = page + .getByRole('button', { name: /save changes|save|update/i }) + .first() + await save.click({ timeout: 10_000 }) + } + const deadline = Date.now() + SAVE_VERIFY_TIMEOUT_MS + for (;;) { + // eslint-disable-next-line no-await-in-loop -- serial poll while npm settles/2FA completes. + await optIntoChallengeCooldown(page) + // eslint-disable-next-line no-await-in-loop -- serial poll while npm settles/2FA completes. + const read = await readPublishingAccessInPage(page, pkg) + if (accessMatchesDesired(read, desired)) { + return { ok: true, read } + } + if (Date.now() >= deadline) { + return { ok: false, read } + } + // eslint-disable-next-line no-await-in-loop -- serial poll interval. + await sleep(SAVE_VERIFY_POLL_MS) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts new file mode 100644 index 00000000..4e8fac6d --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts @@ -0,0 +1,114 @@ +/** + * @file Pure parser for the npm package settings "Publishing access" block — + * the pair of toggles that decide whether a package accepts DIRECT + * publishes (`npm publish` straight to a public version) and STAGED + * publishes (`npm publish --staged` / trusted-publishing OIDC). No + * playwright, no network: the browser side (`access-page.mts`) reads the + * signed-in `/package//access` page and feeds the raw HTML here, the + * same split as `trusted-publisher-parse.mts`. The markers mirror npm's + * form wire contract (checkbox `name="allowDirectPublish"` / + * `name="allowStagedPublish"`, with the React initial-data JSON keys + * `directPublishEnabled` / `stagedPublishEnabled` as the fallback), the + * contract the trusted-publisher checkboxes (`allowPublish` / + * `allowStagePublish`) already proved stabler than DOM structure. + * An unreadable page NEVER defaults: `state: 'unknown'` is a refusal the + * callers must surface, not a classification — misreading "unknown" as + * "staged-only" would let a bootstrap re-run skip the tighten step, and + * misreading it as "both-enabled" would re-plan a write against a page the + * parser cannot see. + */ + +/** + * The four readable answers for a package's publishing-access settings. + * `unknown` means the page did not carry the block in any recognized shape — + * a refusal, never a default. + */ +export type PublishingAccessState = + | 'both-enabled' + | 'direct-only' + | 'staged-only' + | 'unknown' + +/** + * One publishing-access read: the two raw toggle values (undefined when the + * marker was absent) plus their classification. + */ +export interface PublishingAccessRead { + directEnabled: boolean | undefined + stagedEnabled: boolean | undefined + state: PublishingAccessState +} + +/** + * Classify a pair of toggle reads. Either toggle unreadable → `unknown` + * (refuse, never guess); both readable → the three real states, where + * "neither enabled" also reads as `unknown` because npm's settings page + * never renders that shape (a package must accept at least one publish + * path). Pure — exported for tests. + */ +export function classifyPublishingAccess( + directEnabled: boolean | undefined, + stagedEnabled: boolean | undefined, +): PublishingAccessState { + if (directEnabled === undefined || stagedEnabled === undefined) { + return 'unknown' + } + if (directEnabled && stagedEnabled) { + return 'both-enabled' + } + if (directEnabled) { + return 'direct-only' + } + if (stagedEnabled) { + return 'staged-only' + } + return 'unknown' +} + +// One toggle off the page: the whole input tag whatever the attribute order, +// checkedness tested on the matched tag text (the trusted-publisher parser's +// proven pattern), else the React initial-data JSON key whose quotes may be +// escaped when the JSON is embedded in another string. +function readToggle( + html: string, + checkboxName: string, + jsonKey: string, +): boolean | undefined { + const tag = new RegExp( + `]*\\bname="${checkboxName}"[^>]*>`, + 'i', + ).exec(html) + if (tag) { + return /\bchecked\b/i.test(tag[0]) + } + const json = new RegExp(`\\\\?"${jsonKey}\\\\?"\\s*:\\s*(true|false)`).exec( + html, + ) + if (json) { + return json[1] === 'true' + } + return undefined +} + +/** + * Parse the publishing-access block out of the signed-in access page. Both + * toggles must be readable for a real classification; anything else is + * `state: 'unknown'` and the caller refuses. Pure — exported for tests. + */ +export function parsePublishingAccess(html: string): PublishingAccessRead { + const directEnabled = readToggle( + html, + 'allowDirectPublish', + 'directPublishEnabled', + ) + const stagedEnabled = readToggle( + html, + 'allowStagedPublish', + 'stagedPublishEnabled', + ) + return { + directEnabled, + stagedEnabled, + state: classifyPublishingAccess(directEnabled, stagedEnabled), + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts new file mode 100644 index 00000000..52b4b830 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts @@ -0,0 +1,92 @@ +/** + * @file Pure planning for the publishing-access toggles: the two desired + * shapes the bootstrap drives a package through, and the diff/verify + * helpers between a read and a desired shape. The ORDER these shapes are + * applied in is the bootstrap's law — PERMISSIVE first (so the one-time + * direct 0.0.0 placeholder publish can land), STAGED-ONLY after (so from + * then on only staged/trusted publishing is possible) — and re-running the + * bootstrap never re-widens: the permissive shape is planned ONLY while + * the placeholder publish is still pending (see + * `bootstrap/steps/npm-access-permissive.mts`). No I/O here; the browser + * drive lives in `access-page.mts` and behind the bootstrap seams. + */ + +import type { PublishingAccessRead } from './access-parse.mts' + +/** + * A desired publishing-access shape: both toggles, stated explicitly. + */ +export interface PublishingAccessDesired { + directEnabled: boolean + stagedEnabled: boolean +} + +/** + * The wide shape: BOTH direct and staged publishing permitted — required + * only while the direct 0.0.0 placeholder publish has yet to land. + */ +export const PERMISSIVE_ACCESS: PublishingAccessDesired = { + directEnabled: true, + stagedEnabled: true, +} + +/** + * The terminal shape: staged/trusted publishing ONLY, direct publishing + * disabled. Every bootstrapped package must end here. + */ +export const STAGED_ONLY_ACCESS: PublishingAccessDesired = { + directEnabled: false, + stagedEnabled: true, +} + +/** + * One checkbox edit the browser driver performs on the access page. + */ +export interface PublishingAccessEdit { + checkbox: 'allowDirectPublish' | 'allowStagedPublish' + to: boolean +} + +/** + * Whether a read already IS the desired shape — the idempotent no-op test. + * An `unknown` read never matches (refuse, never assume done). + */ +export function accessMatchesDesired( + read: PublishingAccessRead, + desired: PublishingAccessDesired, +): boolean { + return ( + read.state !== 'unknown' && + read.directEnabled === desired.directEnabled && + read.stagedEnabled === desired.stagedEnabled + ) +} + +/** + * The checkbox edits that take `read` to `desired`. Throws on an `unknown` + * read: planning writes against a page the parser could not read is exactly + * the misclassification this layer exists to refuse. Pure — exported for + * tests. + */ +export function diffPublishingAccess( + read: PublishingAccessRead, + desired: PublishingAccessDesired, +): PublishingAccessEdit[] { + if (read.state === 'unknown') { + throw new Error( + 'Refusing to plan publishing-access edits: the access page read is unknown.\n' + + ' Where: the publishing-access block of the package access page\n' + + ' Saw: a page without readable allowDirectPublish/allowStagedPublish toggles\n' + + ' Wanted: both toggles readable\n' + + ' Fix: sign in to npm in the sanctioned browser session and re-run — an unreadable page is never a state.', + ) + } + const edits: PublishingAccessEdit[] = [] + if (read.directEnabled !== desired.directEnabled) { + edits.push({ checkbox: 'allowDirectPublish', to: desired.directEnabled }) + } + if (read.stagedEnabled !== desired.stagedEnabled) { + edits.push({ checkbox: 'allowStagedPublish', to: desired.stagedEnabled }) + } + return edits +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/approve.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/approve.mts new file mode 100644 index 00000000..d852eac9 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/approve.mts @@ -0,0 +1,520 @@ +/** + * @file `--approve` mode: list the user's staged packages, run the + * pre-approve integrity gate over every eligible entry FIRST (staging is + * one-shot per version, so verification must complete successfully before + * the human approve step is even offered), then multi-select over the + * verified entries, then batch-approve with one shared 2FA OTP and create + * the git tag + GitHub release for each promoted package. `--yes` replaces + * both interactive prompts for agent/scripted runs: every verified entry is + * selected, and with no `--otp` the registry challenge drives pnpm's + * web-OTP (a browser window to npmjs.com opens per approve call, so the + * human authenticates in the browser instead of the terminal). + */ + +import process from 'node:process' + +import { checkbox, password } from '@socketsecurity/lib/stdio/prompts' + +import { + APPROVE_IS_NOT_A_RESUME_PATH, + releaseGapRecoveryCommand, +} from '../../_shared/release-gap-recovery.mts' +import { releaseBehindLiveGate } from '../release.mts' +import { logger, rootPath, runInheritTty } from '../shared.mts' +import { isAlreadyPublished } from './registry.mts' +import type { StageListEntry } from './shared.mts' +import { + fetchPriorProvenanceMap, + formatPriorProvenance, + listStagedPackages, + readPackageJson, +} from './shared.mts' +import { ensureNpmIdentity } from './auth-identity.mts' +import { preflightSocketScanAuth, scanStagedEntry } from './scan.mts' +import { + browserStagedRequested, + downloadStagedTarballInPage, + openStagedBrowserSession, +} from './staged-browser-read.mts' +import { threatScanRequested } from './threat-scan.mts' +import type { StagedBrowserSession } from './staged-browser-read.mts' +import { + composeTarballProviders, + defaultDownloadStagedTarball, + defaultPackTarball, + verifyStagedEntry, +} from './staged.mts' +import type { TarballProvider } from './staged.mts' +import { + packWorkspaceReleaseAssets, + verifyStagedPlatformEntry, +} from './staged-workspace.mts' +import { hasMachineBuiltPayload } from './workspace-plan.mts' +import { + findWorkspacePackageByName, + resolveNpmWorkspaceLayout, +} from './workspace.mts' + +export interface ApproveChoice { + checked: boolean + name: string + value: string +} + +/** + * Build the checkbox choices for the approve multi-select: one row per eligible + * staged entry, labelled `name@version` with the prior-provenance annotation, + * valued by its stageId, pre-checked so the default is "approve all". Pure over + * the eligible list + the prior-provenance map. + */ +export function buildApproveChoices( + eligible: readonly StageListEntry[], + priorProvenance: ReadonlyMap, +): ApproveChoice[] { + return eligible.map(e => ({ + checked: true, + name: `${e.name}@${e.version}${formatPriorProvenance(priorProvenance.get(e.name!))}`, + value: e.stageId!, + })) +} + +/** + * `--approve` mode: list the user's staged packages, multi-select, batch + * approve with one OTP. + * + * Filters out any staged entries whose name@version is already public (e.g. a + * re-stage after a partial approve). Empty selection is a no-op. The OTP is + * read via a hidden-character prompt; a single OTP value is reused across all + * approve calls in the same batch — npm accepts the same TOTP within its ~30s + * validity window. With `yes` both prompts are skipped: all eligible entries + * are selected, and (absent `otpFromFlag`) 2FA falls through to the browser + * web-OTP challenge. + */ +export async function runApprove(config: { + dryRun: boolean + noScan: boolean + otpFromFlag: string | undefined + skipRelease?: boolean | undefined + yes: boolean + // ── Injected collaborator seams, dependency injection. Every field + // defaults to the real import below, so omitting them leaves prod behavior + // unchanged; tests pass fakes to drive each decision path without spawning + // npm/pnpm/git/gh, prompting a TTY, or touching the registry. Typed as + // `typeof ` so a signature drift on the collaborator is a compile + // error here. ── + browserRequested?: typeof browserStagedRequested | undefined + checkbox?: typeof checkbox | undefined + downloadStagedInPage?: typeof downloadStagedTarballInPage | undefined + ensureIdentity?: typeof ensureNpmIdentity | undefined + fetchPriorProvenance?: typeof fetchPriorProvenanceMap | undefined + isPublished?: typeof isAlreadyPublished | undefined + listStaged?: typeof listStagedPackages | undefined + openStagedSession?: typeof openStagedBrowserSession | undefined + password?: typeof password | undefined + readPkg?: typeof readPackageJson | undefined + releaseGate?: typeof releaseBehindLiveGate | undefined + resolveLayout?: typeof resolveNpmWorkspaceLayout | undefined + runInheritTty?: typeof runInheritTty | undefined + scanAuth?: typeof preflightSocketScanAuth | undefined + scanEntry?: typeof scanStagedEntry | undefined + threatRequested?: typeof threatScanRequested | undefined + verifyEntry?: typeof verifyStagedEntry | undefined +}): Promise { + const { dryRun, noScan, otpFromFlag, skipRelease, yes } = { + __proto__: null, + ...config, + } as typeof config + // Resolve each injected seam to its real implementation when omitted. + const resolveLayout = config.resolveLayout ?? resolveNpmWorkspaceLayout + const ensureIdentity = config.ensureIdentity ?? ensureNpmIdentity + const listStaged = config.listStaged ?? listStagedPackages + const readPkg = config.readPkg ?? readPackageJson + const isPublished = config.isPublished ?? isAlreadyPublished + const verifyEntry = config.verifyEntry ?? verifyStagedEntry + const fetchPriorProvenance = + config.fetchPriorProvenance ?? fetchPriorProvenanceMap + const scanEntry = config.scanEntry ?? scanStagedEntry + const browserRequested = config.browserRequested ?? browserStagedRequested + const threatRequested = config.threatRequested ?? threatScanRequested + const openStagedSession = config.openStagedSession ?? openStagedBrowserSession + const downloadStagedInPage = + config.downloadStagedInPage ?? downloadStagedTarballInPage + const releaseGate = config.releaseGate ?? releaseBehindLiveGate + const runTty = config.runInheritTty ?? runInheritTty + const promptCheckbox = config.checkbox ?? checkbox + const promptPassword = config.password ?? password + // Identity, not just auth: staged entries are maintainer-visible, so a + // wrong-account login reads an empty stage list and the approve silently + // no-ops. ensureNpmIdentity covers logged-out (delegates to login.mts) AND + // wrong-user (TTY: consented logout/login rotation; otherwise fail loud). + const layout = resolveLayout(rootPath) + if (!(await ensureIdentity(layout.versionSource.name))) { + process.exitCode = 1 + return + } + const staged = await listStaged() + if (staged.length === 0) { + logger.log('No packages currently staged.') + return + } + + // The stage list is ACCOUNT-scoped, not repo-scoped: entries staged by this + // account from OTHER repos show up here too. Approve must skip those — the + // verify gate can only ever pack THIS repo's packages (defaultPackTarball + // packs this checkout), so a foreign entry could never verify; worse, its + // verify pack would pin THIS repo's README against the FOREIGN entry's + // version, a wrong-manifest pin, and then fail with advice to reject an + // artifact that is perfectly good in its own repo. "Ours" is the full + // publishable-name set: the single subject for a plain repo, every + // workspace member (loader + platform packages) for a multi layout. + const localNames = + layout.kind === 'multi' + ? new Set(layout.packages.map(pkg => pkg.name)) + : new Set([readPkg().name]) + const localLabel = [...localNames].toSorted().join(', ') + const ours: StageListEntry[] = [] + for (const entry of staged) { + if (entry.name && localNames.has(entry.name)) { + ours.push(entry) + } else { + logger.log( + `Skipping ${entry.name}@${entry.version} — staged by this account but ` + + `not this repo's package (${localLabel}). Run --approve from its own repo.`, + ) + } + } + if (ours.length === 0) { + logger.log(`No staged entries for ${localLabel}; nothing to approve here.`) + return + } + + // Filter out already-published versions. If a stage upload was + // approved earlier but the entry lingers in stage list (registry + // quirk), don't offer it for re-approval. + const eligible: StageListEntry[] = [] + for (let i = 0, { length } = ours; i < length; i += 1) { + const entry = ours[i]! + // eslint-disable-next-line no-await-in-loop + if ( + entry.name && + entry.version && + !(await isPublished(entry.name, entry.version)) + ) { + eligible.push(entry) + } + } + if (eligible.length === 0) { + // This filter runs BEFORE the tag + GitHub release leg, so an operator who + // re-runs --approve to heal a missing tag lands here and exits zero having + // cut nothing. Name the real resume path rather than let the no-op read as + // "already done". + logger.log('All staged entries are already published; nothing to approve.') + logger.log( + ` ${APPROVE_IS_NOT_A_RESUME_PATH}\n` + + ` If a published version is missing its tag or GitHub release, heal it with\n` + + ` ${releaseGapRecoveryCommand('')}`, + ) + return + } + + // Pre-approve integrity gate FIRST — before the human is offered anything. + // Staging is one-shot per version (a staged-then-published version can + // never re-stage), so verification must complete successfully BEFORE the + // approve step is offered: a divergent or unverifiable artifact never + // reaches the multi-select, the 2FA prompt, or `pnpm stage approve`. + // Generated PLATFORM packages verify structurally on the staged bytes + // (their CI-built payload has no local twin to byte-compare — see + // verifyStagedPlatformEntry); everything else keeps the local-pack + // byte-compare gate. + const verifiedEntries: StageListEntry[] = [] + for (let i = 0, { length } = eligible; i < length; i += 1) { + const entry = eligible[i]! + const member = entry.name + ? findWorkspacePackageByName(layout, entry.name) + : undefined + // eslint-disable-next-line no-await-in-loop + const verified = + member && (member.platform || hasMachineBuiltPayload(member.manifest)) + ? await verifyStagedPlatformEntry(entry, member, { + downloadStagedTarball: defaultDownloadStagedTarball, + }) + : await verifyEntry(entry) + if (verified) { + verifiedEntries.push(entry) + } + } + if (verifiedEntries.length === 0) { + logger.fail( + 'No staged package passed pre-approve verification; nothing offered for approve.', + ) + process.exitCode = 1 + return + } + if (verifiedEntries.length < eligible.length) { + logger.fail( + `${eligible.length - verifiedEntries.length}/${eligible.length} failed pre-approve verify; ` + + `offering only the ${verifiedEntries.length} verified. Reject the rest (node scripts/socket-release/npm-web-auth.mts stage reject ).`, + ) + process.exitCode = 1 + } + + // Fetch prior-version provenance for each unique package name so the + // approver can spot regressions (last public version had provenance + // but the staged one's parent name has lost trust metadata between + // versions — a workflow drift signal). Cheap: one fetch per unique + // name, abbreviated packument (no _npmUser needed; we only check + // attestations presence as a proxy for "this name is OIDC-published"). + const priorProvenance = await fetchPriorProvenance(verifiedEntries) + + const choices = buildApproveChoices(verifiedEntries, priorProvenance) + let selected: string[] | undefined + if (yes) { + // --yes (agent / scripted runs, no TTY): approve everything eligible — + // the same set the interactive default offers, every row pre-checked. + // The rows still print so the prior-provenance annotations stay visible. + logger.log('--yes: approving all staged packages:') + for (const choice of choices) { + logger.log(` ${choice.name}`) + } + selected = choices.map(c => c.value) + } else { + selected = (await promptCheckbox({ + message: 'Select staged packages to approve:', + choices, + })) as string[] | undefined + } + if (!selected || selected.length === 0) { + logger.log('Nothing selected; exiting.') + return + } + + if (dryRun) { + logger.log('[dry-run] would approve:') + for (const stageId of selected) { + const entry = verifiedEntries.find(e => e.stageId === stageId) + logger.log(` ${entry?.name}@${entry?.version} (id: ${stageId})`) + } + logger.success( + `Dry-run complete. Re-run without --dry-run to prompt for OTP and promote.`, + ) + return + } + + // Full-scan gate: the pre-select shasum verify proved the staged bytes + // match the local pack, so a Socket scan of the local artifact IS a scan of + // the upload. Entries that fail drop out, mirroring the verify gate. Runs + // BEFORE the OTP prompt: a TOTP code is only valid ~30s, so every slow gate + // must finish before the human types one. + let gated = selected + if (noScan) { + logger.log('--no-scan: skipping the Socket full-scan gate.') + } else { + // One auth preflight for the whole batch: token resolution (with the + // browser-assisted mint on an interactive run), a cheap quota verify, + // and the org slug — so a missing/expired token surfaces here, not + // per-entry mid-gate. + const scanAuth = config.scanAuth ?? preflightSocketScanAuth + const scanContext = await scanAuth() + if (!scanContext) { + logger.fail( + 'Socket scan gate unavailable; nothing approved. (--no-scan skips the gate explicitly.)', + ) + process.exitCode = 1 + return + } + // Optional browser-read passback: with --staged-browser (or + // SOCKET_STAGED_BROWSER=1) open one signed-in npm session and pull each + // staged tarball's bytes THROUGH it — the staged view + tarball are + // session-only, invisible to the registry API. The gate then scans exactly + // what npm has staged. Opened once for the whole batch; closed in finally. + // Opt-in local code-threat scan: with --threat-scan (or + // SOCKET_THREAT_SCAN=1) each entry additionally runs the keyless on-device + // triage over its extracted source, failing closed on a threat verdict or + // an unavailable model. Resolved once for the batch. + const threatScan = threatRequested() + let browserSession: StagedBrowserSession | undefined + if (browserRequested()) { + try { + browserSession = await openStagedSession() + } catch (e) { + logger.fail( + `Browser-read staged passback failed to open; nothing approved. ${String(e)}`, + ) + process.exitCode = 1 + return + } + } + try { + const scanned: string[] = [] + for (let i = 0, { length } = selected; i < length; i += 1) { + const stageId = selected[i]! + const entry = verifiedEntries.find(e => e.stageId === stageId) + if (!entry?.name || !entry.version) { + continue + } + const member = findWorkspacePackageByName(layout, entry.name) + const scanSubject = { name: entry.name, version: entry.version } + // Artifact-source FALLBACK CHAIN in precedence order, not a single + // pick: a browser-read session (its bytes are npm's actual staged + // upload) → the registry-API staged download for platform/machine-built + // packages a local pack can't reproduce → the default local pack + // (byte-identical once the shasum gate passed). A source that yields no + // bytes (undefined — a staged entry with no tarballUrl, an in-page + // fetch that failed) falls through to the next instead of hard-failing + // the scan, matching downloadStagedTarballInPage's documented contract. + const sources: TarballProvider[] = [] + if (browserSession) { + const stagedTar = browserSession.tarballs.find( + t => t.packageName === entry.name && t.version === entry.version, + ) + if (stagedTar) { + sources.push(() => + downloadStagedInPage(browserSession!.page, stagedTar), + ) + } + } + if ( + member && + (member.platform || hasMachineBuiltPayload(member.manifest)) + ) { + sources.push(() => defaultDownloadStagedTarball(stageId)) + } + sources.push(defaultPackTarball) + const packTarball = composeTarballProviders(sources) + // eslint-disable-next-line no-await-in-loop + const scanOk = await scanEntry(scanSubject, { + context: scanContext, + packTarball, + threatScan, + }) + if (scanOk) { + scanned.push(stageId) + } + } + if (scanned.length === 0) { + logger.fail( + 'No selected package passed the Socket scan gate; nothing approved.', + ) + process.exitCode = 1 + return + } + if (scanned.length < selected.length) { + logger.fail( + `${selected.length - scanned.length}/${selected.length} failed the scan gate; ` + + `approving only the ${scanned.length} that scanned clean.`, + ) + process.exitCode = 1 + } + gated = scanned + } finally { + await browserSession?.close() + } + } + + // OTP resolution order: + // 1. --otp flag (CI / scripted use). + // 2. --yes with no --otp: skip the prompt entirely and let the registry + // challenge drive pnpm's web-OTP (browser) flow directly. + // 3. Interactive prompt; entering a TOTP code uses it for all + // approvals; entering nothing falls through to pnpm's per-call + // web-OTP flow (the registry challenges and pnpm opens a browser + // window to npmjs.com for each approve call). + // Passing the same TOTP to every approve in a batch is fine: npm + // accepts the same code for the duration of its ~30s validity window — + // which is exactly why this prompt sits LAST, after every gate. + let otp = otpFromFlag + if (!otp && yes) { + logger.log( + 'No --otp supplied; npm opens a browser window (web-OTP) to authenticate each approve — complete the 2FA there.', + ) + } else if (!otp) { + const entered = (await promptPassword({ + message: + '2FA OTP (TOTP code for batch; leave blank for browser web-OTP):', + mask: '*', + })) as string | undefined + if (entered) { + otp = entered + } + } + + let approved = 0 + let failed = 0 + const approvedEntries: StageListEntry[] = [] + for (let i = 0, { length } = gated; i < length; i += 1) { + const stageId = gated[i]! + const args = ['stage', 'approve', stageId] + if (otp) { + args.push('--otp', otp) + } + // TTY-wrapped: the registry's web-OTP challenge (no --otp) refuses + // non-interactive stdio instead of opening the browser. + // eslint-disable-next-line no-await-in-loop + const code = await runTty('pnpm', args, rootPath) + if (code === 0) { + approved += 1 + const entry = verifiedEntries.find(e => e.stageId === stageId) + if (entry) { + approvedEntries.push(entry) + } + } else { + failed += 1 + logger.fail(`Approve ${stageId} exited ${code}`) + } + } + if (failed > 0) { + logger.fail(`${failed}/${gated.length} failed; ${approved} approved`) + process.exitCode = 1 + return + } + logger.success(`Approved ${approved} package${approved === 1 ? '' : 's'}`) + + // Approve is the moment a staged package becomes public, so the git tag + + // GitHub release are created here rather than at --staged time. This runs + // locally where git, gh, and npm are all authenticated; the CI --staged step + // holds only an OIDC npm token (no contents:write / GH_TOKEN), so a release + // attempt there fails and is also premature, nothing is public yet. + // `skipRelease` (--no-release) hands the tag + release to the caller, which + // cuts them later via github-release.mts with verify-time checksums. + if (skipRelease) { + logger.log( + '--no-release: leaving the tag + GitHub release to the caller ' + + '(cut them with github-release.mts --tag vX.Y.Z --release).', + ) + return + } + // Multi-package layout: every member shares one lockstep version, so ONE + // tag + immutable release covers the whole approved set — keyed on the + // MAIN package's liveness, with every member tarball (+ checksums) as + // assets. Per-entry releases would fight over the same v tag. + if (layout.kind === 'multi' && approvedEntries.length > 0) { + const main = layout.main! + const version = layout.versionSource.version + const released = await releaseGate({ + isLive: () => isPublished(main.name, version), + packAssets: () => packWorkspaceReleaseAssets(layout), + pkg: { name: main.name, version }, + registry: 'npm', + }) + if (!released) { + process.exitCode = 1 + } + return + } + for (let i = 0, { length } = approvedEntries; i < length; i += 1) { + const entry = approvedEntries[i]! + if (entry.name && entry.version) { + // The tag + immutable release are the LAST markers: cut them only once + // the approved version is actually resolvable on the registry. + // eslint-disable-next-line no-await-in-loop + const released = await releaseGate({ + isLive: () => isPublished(entry.name!, entry.version!), + pkg: { name: entry.name, version: entry.version }, + registry: 'npm', + }) + if (!released) { + process.exitCode = 1 + } + } + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/auth-identity.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/auth-identity.mts new file mode 100644 index 00000000..1cd6bcf4 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/auth-identity.mts @@ -0,0 +1,229 @@ +/** + * @file Npm auth IDENTITY for publish flows. login.mts repairs a logged-OUT + * npm; this module owns the orthogonal failure: logged in as the WRONG user. + * Staged entries are visible only to the subject package's maintainers, so a + * non-maintainer login makes `pnpm stage list` read as EMPTY and every + * verify/approve silently no-ops — the operator debugs "0 staged entries" + * instead of "wrong account". ensureNpmIdentity reads who is needed, the + * packument's maintainers, who is logged in (`npm whoami`), and on mismatch + * prompts for the logout/login rotation on a TTY or fails LOUD with the exact + * commands otherwise. The maintainer read is a three-way discriminant — known + * / unpublished / unreachable — because only a 404, first publish, may pass + * silently: a transient registry failure on a KNOWN-published package would + * otherwise fail open and re-open the exact wrong-account trap this gate + * closes. npm commands run from npmScratchCwd() — see its doc for why the + * temp dir is the only cwd that dodges both the repo's devEngines veto and + * lib spawn's untrusted-root PATH sanitization. Also a CLI: `node + * scripts/socket-release/publish-infra/npm/auth-identity.mts `. + */ + +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { httpJson, HttpResponseError } from '@socketsecurity/lib/http-request' +import { confirm } from '@socketsecurity/lib/stdio/prompts' + +import { packumentUrl } from '../../constants/npm-registry.mts' +import { ensureNpmLogin } from './login.mts' +import { npmScratchCwd } from './shared.mts' +import { logger, runCapture, runInherit } from '../shared.mts' + +/** + * The npm username the local machine is logged in as, or undefined when + * logged out, or npm is unusable. Runs from npmScratchCwd() — the repo's + * devEngines veto in-repo `npm`, and a home-dir cwd makes lib spawn drop + * every home-rooted PATH entry. + */ +export async function npmWhoami(): Promise { + const { code, stdout } = await runCapture('npm', ['whoami'], npmScratchCwd()) + const name = stdout.trim() + return code === 0 && name ? name : undefined +} + +/** + * The maintainer read's three honest outcomes. `unpublished` (a 404) is the + * only silent pass — a first publish has no maintainers to match. + * `unreachable` (timeout / 5xx / proxy) must fail CLOSED: the package may be + * published with a maintainer set this login is not in, and passing here + * would re-open the empty-stage-list trap. `known` gates on membership — + * including an empty set, which can never match. + */ +export type MaintainerRead = + | { kind: 'known'; names: string[] } + | { kind: 'unpublished' } + | { detail: string; kind: 'unreachable' } + +/** + * Read the subject package's npm maintainers from the packument. + */ +export async function readPackageMaintainers( + name: string, +): Promise { + const url = packumentUrl(name) + try { + const json = await httpJson<{ + maintainers?: Array<{ name?: string | undefined }> | undefined + }>(url, { + headers: { accept: 'application/json' }, + timeout: 15_000, + }) + const names = (json.maintainers ?? []) + .map(m => m.name) + .filter((n): n is string => typeof n === 'string' && n.length > 0) + return { kind: 'known', names } + } catch (e) { + if (e instanceof HttpResponseError && e.response.status === 404) { + return { kind: 'unpublished' } + } + return { + detail: errorMessage(e), + kind: 'unreachable', + } + } +} + +export interface NpmIdentityReport { + /** + * True when the flow may proceed: the login matches a maintainer, or the + * package has never been published, nothing to match on a first publish. + */ + ok: boolean + currentUser: string | undefined + read: MaintainerRead +} + +/** + * Diagnosis lines for the identity state, in the four-ingredient shape — + * used by verify/approve failure paths so an empty stage list names WHO was + * looking, not just "0 entries". + */ +export function describeNpmIdentity(report: NpmIdentityReport, pkg: string) { + const { currentUser, read } = report + const maintainerText = + read.kind === 'known' + ? read.names.join(', ') || '' + : read.kind === 'unpublished' + ? '' + : `` + const lines = [ + `npm identity: ${currentUser ?? ''}; ${pkg} maintainers: ${maintainerText}.`, + ] + if (!report.ok && read.kind === 'unreachable') { + lines.push( + `The maintainer read failed, so this identity CANNOT be verified — ` + + `refusing rather than risking the wrong-account empty-stage-list trap.`, + `Fix: retry when the registry is reachable, or verify by hand ` + + `(\`npm view ${pkg} maintainers\`).`, + ) + } else if (!report.ok) { + lines.push( + `Staged entries are visible only to maintainers, so this login reads ` + + `an EMPTY stage list for ${pkg}.`, + `Fix: rotate the login — \`npm logout\` then ` + + `\`npm login --auth-type=web\` as a maintainer (run both from your ` + + `home dir or /tmp; the repo's devEngines veto in-repo npm).`, + ) + } + return lines +} + +/** + * Compute the identity report for a publish subject: logged-in user vs the + * packument's maintainers. Unpublished passes, first publish; unreachable + * and non-membership, including an empty maintainer set, do not. + */ +export async function npmIdentityFor(pkg: string): Promise { + const [currentUser, read] = await Promise.all([ + npmWhoami(), + readPackageMaintainers(pkg), + ]) + const ok = + read.kind === 'unpublished' || + (read.kind === 'known' && + currentUser !== undefined && + read.names.includes(currentUser)) + return { currentUser, ok, read } +} + +/** + * Ensure the local npm identity can operate on `pkg`'s staged entries: + * logged out → run the login flow (login.mts); logged in as a non-maintainer + * → on a TTY, offer the logout/login rotation and run it on consent; + * otherwise fail LOUD with the exact repair commands. Returns true when the + * flow may proceed. Rotation is NEVER automatic without consent: `npm + * logout` revokes the current token, which a parallel publish flow (a bot + * account, another repo's staging) may still depend on. + */ +export async function ensureNpmIdentity(pkg: string): Promise { + let report = await npmIdentityFor(pkg) + if (report.currentUser === undefined) { + if (!(await ensureNpmLogin())) { + return false + } + report = await npmIdentityFor(pkg) + } + if (report.ok) { + logger.log(describeNpmIdentity(report, pkg)[0]!) + return true + } + for (const line of describeNpmIdentity(report, pkg)) { + logger.fail(line) + } + if (report.read.kind === 'unreachable' || !process.stdin.isTTY) { + return false + } + const rotate = await confirm({ + default: false, + message: `Log out ${report.currentUser} and log in as a maintainer of ${pkg} now?`, + }) + if (!rotate) { + return false + } + const previousUser = report.currentUser + const logout = await runInherit('npm', ['logout'], npmScratchCwd()) + if (logout !== 0) { + logger.fail(`npm logout exited ${logout}.`) + return false + } + if (!(await ensureNpmLogin())) { + return false + } + report = await npmIdentityFor(pkg) + if (!report.ok) { + for (const line of describeNpmIdentity(report, pkg)) { + logger.fail(line) + } + if (report.currentUser === previousUser) { + logger.fail( + `The identity did not rotate: npm logout only clears the user-npmrc ` + + `token, so an env token (npm_config__authToken / NPM_TOKEN) or a ` + + `global npmrc may be pinning ${previousUser ?? 'this login'}. ` + + `Clear those, then retry.`, + ) + } + return false + } + logger.success(`npm identity ok: ${report.currentUser} maintains ${pkg}.`) + return true +} + +async function runCli(): Promise { + const pkg = process.argv[2] + if (!pkg) { + logger.fail( + 'Usage: node scripts/socket-release/publish-infra/npm/auth-identity.mts ', + ) + process.exitCode = 1 + return + } + if (!(await ensureNpmIdentity(pkg))) { + process.exitCode = 1 + } +} + +if (import.meta.main) { + void runCli().catch((error: unknown) => { + logger.error(error) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/backfill.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/backfill.mts new file mode 100644 index 00000000..b46c9292 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/backfill.mts @@ -0,0 +1,262 @@ +/* + * @file The sanctioned gap-fill backfill gate for the npm publish flow. A + * backfill republishes PRIOR content as a version that was skipped between + * two already-published versions — 1.4.3 between a live 1.4.2 and 1.4.4. + * The normal release path can't reach it: the bump/changelog gate anchors + * to registry latest and refuses anything at-or-below it, and a historical + * ref can't be dispatched because workflow_dispatch needs npm-publish.yml + * to exist on the dispatched ref. So the workflow is dispatched from MAIN + * with `checkout-ref` naming the CONTENT and `backfill-version` naming the + * gap, the bump stage is bypassed, and the staged publish runs behind five + * hard guards that keep the mode gap-fill-only: + * + * 1. The version must not be CURRENTLY published. The registry `time` map + * the permanent publish ledger, plus the live `versions` set split the + * history three ways: never published (backfillable), published and + * still live (refused — nothing to fill), and published-then-UNPUBLISHED + * (backfillable — the staging-era registry frees an unpublished number, + * and the stage attempt is server-side rejectable, so the registry + * itself arbitrates). An unreadable ledger or versions set fails CLOSED. + * 2. The version must be LOWER than registry latest. Backfill can only fill a + * gap behind history — it is never a way to skip the bump gate forward. + * 3. The dist-tag must be explicitly non-`latest`. A backfill never moves the + * latest pointer. + * 4. A checkout-ref is required — the workflow definition comes from main, so + * the content ref must be named, never implied. + * 5. The package.json version at the checkout-ref must equal the backfill + * version — the content commit declares itself. + */ + +import { lt } from '@socketsecurity/lib/versions/compare' + +import { logger, rootPath } from '../shared.mts' +import { fetchRegistryReleaseState } from './registry.mts' +import { resolveNpmWorkspaceLayout } from './workspace.mts' + +// A release or prerelease semver. Guard 5 already pins the version to the +// checked-out manifest; this only rejects obvious non-versions early with a +// clearer message than a manifest mismatch. +const BACKFILL_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ + +export interface BackfillGateInput { + backfillVersion: string + checkoutRef: string | undefined + distTag: string | undefined + /** + * The registry `dist-tags.latest`, or undefined when the package has never + * published — which refuses: with no latest there is no gap to fill. + */ + latestVersion: string | undefined + /** + * The package.json version of the CHECKED-OUT content. + */ + manifestVersion: string + /** + * The live registry `versions` set, or undefined when it could not be + * read — which fails CLOSED alongside the time map. + */ + publishedVersions: readonly string[] | undefined + /** + * The registry packument `time` map, or undefined when it could not be + * read — which fails CLOSED: absence from the ledger can't be proven, so + * the gate refuses. + */ + timeMap: Record | undefined +} + +export type BackfillVerdict = { ok: true } | { ok: false; reason: string } + +/** + * Evaluate the five backfill guards. Pure — the caller supplies the registry + * state — so every guard is behaviorally testable without a network. Returns + * the FIRST failing guard's reason; guards are ordered cheapest-first. + */ +export function evaluateBackfillGate( + input: BackfillGateInput, +): BackfillVerdict { + const cfg = { __proto__: null, ...input } as BackfillGateInput + const { + backfillVersion, + checkoutRef, + distTag, + latestVersion, + manifestVersion, + publishedVersions, + timeMap, + } = cfg + if (!BACKFILL_VERSION_RE.test(backfillVersion)) { + return { + ok: false, + reason: `backfill-version "${backfillVersion}" is not a semver version.`, + } + } + // Guard 4: the content ref must be named. The workflow definition comes + // from main, so without an explicit checkout-ref the "content" would + // silently be whatever main holds today. + if (!checkoutRef) { + return { + ok: false, + reason: + 'backfill requires checkout-ref — the branch/tag/SHA whose content ' + + 'is being republished. The workflow definition comes from main; the ' + + 'content ref is never implied.', + } + } + // Guard 3: the latest pointer never moves on a backfill. The dist-tag + // input defaults to `latest`, so an operator who didn't deliberately pick + // a tag lands here. + if (!distTag || distTag === 'latest') { + return { + ok: false, + reason: + 'backfill requires an explicit non-`latest` dist-tag — a backfill ' + + 'never moves the latest pointer. Pick a tag like `backfill`.', + } + } + // Guard 5: the content commit declares itself. The checked-out + // package.json version must equal the backfill version, so the staged + // tarball can only ever carry the version its own commit names. + if (manifestVersion !== backfillVersion) { + return { + ok: false, + reason: + `the checked-out package.json says ${manifestVersion}, not ` + + `${backfillVersion} — the content commit must declare the backfill ` + + 'version itself. Check out the commit whose package.json names it.', + } + } + // Guard 2: gap-fill only, never forward. Anything at-or-above latest is + // the bump gate's territory; a backfill that could move forward would be a + // bump-gate bypass. + if (!latestVersion) { + return { + ok: false, + reason: + 'the registry has no latest version for this package — with nothing ' + + 'published there is no gap to backfill. Use the normal release path.', + } + } + if (!lt(backfillVersion, latestVersion)) { + return { + ok: false, + reason: + `${backfillVersion} is not lower than the registry latest ` + + `${latestVersion} — backfill fills gaps BEHIND history, never ahead ` + + 'of it. Use the normal bump/release path to move forward.', + } + } + // Guard 1: not CURRENTLY published. The time map is the permanent ledger; + // the live versions set says what is public NOW. Never-published passes; a + // live version refuses, nothing to fill; published-then-UNPUBLISHED + // passes — the staging-era registry frees an unpublished number, and the + // stage attempt is server-side rejectable, so the registry itself is the + // final arbiter. Unreadable state fails CLOSED. + if (!timeMap || !publishedVersions) { + return { + ok: false, + reason: + "the registry ledger could not be read, so the version's publish " + + 'history is unverifiable — refusing rather than guessing. Retry ' + + 'when the registry is reachable.', + } + } + if (publishedVersions.includes(backfillVersion)) { + return { + ok: false, + reason: + `${backfillVersion} is currently published — there is no gap to ` + + 'fill. Unpublish it first if the artifact is wrong, or pick a ' + + 'different version.', + } + } + if (Object.hasOwn(timeMap, backfillVersion)) { + logger.warn( + `${backfillVersion} was published before and later unpublished — ` + + 'backfilling the freed number; the registry rejects the stage if ' + + 'it disagrees.', + ) + } + return { ok: true } +} + +/** + * The flag-composition conflicts around `--backfill`, checked before any + * network call. Returns the conflict reason, or undefined when the flag set + * is coherent. Pure, so the refusals are unit-testable. + */ +export function backfillFlagConflict(config: { + backfillVersion: string | undefined + bump: boolean + checkoutRef: string | undefined + mode: 'approve' | 'direct' | 'staged' + releaseAs: string | undefined +}): string | undefined { + const cfg = { __proto__: null, ...config } as { + backfillVersion: string | undefined + bump: boolean + checkoutRef: string | undefined + mode: 'approve' | 'direct' | 'staged' + releaseAs: string | undefined + } + if (!cfg.backfillVersion) { + return cfg.checkoutRef + ? '--checkout-ref is only meaningful with --backfill.' + : undefined + } + if (cfg.mode !== 'staged') { + return '--backfill publishes through the staged path only — do not combine it with --approve/--direct.' + } + if (cfg.bump) { + return '--backfill bypasses the bump/changelog gate — never combine it with --bump.' + } + if (cfg.releaseAs) { + return '--backfill republishes prior content as-is — --release-as has no meaning here.' + } + return undefined +} + +/** + * Run the backfill gate against the live registry + the checked-out + * package.json. Fetches latest + the time map in one packument read, then + * defers to `evaluateBackfillGate`. Logs the verdict; returns false on any + * refusal so the caller stops before staging. + */ +export async function runBackfillGate(config: { + backfillVersion: string + checkoutRef: string | undefined + distTag: string | undefined +}): Promise { + const cfg = { __proto__: null, ...config } as { + backfillVersion: string + checkoutRef: string | undefined + distTag: string | undefined + } + // The publish SUBJECT, not the repo root: a multi-package workspace's + // version source is the main member (a 0.0.0 root is a versionless + // placeholder), so the gate must read the member's manifest and query the + // member's packument. + const subject = resolveNpmWorkspaceLayout(rootPath).versionSource + const state = await fetchRegistryReleaseState(subject.name) + const verdict = evaluateBackfillGate({ + backfillVersion: cfg.backfillVersion, + checkoutRef: cfg.checkoutRef, + distTag: cfg.distTag, + latestVersion: state?.latest, + manifestVersion: subject.version, + publishedVersions: state?.versions, + timeMap: state?.timeMap, + }) + if (!verdict.ok) { + logger.fail( + `Backfill gate REFUSED ${subject.name}@${cfg.backfillVersion}.\n` + + ` Why: ${verdict.reason}`, + ) + return false + } + logger.log( + `Backfill gate passed: ${subject.name}@${cfg.backfillVersion} is an ` + + `unused gap below latest ${state!.latest}; staging under dist-tag ` + + `"${cfg.distTag}".`, + ) + return true +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-session.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-session.mts new file mode 100644 index 00000000..c755c9a8 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-session.mts @@ -0,0 +1,495 @@ +/* + * @file THE sanctioned npm browser session for every fleet tool that drives + * npmjs.com — one durable profile, one launch shape, one sign-in contract. + * Ported from socket-registry's proven configurator + * (`scripts/npm/configure-staged-publishing-browser.mts`), which + * mass-configured npm package settings across that registry. + * Every rule below exists because of the 2026-07-29 sign-in-loop incident: + * an npm sign-in inside a freshly invented per-tool profile looped forever — + * credentials and OTP succeeded, then npmjs bounced straight back to + * signed-out — and the debugging thrash added a per-tool profile, a sandbox + * toggle, and a challenge retry ladder, each of which made things worse. + * + * - NO scripted login, ever. The operator signs in ONCE in the headed window; + * the profile persists, so it is a per-machine step. No password, OTP, or + * cookie passes through this process. + * - ONE durable profile ({@link DEFAULT_PROFILE_DIR}) shared by every npm + * browser tool, so an operator signed in for the publish gate is signed in + * everywhere. A second per-tool profile means a second sign-in. + * - ONE launch shape: `launchPersistentContext(profileDir, { channel, + * chromiumSandbox: true, headless, ignoreDefaultArgs: + * ['--enable-automation', '--use-mock-keychain'] })` and NOTHING else. No + * `args` array, and exactly those two ignored Playwright defaults: + * `--enable-automation` sets `navigator.webdriver = true` — the standard + * bot signal — and with it a fresh-profile npmjs.com login + OTP was + * observed (2026-07-30) bouncing straight back to the signed-out landing + * page, the session dropped live by the site (keychain corruption ruled + * out by profile wipes). `--use-mock-keychain` writes a cookie store a + * bare Chrome launch of the same profile can neither read nor add to, so + * one stray manual launch would poison the session for every tool run. + * `chromiumSandbox: true` is REQUIRED, not optional: Playwright defaults + * the sandbox OFF and injects `--no-sandbox` itself, and current Chrome + * refuses that flag outright (observed 2026-07-30 — the window opens and + * the session is unusable). Sandbox ON is the only launch real Chrome + * accepts. + * - SINGLE instance. A second Chrome on the same profile forces an ephemeral + * session, so a held profile is refused by name rather than silently + * producing a session that cannot persist. + * - The only auth signal is npm's own `/-/whoami` on the WEBSITE origin, + * and the BODY decides — never the HTTP status. www.npmjs.com removed + * the route (observed 2026-07-30): it answers 404 whose spiferack + * envelope still carries the session — `user.name` a string when signed + * in, `user: null` when signed out. Requiring a 200 reads every live + * session as signed out until the sign-in timeout, which presents as + * "login does not persist". The only auth failure reported is "signed + * out". + * - A human-verification challenge is PAUSED for the operator with a visible + * elapsed/remaining countdown, NEVER retried on a backoff ladder: a blind + * retry against a bot challenge earns a rate limit, which then masquerades + * as a broken session. Nothing is written while a challenge is outstanding. + * A launch-sanction check enforces the launch rules across the tree, so a + * new tool cannot re-derive its own. + */ + +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { existsSync } from 'node:fs' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { chromium } from 'playwright-core' +import type { BrowserContext, Page } from 'playwright-core' + +import { logger } from '../shared.mts' + +export const NPM_ORIGIN = 'https://www.npmjs.com' + +/** + * The ONE durable Chrome profile every npm browser tool shares. It lives in + * the OS config dir, never in the repo tree. Historical directory name kept + * so profiles already signed in keep working. + */ +export const DEFAULT_PROFILE_DIR = path.join( + os.homedir(), + '.config', + 'socket-wheelhouse', + 'staged-browser-profile', +) + +// npm OAuth / 2FA is human-paced. +const SIGN_IN_TIMEOUT_MS = 5 * 60_000 +const SIGN_IN_POLL_MS = 2000 + +/** + * A human-verification challenge is solved by a PERSON, so the budget is + * generous and the poll is slow. This is a pause, not a retry ladder. + */ +export const CHALLENGE_BUDGET_MS = 10 * 60_000 +export const CHALLENGE_POLL_MS = 5000 + +/** + * The npm challenge page's per-IP cooldown opt-in. Ticking it lets a BATCH of + * publish/trust operations ride one approval instead of re-challenging per + * operation. Fail-soft by design — never load-bearing. + */ +export const COOLDOWN_OPTIN_SELECTOR = 'input[name="didOptForCooldown"]' + +// Chrome's profile lock. Present while an instance holds the profile; a +// crashed instance can leave it behind, which is why the guard reports it as +// "possibly stale" rather than asserting a live holder. +const SINGLETON_LOCK = 'SingletonLock' + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Run a same-origin fetch in the page's MAIN world and return status + raw + * body. The page's own cookies authenticate it, so no credential is read, + * copied, or logged by this process. A destroyed execution context from a + * mid-navigation race yields status 0, which callers treat as retryable + * rather than fatal. + */ +export async function fetchInPage( + page: Page, + url: string, + accept: string, +): Promise<{ body: string; status: number }> { + try { + return await page.evaluate( + async ({ acceptHeader, fetchUrl }) => { + // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- runs in the npm page's MAIN world via page.evaluate; the lib httpRequest is unavailable there and only the page's cookies authenticate this request. + const r = await fetch(fetchUrl, { + cache: 'no-store', + credentials: 'same-origin', + headers: { accept: acceptHeader, 'x-spiferack': '1' }, + method: 'GET', + }) + return { body: await r.text(), status: r.status } + }, + { acceptHeader: accept, fetchUrl: url }, + ) + } catch { + return { body: '', status: 0 } + } +} + +/** + * The signed-in npm username via the website origin's `/-/whoami`, or '' + * when the session is signed out. The ONLY auth signal any consumer reads. + * The BODY decides, never the status: www.npmjs.com removed the route + * (observed 2026-07-30) and answers HTTP 404 whose spiferack envelope still + * carries the session — `{"message":"Route not found!","user":{"name":…}}` + * signed in, `"user":null` signed out. The registry-style + * `{"username":…}` shape is still accepted in case the route ever serves + * again, with no status requirement either. A destroyed execution context + * (status 0) has an empty body and reads as signed out, which callers + * already treat as retryable. + */ +export async function resolveNpmUser(page: Page): Promise { + const { body } = await fetchInPage( + page, + `${NPM_ORIGIN}/-/whoami`, + 'application/json', + ) + try { + const parsed = JSON.parse(body) as { + user?: { name?: unknown | undefined } | null | undefined + username?: unknown | undefined + } + if (typeof parsed.user?.name === 'string') { + return parsed.user.name + } + return typeof parsed.username === 'string' ? parsed.username : '' + } catch { + return '' + } +} + +/** + * Tick npm's challenge-cooldown opt-in when the challenge page offers it, so + * a batch of operations rides ONE approval. Fail-soft: any error is swallowed + * and the flow proceeds exactly as before. + */ +export async function optIntoChallengeCooldown(page: Page): Promise { + try { + const box = page.locator(COOLDOWN_OPTIN_SELECTOR).first() + if ((await box.count()) > 0 && !(await box.isChecked())) { + await box.check({ timeout: 2000 }) + logger.log( + 'Ticked the npm challenge-cooldown opt-in — publish/trust operations skip re-challenge for 5 minutes.', + ) + } + } catch {} +} + +/** + * Human-readable progress line for a PAUSED challenge — elapsed and + * remaining seconds, so the wait is visible rather than a silent hang. Pure — + * exported for tests. + */ +export function formatChallengeWait(config: { + budgetMs: number + elapsedMs: number + url: string +}): string { + const cfg = { __proto__: null, ...config } as typeof config + const elapsed = Math.round(cfg.elapsedMs / 1000) + const remaining = Math.max( + 0, + Math.round((cfg.budgetMs - cfg.elapsedMs) / 1000), + ) + return ( + `Waiting on human verification at ${cfg.url} — ${elapsed}s elapsed, ` + + `${remaining}s before this run gives up. Solve the challenge in the ` + + 'Chrome window; the run resumes on its own.' + ) +} + +/** + * Failure block for a challenge that outlasted its budget, in What / Where / + * Saw vs wanted / Fix order. Pure — exported for tests. + */ +export function formatChallengeTimeout(config: { + budgetMs: number + url: string +}): string { + const cfg = { __proto__: null, ...config } as typeof config + return [ + 'What: npm kept serving a human-verification challenge, so the run stopped rather than retrying into a rate limit.', + `Where: ${cfg.url}`, + `Saw: the challenge was still unsolved after ${Math.round(cfg.budgetMs / 1000)}s of waiting.`, + 'Wanted: the challenge cleared in the Chrome window so the signed-in session can read the page.', + 'Fix: solve the "Just a moment…" check in the Chrome window, then re-run. Nothing was changed, so a re-run is safe.', + ].join('\n') +} + +/** + * One tick of the challenge PAUSE, shared by every consumer's read loop: on + * the first tick bring the challenge page to the front for the operator, then + * keep the cooldown opt-in ticked, print the countdown, and sleep. Throws the + * challenge-timeout block once the budget is spent — the caller therefore + * never needs a retry ladder. + */ +export async function pauseForChallenge( + page: Page, + config: { + announced: boolean + budgetMs?: number | undefined + elapsedMs: number + label: string + pollMs?: number | undefined + url: string + }, +): Promise<{ announced: true }> { + const cfg = { __proto__: null, ...config } as typeof config + const budgetMs = cfg.budgetMs ?? CHALLENGE_BUDGET_MS + if (cfg.elapsedMs >= budgetMs) { + throw new Error(formatChallengeTimeout({ budgetMs, url: cfg.url })) + } + if (!cfg.announced) { + logger.warn( + `Human verification interjected on ${cfg.label}. This run is PAUSED — solve it in the Chrome window.`, + ) + await page.goto(cfg.url, { waitUntil: 'domcontentloaded' }).catch(() => {}) + await page.bringToFront().catch(() => {}) + } + await optIntoChallengeCooldown(page) + logger.log( + formatChallengeWait({ budgetMs, elapsedMs: cfg.elapsedMs, url: cfg.url }), + ) + await sleep(cfg.pollMs ?? CHALLENGE_POLL_MS) + return { announced: true } +} + +/** + * Hand the window to the operator until npm reports a signed-in session. No + * credential is typed by this process and the profile persists, so this is a + * once-per-machine step. + */ +export async function waitForNpmSignIn( + page: Page, + profileDir: string, +): Promise { + await page.goto(NPM_ORIGIN, { waitUntil: 'domcontentloaded' }).catch(() => {}) + const deadline = Date.now() + SIGN_IN_TIMEOUT_MS + let announced = false + for (;;) { + // The challenge page with the cooldown box can appear at any poll tick + // while the operator works through sign-in/2FA; keep it ticked. + // eslint-disable-next-line no-await-in-loop -- serial poll while the operator signs in. + await optIntoChallengeCooldown(page) + // eslint-disable-next-line no-await-in-loop -- serial poll while the operator signs in. + const user = await resolveNpmUser(page) + if (user) { + return user + } + if (!announced) { + logger.log('Sign in to npm in the Chrome window; waiting…') + announced = true + } + if (Date.now() >= deadline) { + throw new Error( + [ + 'What: the run needs a signed-in npm session and never got one.', + `Where: the Chrome profile at ${profileDir}`, + `Saw: /-/whoami reported no user after ${SIGN_IN_TIMEOUT_MS / 1000}s.`, + 'Wanted: a signed-in npmjs.com session in that profile.', + 'Fix: re-run and complete sign-in, including 2FA, in the Chrome window. The profile persists, so this is a one-time step.', + ].join('\n'), + ) + } + // eslint-disable-next-line no-await-in-loop -- serial poll interval. + await sleep(SIGN_IN_POLL_MS) + } +} + +/** + * The pid a Chrome SingletonLock symlink encodes, or undefined when the + * target has no readable `-` shape. Pure; exported for tests. + */ +export function parseSingletonLockPid(target: string): number | undefined { + const match = /-(\d+)$/.exec(target) + if (!match) { + return undefined + } + const pid = Number(match[1]) + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined +} + +// Chrome's three per-profile singleton artifacts. A SIGTERM'd or crashed +// Chrome leaves them behind, and the next launch then prints "Opening in +// existing browser session" and exits — a phantom holder that burned ~30 +// minutes of launch bounces (2026-07-31). When the lock's pid is dead, the +// files are trash, not a tenant. +const SINGLETON_ARTIFACTS = [ + 'SingletonLock', + 'SingletonSocket', + 'SingletonCookie', +] + +/** + * Remove stale singleton artifacts when NO live process holds the lock: + * reads the SingletonLock symlink's `-` target, probes the pid, + * and clears all three artifacts if it is dead or unparseable. A live pid + * leaves everything in place for {@link profileInUseRefusal} to refuse + * honestly. Returns true when a stale set was cleared. + */ +export async function clearStaleSingletons( + profileDir: string, +): Promise { + const lockPath = path.join(profileDir, SINGLETON_LOCK) + let target: string + try { + target = await fs.readlink(lockPath) + } catch { + return false + } + const pid = parseSingletonLockPid(target) + if (pid !== undefined) { + try { + process.kill(pid, 0) + return false + } catch { + // Dead pid — the lock is stale; fall through to the cleanup. + } + } + for (let i = 0, { length } = SINGLETON_ARTIFACTS; i < length; i += 1) { + // eslint-disable-next-line no-await-in-loop -- three tiny unlinks, sequential by choice. + await safeDelete(path.join(profileDir, SINGLETON_ARTIFACTS[i]!)) + } + return true +} + +/** + * The refusal for a profile another Chrome already holds, or undefined when + * the profile is free to use. A second instance on one profile forces an + * EPHEMERAL session — the sign-in appears to succeed and then evaporates — so + * this refuses by name instead. The caller answers the lock-existence + * question, which keeps this pure and testable. + */ +export function profileInUseRefusal(config: { + lockHeld: boolean + profileDir: string +}): string | undefined { + const cfg = { __proto__: null, ...config } as typeof config + if (!cfg.lockHeld) { + return undefined + } + return [ + 'What: another Chrome instance is holding the npm browser profile, so this run stopped before launching a second one.', + `Where: ${path.join(cfg.profileDir, SINGLETON_LOCK)}`, + 'Saw: the profile lock present.', + 'Wanted: sole use of the profile — a second instance forces an ephemeral session whose sign-in cannot persist.', + `Fix: quit the Chrome window using this profile, then re-run. If no window is open, the lock is stale from a crash: delete ${SINGLETON_LOCK} in that directory and re-run.`, + ].join('\n') +} + +/** + * The injectable options every npm browser session opener shares. `launch` + * lets tests hand in a fake BrowserContext so no real Chrome ever starts; + * `scope` skips the sign-in wait when the caller already knows the user. + */ +export interface NpmBrowserSessionOptions { + headless?: boolean | undefined + launch?: + | ((config: { + headless: boolean + profileDir: string + }) => Promise) + | undefined + profileDir?: string | undefined + scope?: string | undefined +} + +/** + * A live signed-in npm browser session. The caller MUST call `close()`. + */ +export interface NpmBrowserSession { + close: () => Promise + page: Page + user: string +} + +/** + * Launch headed system Chrome on the shared durable profile and wait for a + * signed-in session. Headed by design: the operator signs in here and solves + * any human verification here, neither of which a headless run can do. THE + * only sanctioned `launchPersistentContext` call in the fleet's npm tooling — + * see the file header for why each rule exists. + */ +export async function openNpmBrowserSession( + options?: NpmBrowserSessionOptions | undefined, +): Promise { + const { + headless = false, + launch, + profileDir = DEFAULT_PROFILE_DIR, + scope, + } = { __proto__: null, ...options } as NonNullable + await fs.mkdir(profileDir, { recursive: true }) + // Single-instance guard. Skipped when a fake `launch` is injected: a test + // never touches a real profile, and the operator's own Chrome must not make + // the suite fail. + if (!launch) { + // Heal a crashed holder first: a SIGTERM'd Chrome leaves its Singleton + // artifacts behind, and launching against them prints "Opening in + // existing browser session" and exits. Only a DEAD lock pid is cleaned; + // a live one falls through to the refusal below. + if (await clearStaleSingletons(profileDir)) { + logger.log( + 'cleared stale Chrome singleton artifacts (their holder is dead) — proceeding.', + ) + } + const refusal = profileInUseRefusal({ + lockHeld: existsSync(path.join(profileDir, SINGLETON_LOCK)), + profileDir, + }) + if (refusal !== undefined) { + throw new Error(refusal) + } + } + // The browser channel defaults to system Chrome but is overridable + // (SOCKET_BROWSER_CHANNEL=msedge / chromium / …) for a machine without + // Chrome installed — playwright-core can't conjure a channel it has no + // binary for, so the operator points it at one they do have. + const channel = process.env['SOCKET_BROWSER_CHANNEL'] || 'chrome' + const doLaunch = + launch ?? + // The sanctioned shape: channel + sandbox ON + headedness + the two + // ignored defaults below, nothing else. No args array. See the file + // header. + (cfg => + chromium.launchPersistentContext(cfg.profileDir, { + channel, + // REQUIRED. Playwright defaults the sandbox OFF and injects + // --no-sandbox itself; current Chrome refuses that flag outright + // (observed 2026-07-30), leaving the window open but the session + // unusable. Sandbox ON is the only launch real Chrome accepts. + chromiumSandbox: true, + headless: cfg.headless, + // Drop two Playwright defaults that break a REAL npm session. + // --enable-automation sets navigator.webdriver = true, the standard + // bot signal; with it, a fresh-profile npmjs.com login + OTP bounced + // straight back to the signed-out landing page — the session dropped + // live by the site (observed 2026-07-30; keychain corruption ruled + // out by profile wipes). --use-mock-keychain writes a cookie store a + // bare Chrome launch of the same profile can neither read nor add + // to, so one stray manual launch would poison the session for every + // tool run. + ignoreDefaultArgs: ['--enable-automation', '--use-mock-keychain'], + })) + const context = await doLaunch({ headless, profileDir }) + try { + const page = context.pages()[0] ?? (await context.newPage()) + const user = scope || (await waitForNpmSignIn(page, profileDir)) + if (!user) { + throw new Error('Could not resolve the signed-in npm user.') + } + return { close: () => context.close(), page, user } + } catch (e) { + await context.close() + throw e + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-sign-in.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-sign-in.mts new file mode 100644 index 00000000..7a3e4f3a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/browser-sign-in.mts @@ -0,0 +1,165 @@ +/** + * @file Seed the shared npm browser profile with a PLAIN-Chrome sign-in — + * no Playwright, no CDP. npmjs.com sits behind bot management that drops a + * LOGIN transaction performed in a devtools-driven browser: sign-in + OTP + * complete and the site bounces straight back to the signed-out landing + * page (observed 2026-07-30 on a FRESH profile with the sanctioned launch — + * sandbox on, automation flags stripped — so no flag tuning fixes it; the + * CDP wire itself is the tell). An EXISTING session cookie is honored fine. + * So the lanes split: this script launches real Chrome (CDP-free) on the + * shared profile for the one human sign-in, and every automation launch + * only ever REUSES the session it seeded. + * Flow: refuse if the profile is held → open plain Chrome on the profile at + * the npm login page → the operator signs in (password + OTP) and QUITS + * Chrome (Cmd-Q; quitting releases the profile lock and flushes cookies) → + * the sanctioned driver opens the profile and proves the session with + * npm's own /-/whoami. Fail-loud on every arm: a signed-out verify names + * the next move instead of leaving the operator guessing. + * Usage: node scripts/socket-release/publish-infra/npm/browser-sign-in.mts. + */ + +import { existsSync } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { + DEFAULT_PROFILE_DIR, + NPM_ORIGIN, + openNpmBrowserSession, + sleep, +} from './browser-session.mts' +import { isMainModule } from '../../_shared/is-main-module.mts' + +const logger = getDefaultLogger() + +// Chrome's per-profile single-instance marker; present while any Chrome has +// the profile open, gone once the operator quits. +const SINGLETON_LOCK = 'SingletonLock' + +// How long the operator gets for the whole sign-in (password + OTP + quit). +const SIGN_IN_BUDGET_MS = 15 * 60_000 +const POLL_MS = 2000 + +/** + * Launch plain (CDP-free) system Chrome on the shared profile at the npm + * login page, wait for the operator to sign in and QUIT Chrome, then verify + * the seeded session through the sanctioned driver. Returns the signed-in + * username; throws loud on refusal, timeout, or a signed-out verify. + */ +export async function seedNpmSignIn( + options?: { profileDir?: string | undefined } | undefined, +): Promise { + const opts = { __proto__: null, ...options } as { + profileDir?: string | undefined + } + const profileDir = opts.profileDir ?? DEFAULT_PROFILE_DIR + await fs.mkdir(profileDir, { recursive: true }) + const lockPath = path.join(profileDir, SINGLETON_LOCK) + if (existsSync(lockPath)) { + throw new Error( + `the profile is already held by a running Chrome (${lockPath}).\n` + + ` Fix: quit that Chrome window (Cmd-Q), then re-run.`, + ) + } + // Exec the Chrome BINARY directly — real Chrome with NO devtools wire + // attached, which is the whole point of this lane. Never `open -na`: when + // Chrome is already running, LaunchServices routes the URL to the existing + // instance and silently DROPS the --user-data-dir args, so the operator + // signs in on their personal profile while this script waits forever for a + // lock that can never appear (observed 2026-07-30). + const chromeBinary = + process.env['SOCKET_BROWSER_BINARY'] || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + if (!existsSync(chromeBinary)) { + throw new Error( + `no Chrome binary at ${chromeBinary}.\n` + + ' Fix: install Google Chrome, or point SOCKET_BROWSER_BINARY at the ' + + 'browser binary to use.', + ) + } + const child = spawn( + chromeBinary, + [`--user-data-dir=${profileDir}`, `${NPM_ORIGIN}/login`], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + // Chrome's stderr is diagnostics-only noise on a good run — but on a + // failed launch it is the ONLY evidence, and the first version of this + // script swallowed it (`void child.catch(...)` + stdio ignore), which + // turned a silent spawn failure into a 15-minute lock wait with nothing to + // debug (2026-07-30). A rolling tail is kept for the failure message; the + // exit promise is still swallowed because the operator quitting Chrome is + // the SUCCESS path, whatever the exit code. + let stderrTail = '' + child.process.stderr?.on('data', (chunk: Buffer) => { + stderrTail = (stderrTail + chunk.toString('utf8')).slice(-2000) + }) + let childAlive = true + child.process.on('exit', () => { + childAlive = false + }) + void child.catch(() => undefined) + logger.log('Chrome is open on the shared profile at the npm login page.') + logger.log('Sign in (password + OTP), then QUIT Chrome (Cmd-Q).') + logger.log( + 'Quitting is load-bearing: it flushes cookies and frees the profile.', + ) + // Launch signal: the PROCESS, not the lock — a fresh profile's first-run + // initialization can delay SingletonLock well past any reasonable poll + // window, and waiting on the lock alone reported "Chrome never opened" + // against a Chrome that was busily initializing (2026-07-31 probe). The + // lock remains the QUIT signal below. A child that dies before the lock + // ever appears is the real launch failure, reported with its stderr. + const deadline = Date.now() + SIGN_IN_BUDGET_MS + while (!existsSync(lockPath)) { + if (!childAlive) { + throw new Error( + 'Chrome exited before opening the profile.\n' + + ` Saw (stderr tail): ${stderrTail.trim().slice(-500) || '(nothing)'}\n` + + ' Fix: run the binary by hand to reproduce: ' + + `"${chromeBinary}" --user-data-dir=${profileDir} ${NPM_ORIGIN}/login`, + ) + } + if (Date.now() > deadline) { + throw new Error( + 'Chrome is running but never adopted the profile (no lock appeared).', + ) + } + await sleep(POLL_MS) + } + while (existsSync(lockPath)) { + if (Date.now() > deadline) { + throw new Error( + `still signed in after ${SIGN_IN_BUDGET_MS / 60_000} minutes without quitting Chrome.\n` + + ' Fix: finish the sign-in, Cmd-Q Chrome, re-run — the profile keeps whatever you completed.', + ) + } + await sleep(POLL_MS) + } + logger.log('Chrome quit — verifying the seeded session through the driver…') + const session = await openNpmBrowserSession({ profileDir }) + try { + return session.user + } finally { + await session.close() + } +} + +async function main(): Promise { + const user = await seedNpmSignIn() + logger.success( + `signed in as ${user} — the shared profile now carries the session, ` + + 'and every driver launch reuses it (never re-logs-in).', + ) +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.fail(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/login.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/login.mts new file mode 100644 index 00000000..e1588c2a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/login.mts @@ -0,0 +1,146 @@ +/** + * @file Npm auth for the approve flow. The staging endpoints 401 without a + * token, and `pnpm stage list`'s failure output parses as an EMPTY stage + * list, so a missing login must be repaired BEFORE anything reads the stage + * list. On a real terminal `npm login` owns the flow; without a TTY the + * registry's web-login protocol runs by hand (npm login's non-TTY path + * bails to the legacy `Username:` prompt, which EOFs and dies in + * agent-driven runs — the runs `--yes` exists for). + */ + +import process from 'node:process' + +import { httpRequest } from '@socketsecurity/lib/http-request' +import { sleep } from '@socketsecurity/lib/promises/timers' + +import { + NPM_AUTH_TOKEN_KEY, + NPM_REGISTRY_URL, +} from '../../constants/npm-registry.mts' +import { npmScratchCwd } from './shared.mts' +import { logger, runCapture, runInherit } from '../shared.mts' + +// Best-effort: pop the default browser at `url`. Non-fatal when it can't +// (headless / CI) — the caller prints the URL either way. +async function openBrowser(url: string, cwd: string): Promise { + const opener = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open' + try { + await runCapture(opener, [url], cwd) + } catch { + // Printing the URL is the fallback; nothing to do. + } +} + +/** + * The registry's web-login protocol, done by hand: create a session + * (POST /-/v1/login), hand the human the login URL (opening the browser + * best-effort), poll `doneUrl` until the token arrives, persist it with + * `npm config set`. `npm login` isn't spawnable here: without a TTY its web + * flow bails to the legacy `Username:` prompt, which EOFs and dies in + * agent-driven runs — and those runs are the reason `--yes` exists. + */ +async function webLogin(scratchCwd: string): Promise { + // `npm-auth-type: web` is load-bearing: without it the registry 401s the + // session create, it gates the endpoint on the client declaring web auth. + const created = await httpRequest(`${NPM_REGISTRY_URL}/-/v1/login`, { + body: '{}', + headers: { + 'content-type': 'application/json', + 'npm-auth-type': 'web', + 'npm-command': 'login', + }, + method: 'POST', + }) + if (!created.ok) { + logger.fail(`Web-login session create failed (${created.status}).`) + return false + } + const session = created.json<{ + doneUrl?: string | undefined + loginUrl?: string | undefined + }>() + if (!session.loginUrl || !session.doneUrl) { + logger.fail('Web-login session response missing loginUrl/doneUrl.') + return false + } + logger.log(`Authenticate in the browser: ${session.loginUrl}`) + await openBrowser(session.loginUrl, scratchCwd) + // Poll until authenticated: 202 (+ retry-after) while pending, 200 + token + // once the human completes the browser challenge. Cap at ~10 minutes. + const deadline = Date.now() + 10 * 60 * 1000 + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + const done = await httpRequest(session.doneUrl, { + headers: { 'npm-auth-type': 'web', 'npm-command': 'login' }, + }) + if (done.status === 200) { + const { token } = done.json<{ token?: string | undefined }>() + if (!token) { + logger.fail('Web-login done response carried no token.') + return false + } + // `--location=user` anchors the write to the user npmrc no matter the + // cwd, so the scratch cwd never redirects where the token lands. + const { code } = await runCapture( + 'npm', + ['config', 'set', `${NPM_AUTH_TOKEN_KEY}=${token}`, '--location=user'], + npmScratchCwd(), + ) + if (code !== 0) { + logger.fail( + `Persisting the npm token failed (npm config set → ${code}).`, + ) + return false + } + logger.success('npm web login complete; token saved to the user npmrc.') + return true + } + if (done.status !== 202) { + logger.fail(`Web-login poll failed (${done.status}).`) + return false + } + const retryAfterHeader = done.headers['retry-after'] + const retryAfter = Number( + Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader, + ) + // eslint-disable-next-line no-await-in-loop + await sleep( + Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2000, + ) + } + logger.fail('Web-login timed out after 10 minutes.') + return false +} + +/** + * Ensure local npm auth before touching the staging endpoints — they 401 + * without a token, and `pnpm stage list`'s failure output parses as an EMPTY + * stage list, which would silently no-op the whole approve. When logged out: + * on a real terminal, defer to `npm login` (its web-first flow is the nicest + * UX there); without a TTY, run the web-login protocol directly. npm + * commands run from npmScratchCwd() — see its doc for why the temp dir is + * the only cwd that dodges both the repo's devEngines veto and lib spawn's + * untrusted-root PATH sanitization. + */ +export async function ensureNpmLogin(): Promise { + const scratchCwd = npmScratchCwd() + const { code } = await runCapture('npm', ['whoami'], scratchCwd) + if (code === 0) { + return true + } + logger.log('Not logged in to npm — starting browser login…') + if (process.stdin.isTTY) { + const login = await runInherit('npm', ['login'], scratchCwd) + if (login !== 0) { + logger.fail(`npm login exited ${login}.`) + return false + } + return true + } + return await webLogin(scratchCwd) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-manifest.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-manifest.mts new file mode 100644 index 00000000..f903aa5f --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-manifest.mts @@ -0,0 +1,86 @@ +/** + * @file Pack-time manifest scrub — strip repo-only lifecycle scripts from the + * manifest that packs. The fleet manifest declares consumer-visible lifecycle + * scripts (`preinstall` → scripts/socket-release/setup/…) whose targets are + * repo scaffolding the `files` field never ships, so the published tarball's + * manifest points at files it does not carry and every consumer install + * breaks (the sdk 4.0.3 incident). Same shape as the README pin in + * ../pin-readme.mts: rewrite the on-disk manifest around the pack, ALWAYS + * restore the original bytes (try/finally), and wrap EVERY pack of one + * release, stage, direct, approve-time verify re-pack, release-asset pack so + * the integrity gates keep comparing identical bytes. npm-only: cargo + * manifests have no lifecycle scripts. + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' + +import { findDanglingLifecycleScripts } from '../../_shared/lifecycle-scripts.mts' +import { writeThroughMirrorLock } from '../../_shared/mirror-lock.mts' +import { isCoveredByFiles } from '../../_shared/pack-files.mts' +import { logger } from '../shared.mts' + +import type { DanglingLifecycleScript } from '../../_shared/lifecycle-scripts.mts' + +interface ManifestShape { + files?: string[] | undefined + scripts?: Record | undefined +} + +/** + * The lifecycle scripts of the manifest at `subjectDir` whose `node ` + * targets will not be in the pack file set — the target file is missing on + * disk, a dangling ref, or not covered by the `files` field (repo-only + * scaffolding npm never packs). Exported for tests. + */ +export function danglingLifecycleScriptsFor( + manifest: ManifestShape, + subjectDir: string, +): DanglingLifecycleScript[] { + return findDanglingLifecycleScripts( + manifest.scripts, + rel => + existsSync(path.join(subjectDir, rel)) && + isCoveredByFiles(rel, manifest.files), + ) +} + +/** + * Run `fn` with the publish subject's package.json temporarily rewritten to + * drop every lifecycle script whose target is not in the pack file set, so + * the tarball's manifest never references files the tarball does not carry. + * Each strip is logged loud. The original manifest bytes are ALWAYS restored + * (try/finally); a manifest with nothing to strip runs `fn` untouched. + * Returns `fn`'s result. + */ +export async function withPrunedPackManifest( + subjectDir: string, + fn: () => Promise, +): Promise { + const manifestPath = path.join(subjectDir, 'package.json') + let original: string + try { + original = readFileSync(manifestPath, 'utf8') + } catch { + // No readable manifest — the pack itself will fail loud; nothing to prune. + return await fn() + } + const manifest = JSON.parse(original) as ManifestShape + const dangling = danglingLifecycleScriptsFor(manifest, subjectDir) + if (!dangling.length || !manifest.scripts) { + return await fn() + } + for (const d of dangling) { + delete manifest.scripts[d.name] + logger.warn( + `[pack-manifest] stripping repo-only lifecycle script "${d.name}" ` + + `(${d.command}) — not in the pack file set: ${d.missing.join(', ')}`, + ) + } + writeThroughMirrorLock(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + try { + return await fn() + } finally { + writeThroughMirrorLock(manifestPath, original) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-preflight.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-preflight.mts new file mode 100644 index 00000000..6ee00b57 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/pack-preflight.mts @@ -0,0 +1,136 @@ +/** + * @file Pack preflight — the tarball-level hollow gate every npm publish + * stands behind. Packs the publish subject with `pnpm pack` and requires + * every declared payload file (the literal `files` entries plus `main`, + * via requiredPayloadFiles) present INSIDE the packed tarball before any + * stage/publish command runs. The disk-level hollow gate + * (findHollowPackages) covers platform packages' working trees only; this + * gate reads the packed bytes themselves, for every package, so a manifest + * that declares payload its build never produced can never stage or + * publish a hollow tarball. Callers invoke it INSIDE the same README-pin + + * manifest-prune brackets as the real publish, so the inspected bytes + * match the upload. + */ + +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { normalizePath } from '@socketsecurity/lib/paths/normalize' + +import { logger, runCapture } from '../shared.mts' +import { requiredPayloadFiles } from './workspace-plan.mts' +import { tarExecutable } from '../../_shared/tar-executable.mts' + +import type { WorkspaceManifestShape } from './workspace.mts' + +export interface PackPreflightConfig { + /** + * The directory `pnpm pack` runs in — the publish subject's own directory + * pnpm packs the cwd package and writes the tarball there. + */ + dir: string + manifest: WorkspaceManifestShape + name: string + version: string +} + +/** + * Pack the package at `config.dir` and require every declared payload file + * (requiredPayloadFiles over `config.manifest`) inside the tarball. A `files` + * entry naming a directory is satisfied by any contained file path (npm + * tarballs carry file entries only, every one rooted at `package/`). The + * preflight tarball exists only to be inspected — it is always deleted, pass + * or fail. Returns true when every required entry is present; fails LOUD + * (What / Where / Saw-vs-wanted / Fix) and returns false otherwise, so the + * caller can refuse to run the publish command. + */ +export async function verifyPackedPayload( + config: PackPreflightConfig, +): Promise { + const cfg = { __proto__: null, ...config } as PackPreflightConfig + const { dir, manifest, name, version } = cfg + const required = requiredPayloadFiles(manifest) + if (required.length === 0) { + // The manifest declares no concrete payload (an .npmignore-shaped + // package) — there is nothing the tarball can be missing; skip the pack. + return true + } + const tarballName = `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz` + const tarballPath = path.join(dir, tarballName) + // Ignoring scripts keeps the preflight pack byte-identical to the guarded + // publish commands (which all pass --ignore-scripts): a prepack/postpack + // lifecycle script must neither shape THIS tarball differently nor mutate + // the tree the real pack reads afterwards. `pnpm pack` rejects the bare + // --ignore-scripts flag; the --config form is its accepted spelling. + const packed = await runCapture( + 'pnpm', + ['pack', '--config.ignore-scripts=true'], + dir, + ) + try { + const tarballExists = existsSync(tarballPath) + if (packed.code !== 0 || !tarballExists) { + logger.fail( + `Pack preflight FAILED for ${name}@${version}.\n` + + ` Where: pnpm pack in ${dir}\n` + + ` Saw vs wanted: exit ${packed.code}, tarball ` + + `${tarballExists ? 'present' : 'absent'}; wanted exit 0 + ` + + `${tarballName} to inspect before any upload.\n` + + ` Fix: make \`pnpm pack\` succeed in that directory, then re-run.`, + ) + return false + } + const listing = await runCapture( + tarExecutable(), + ['-tzf', tarballPath], + dir, + ) + if (listing.code !== 0) { + logger.fail( + `Pack preflight FAILED for ${name}@${version}.\n` + + ` Where: listing ${tarballPath} (tar -tzf exited ${listing.code})\n` + + ` Saw vs wanted: an unreadable tarball; wanted its entry list to ` + + `check the declared payload.\n` + + ` Fix: make \`pnpm pack\` produce a readable tarball, then re-run.`, + ) + return false + } + // npm roots every tarball entry at `package/`. Normalize separators (a + // Windows tar can list `\`-joined paths) before any '/'-sensitive match. + const entries = listing.stdout + .split('\n') + .map(line => normalizePath(line.trim())) + .filter(entry => entry.length > 0) + const missing = required.filter(rel => { + // npm accepts leading-slash files entries ("/dist") and packs them as + // repo-relative; strip the slashes or `wanted` becomes `package//dist` + // and a valid tarball reads as hollow. + const wanted = `package/${normalizePath(rel).replace(/^\/+/, '')}` + return !entries.some( + entry => entry === wanted || entry.startsWith(`${wanted}/`), + ) + }) + if (missing.length > 0) { + logger.fail( + `Pack preflight FAILED for ${name}@${version}: the packed tarball ` + + `is HOLLOW.\n` + + ` Where: ${tarballPath}\n` + + ` Saw vs wanted: missing ${missing.join(', ')}; wanted every ` + + `literal files/main entry inside the tarball.\n` + + ` Fix: the build must produce the declared payload before ` + + `publishing — run it, confirm the files exist, then re-run. ` + + `Nothing was uploaded.`, + ) + return false + } + logger.log( + `Pack preflight passed for ${name}@${version}: ${required.length} ` + + `declared payload entr${required.length === 1 ? 'y' : 'ies'} present ` + + `in ${tarballName}.`, + ) + return true + } finally { + await safeDelete(tarballPath) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/pinned-npm.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/pinned-npm.mts new file mode 100644 index 00000000..ba97f7a6 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/pinned-npm.mts @@ -0,0 +1,137 @@ +/** + * @file Resolve the npm that ships with the repo's PINNED Node, rather than + * whichever npm sits on PATH. + * WHY THIS EXISTS. The two disagree, and the gap is not cosmetic. A promote + * run against a Homebrew npm 11.17.0 while `.node-version` pinned Node 26.5.0 + * used a binary BELOW the repo's own `engines.npm` floor of >=12.0.1 — the + * staging API surface (`npm stage approve|reject`) is exactly where that + * matters, because it is 2FA-gated and irreversible. The pinned Node bundles + * npm 12.0.1; PATH offered 11.17.0. + * Also WHY NPM AT ALL for staging: pnpm's `stage` commands print the web-auth + * URL and then block on an interactive ENTER before opening the browser, so + * they cannot complete from an agent channel — they sit until + * ERR_PNPM_WEBAUTH_TIMEOUT. npm's flow opens the browser and polls, which the + * `npm-web-auth.mts` PTY wrapper already services. npm is the runner for + * stage operations; pnpm remains the package manager everywhere else. + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +/** + * The trimmed `.node-version` pin at `repoRoot`, or undefined when absent or + * unreadable. + */ +export function readNodePin(repoRoot: string): string | undefined { + try { + return ( + readFileSync(path.join(repoRoot, '.node-version'), 'utf8').trim() || + undefined + ) + } catch { + return undefined + } +} + +/** + * Candidate npm paths for `version`, in the order a resolver should try them. + * Covers the version managers the fleet runs on: fnm, nvm, and asdf. Pure so + * the layout knowledge is testable without those managers installed. + */ +export function pinnedNpmCandidates( + version: string, + home: string, + platform: NodeJS.Platform = process.platform, +): string[] { + const bin = platform === 'win32' ? 'npm.cmd' : 'npm' + const v = version.startsWith('v') ? version : `v${version}` + const bare = v.slice(1) + return [ + path.join( + home, + '.local/share/fnm/node-versions', + v, + 'installation/bin', + bin, + ), + path.join(home, '.fnm/node-versions', v, 'installation/bin', bin), + path.join(home, '.nvm/versions/node', v, 'bin', bin), + path.join(home, '.asdf/installs/nodejs', bare, 'bin', bin), + ] +} + +export interface PinnedNpmResolution { + // Absolute path to the resolved npm, or undefined when none was found. + readonly npmPath: string | undefined + // The `.node-version` pin the lookup used, when the repo declares one. + readonly pin: string | undefined + // Why a caller should refuse, or undefined when the resolution is usable. + readonly refusal: string | undefined +} + +/** + * Locate the npm bundled with the pinned Node. + * + * Returns a refusal rather than throwing: a caller mid-release wants to report + * What / Where / Saw / Fix and stop, not unwind a stack. `exists` is injected + * so the lookup is testable without a version manager on the box. + */ +export function resolvePinnedNpm(config: { + exists?: ((p: string) => boolean) | undefined + home: string + platform?: NodeJS.Platform | undefined + repoRoot: string +}): PinnedNpmResolution { + const cfg = { __proto__: null, ...config } as typeof config + const fileExists = cfg.exists ?? existsSync + const pin = readNodePin(cfg.repoRoot) + if (!pin) { + return { + npmPath: undefined, + pin: undefined, + refusal: + 'no .node-version pin.\n' + + ` What: staging runs npm, and the npm to run is the one bundled with the pinned Node.\n` + + ` Where: ${cfg.repoRoot}/.node-version\n` + + ` Saw: the file is absent or empty; wanted a version such as 26.5.0.\n` + + ` Fix: add the pin, or pass an explicit npm path.`, + } + } + const candidates = pinnedNpmCandidates(pin, cfg.home, cfg.platform) + for (let i = 0, { length } = candidates; i < length; i += 1) { + // oxlint-disable-next-line socket/prefer-exists-sync -- injected seam, not a wrapper: the fnm/nvm/asdf layouts must be probed in tests on a box where none of those managers are installed. + if (fileExists(candidates[i]!)) { + return { npmPath: candidates[i]!, pin, refusal: undefined } + } + } + return { + npmPath: undefined, + pin, + refusal: + `no npm found for the pinned Node ${pin}.\n` + + ` What: a stage operation is 2FA-gated and irreversible, so it runs the\n` + + ` PINNED npm rather than whatever PATH offers — those disagreed by a\n` + + ` major once, below the repo's own engines.npm floor.\n` + + ` Where: looked under fnm, nvm, and asdf layouts for ${pin}.\n` + + ` Saw: none of ${candidates.length} candidate paths exist.\n` + + ` Fix: install Node ${pin} with your version manager, then re-run.`, + } +} + +// The npm stage subcommands. `publish` is deliberately ABSENT: staging an +// upload is CI's job through npm-publish.yml, never a local run. +export const NPM_STAGE_SUBCOMMANDS: readonly string[] = [ + 'approve', + 'download', + 'list', + 'reject', + 'view', +] + +/** + * True when `subcommand` is a stage operation this layer will run. + */ +export function isNpmStageSubcommand(subcommand: string): boolean { + return NPM_STAGE_SUBCOMMANDS.includes(subcommand) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/placeholder.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/placeholder.mts new file mode 100644 index 00000000..0f72ed8b --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/placeholder.mts @@ -0,0 +1,386 @@ +#!/usr/bin/env node +/** + * @file One-time npm name-reservation bootstrap. npm trusted publishing (OIDC) + * can only be CONFIGURED for a package name that ALREADY EXISTS on the + * registry — but a brand-new package has no name to configure the trusted + * publisher against, a chicken-and-egg. This script breaks it: it publishes a + * minimal `0.0.0` reservation (a package.json + a one-line README, and + * nothing else) to CLAIM the name, so the OIDC trusted-publisher can then be + * wired up in the npm UI. Real releases go out via CI afterward (staged + + * provenance) — this is the SANCTIONED one-time LOCAL publish, the only + * local-publish carve-out in the fleet. + * Each name assembles a fresh temp dir containing ONLY those two files (an + * empty `files: []` guarantees nothing else ships) and runs + * `npm publish --access ` from it. + * CLI: placeholder [--access public|restricted] [--apply] + * Dry-run by default, prints the plan, publishes nothing; `--apply` performs + * the publish. Per-name isolation: one name failing never aborts the rest, and + * a summary prints at the end. Fail-soft — main() catches, logs, and sets a + * non-zero exit code; it never throws. + * Usage: node scripts/socket-release/publish-infra/npm/placeholder.mts @scope/pkg\ + * other-pkg --access public --apply + */ + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' + +import { isMainModule } from '../../_shared/is-main-module.mts' +import { runNpmWebAuth } from '../../npm-web-auth.mts' +import { NAPI_TARGETS_DEFAULT } from '../../util/napi-targets.mts' +import { logger } from '../shared.mts' +import { safeDelete } from '@socketsecurity/lib/fs/safe' + +// The reservation version. Deliberately the lowest possible semver so the real +// first release (any 0.0.1+ / 1.0.0) always supersedes it as `latest`. +export const PLACEHOLDER_VERSION = '0.0.0' + +export type Access = 'public' | 'restricted' + +export interface PlaceholderPackageJson { + name: string + version: string + // `false` so npm doesn't refuse the publish outright — a reservation must be + // publishable, and the point is to claim the name on the public registry. + private: false + // Mirror the CLI `--access` into the manifest too, so the reservation's + // access is self-describing, belt-and-suspenders with the publish flag. + publishConfig: { access: Access } + // Empty allow-list: npm still ships the always-included files (package.json + + // README.md) and NOTHING else — the reservation carries no code. + files: string[] +} + +export interface PlaceholderArgs { + access: Access + apply: boolean + names: string[] +} + +export type PlaceholderStatus = 'published' | 'planned' | 'skipped' | 'failed' + +export interface PlaceholderResult { + name: string + status: PlaceholderStatus + detail?: string | undefined +} + +export interface RunPlaceholderOptions { + // The publish executor. Defaults to `npm publish --access ` run from + // the temp dir; injected in tests so no real registry call happens. + publishExec?: ((dir: string, access: Access) => Promise) | undefined + // Temp-dir assembler; injectable so plan tests can avoid touching disk. + assembleDir?: ((name: string, access: Access) => Promise) | undefined + // Temp-dir cleanup; injectable for the same reason. + removeDir?: ((dir: string) => Promise) | undefined +} + +/** + * Build the reservation package.json for `name` at `access`. Pure — the exact + * on-disk shape (see PlaceholderPackageJson): name, `0.0.0`, `private: false`, + * the access, and an empty `files` allow-list so only package.json + README + * ship. + */ +export function buildPlaceholderPackageJson( + name: string, + access: Access, +): PlaceholderPackageJson { + return { + name, + version: PLACEHOLDER_VERSION, + private: false, + publishConfig: { access }, + files: [], + } +} + +/** + * The one-line reservation README for `name`. Pure. Trailing newline so the + * file is POSIX-clean. This is the only prose that ships in the reservation. + */ +export function buildPlaceholderReadme(name: string): string { + return ( + `# ${name}\n\n` + + 'Placeholder to reserve the name for npm trusted publishing. ' + + 'Real releases publish via CI (OIDC/provenance).\n' + ) +} + +/** + * A pragmatic npm package-name gate: length 1–214, no leading `.`/`_`, no + * uppercase or spaces, url-safe chars; scoped (`@scope/name`) or unscoped. The + * registry is the final arbiter — this only skips OBVIOUSLY invalid names + * before we bother assembling + publishing them. Pure. + */ +export function isValidNpmPackageName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0 || name.length > 214) { + return false + } + if (name.trim() !== name) { + return false + } + const segment = '[a-z0-9][a-z0-9._-]*' + const scoped = new RegExp(`^@${segment}/${segment}$`) + const unscoped = new RegExp(`^${segment}$`) + return scoped.test(name) || unscoped.test(name) +} + +/** + * Create a fresh temp dir under `tmpBase` (defaults to the OS temp dir) holding + * EXACTLY the reservation's package.json + README.md, and return its path. The + * caller owns cleanup (see runPlaceholder's finally). `tmpBase` is injectable + * for hermetic tests. + */ +export async function assemblePlaceholderDir( + name: string, + access: Access, + tmpBase: string = os.tmpdir(), +): Promise { + const dir = await fs.mkdtemp(path.join(tmpBase, 'socket-npm-placeholder-')) + const pkg = buildPlaceholderPackageJson(name, access) + await fs.writeFile( + path.join(dir, 'package.json'), + `${JSON.stringify(pkg, null, 2)}\n`, + 'utf8', + ) + await fs.writeFile( + path.join(dir, 'README.md'), + buildPlaceholderReadme(name), + 'utf8', + ) + return dir +} + +// Default publish executor: the sanctioned one-time LOCAL publish. Routes +// `npm publish --access `, run from the assembled temp dir, through +// the npm-web-auth PTY wrapper: on a real TTY, or with --otp supplied, the +// wrapper execs npm directly; from a NON-interactive agent shell it allocates +// a PTY so npm's 2FA web-auth flow opens the browser and polls for approval +// instead of dying EOTP once per name, the ajar-reservation incident shape. +async function defaultPublishExec( + dir: string, + access: Access, +): Promise { + return await runNpmWebAuth({ + argv: ['publish', '--access', access], + cwd: dir, + env: process.env, + isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY), + platform: process.platform, + }) +} + +async function defaultRemoveDir(dir: string): Promise { + await safeDelete(dir) +} + +/** + * One-line human summary of the run: counts by status, tagged with the mode. + * Pure — exported for tests. + */ +export function formatSummary( + results: readonly PlaceholderResult[], + config: { apply: boolean }, +): string { + const cfg = { __proto__: null, ...config } as { apply: boolean } + const count = (status: PlaceholderStatus): number => + results.filter(r => r.status === status).length + return ( + `Placeholder ${cfg.apply ? 'publish' : 'dry-run'} summary: ` + + `${count('published')} published, ${count('planned')} planned, ` + + `${count('skipped')} skipped, ${count('failed')} failed.` + ) +} + +/** + * Reserve each name, isolated. For every name: validate it (invalid → skipped), + * assemble its temp dir, then either PRINT the plan (dry-run) or run the + * publish (`--apply`). A thrown error or non-zero publish exit for one name is + * recorded as `failed` and never aborts the others; every assembled dir is + * cleaned up. Logs a summary and returns the per-name results (for tests + the + * caller's exit-code decision). + */ +export async function runPlaceholder( + args: PlaceholderArgs, + options?: RunPlaceholderOptions | undefined, +): Promise { + const opts = { __proto__: null, ...options } as RunPlaceholderOptions + const assembleDir = opts.assembleDir ?? assemblePlaceholderDir + const publishExec = opts.publishExec ?? defaultPublishExec + const removeDir = opts.removeDir ?? defaultRemoveDir + const { access, apply, names } = args + + const results: PlaceholderResult[] = [] + for (let i = 0, { length } = names; i < length; i += 1) { + const name = names[i]! + if (!isValidNpmPackageName(name)) { + logger.warn(`Skipping invalid npm package name: ${JSON.stringify(name)}`) + results.push({ + name, + status: 'skipped', + detail: 'invalid npm package name', + }) + continue + } + try { + // eslint-disable-next-line no-await-in-loop + const dir = await assembleDir(name, access) + try { + if (!apply) { + logger.log( + `[dry-run] ${name}@${PLACEHOLDER_VERSION} — would run ` + + `\`npm publish --access ${access}\` from ${dir} ` + + `(package.json + README.md only). Re-run with --apply to publish.`, + ) + results.push({ name, status: 'planned' }) + continue + } + logger.log( + `Publishing reservation ${name}@${PLACEHOLDER_VERSION} ` + + `(--access ${access})…`, + ) + // eslint-disable-next-line no-await-in-loop + const code = await publishExec(dir, access) + if (code === 0) { + logger.success( + `Reserved ${name}@${PLACEHOLDER_VERSION}. Configure the OIDC ` + + `trusted publisher in the npm UI, then release via CI. ` + + `A 404 from \`npm view\` right after this is the account's ` + + `STAGED publishing, not a failed publish — promote the staged ` + + `package in the npm UI to make the name publicly readable.`, + ) + results.push({ name, status: 'published' }) + } else { + logger.fail(`npm publish exited ${code} for ${name}.`) + results.push({ + name, + status: 'failed', + detail: `npm publish exited ${code}`, + }) + } + } finally { + // eslint-disable-next-line no-await-in-loop + await removeDir(dir) + } + } catch (e) { + logger.error(`${name}: ${errorMessage(e)}`) + results.push({ name, status: 'failed', detail: errorMessage(e) }) + } + } + + logger.log('') + logger.log(formatSummary(results, { apply })) + return results +} + +// A dotted target token after the package's base name means the caller passed +// an implementation/platform package, not a meta-selector — expanding it would +// mint malformed names like `x.node-a.node-b`. +const DOTTED_TARGET_TOKEN_RE = /\.(?:exe|node|wasm)(?:[.-]|$)/ + +/** + * Expand a napi meta-selector into its placeholder family: the meta name plus + * one `.node-` platform package per fleet-default napi target, per the + * dot-naming grammar `@/[.].[-]`. The + * platform set derives from the canonical `NAPI_TARGETS_DEFAULT`, so a matrix + * change reaches reservations automatically. Returns `undefined` when `meta` + * already carries a target token (the caller named an implementation package, + * not a family). Pure — exported for tests. + */ +export function expandNapiFamily(meta: string): string[] | undefined { + if (DOTTED_TARGET_TOKEN_RE.test(meta)) { + return undefined + } + return [meta, ...NAPI_TARGETS_DEFAULT.map(t => `${meta}.node-${t}`)] +} + +/** + * Parse `placeholder [--access public|restricted] [--napi-family] + * [--apply]`. `--access` defaults to `public`; dry-run is the default (no + * `--apply`). Positional args are package names; with `--napi-family` each + * positional is a napi meta-selector expanded to its full reservation family + * (meta + the 5 fleet-default `.node-` platform packages), so a + * family claim is ONE short argument instead of a six-name command line that + * wraps in a terminal and silently drops names. Exits, usage error, on an + * unknown flag, a bad `--access` value, a `--napi-family` positional that + * already carries a target token, or when no names are given. + */ +export function parseArgs(argv: readonly string[]): PlaceholderArgs { + let access: Access = 'public' + let apply = false + let napiFamily = false + const names: string[] = [] + for (let i = 0, { length } = argv; i < length; i += 1) { + const arg = argv[i]! + if (arg === '--apply') { + apply = true + } else if (arg === '--napi-family') { + napiFamily = true + } else if (arg === '--access') { + const v = argv[++i] + if (v !== 'public' && v !== 'restricted') { + logger.fail( + `--access must be 'public' or 'restricted' (saw ${String(v)}).`, + ) + process.exit(1) + } + access = v + } else if (arg === '--access=public' || arg === '--access=restricted') { + access = arg.slice('--access='.length) as Access + } else if (arg.startsWith('-')) { + logger.fail(`Unknown flag: ${arg}`) + process.exit(1) + } else { + names.push(arg) + } + } + if (names.length === 0) { + logger.fail( + 'Usage: placeholder [--access public|restricted] ' + + '[--napi-family] [--apply]', + ) + process.exit(1) + } + if (napiFamily) { + const expanded: string[] = [] + for (let i = 0, { length } = names; i < length; i += 1) { + const name = names[i]! + const family = expandNapiFamily(name) + if (!family) { + logger.fail( + `--napi-family expands meta-selectors, but ${name} already ` + + `carries a .node/.exe/.wasm target token. Where: argv. Fix: ` + + `pass the bare meta name (e.g. @socketsecurity/ajar).`, + ) + process.exit(1) + } + expanded.push(...family) + } + return { access, apply, names: expanded } + } + return { access, apply, names } +} + +export async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + logger.log( + `npm placeholder reservation — ${args.names.length} name(s), ` + + `--access ${args.access}${args.apply ? ' [apply]' : ' [dry-run]'}`, + ) + const results = await runPlaceholder(args) + if (results.some(r => r.status === 'failed')) { + process.exitCode = 1 + } +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the CLI. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/provenance.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/provenance.mts new file mode 100644 index 00000000..4dca58a1 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/provenance.mts @@ -0,0 +1,232 @@ +/* + * @file The npm SLSA provenance read — "which git commit actually produced + * this published artifact?". The registry answers at + * `/-/npm/v1/attestations/@`, returning an ARRAY of + * attestations. Two traps live in that array and both cost real debugging + * time, so they are encoded here once rather than at each call site: + * + * 1. Index 0 is npm's own PUBLISH attestation + * (`https://github.com/npm/attestation/tree/main/specs/publish/v0.1`), + * not the SLSA provenance. It carries no source commit at all, so a + * `attestations[0]` read yields nothing and looks like "no provenance". + * Select by `predicateType` containing `slsa`, never by position. + * 2. The payload is a base64 DSSE envelope, not inline JSON. + * + * The source commit lands at + * `predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit`, and + * its sibling `uri` names the ref the build checked out + * (`git+https://github.com//@refs/heads/main`). + * + * Every read is classified rather than collapsed to undefined: a registry + * that ANSWERED "this version has no provenance" (404) is a different fact + * from a registry that could not be reached, and a gate that conflates them + * reports a green it did not earn. + */ + +import { httpJson, HttpResponseError } from '@socketsecurity/lib/http-request' + +import { NPM_REGISTRY_URL } from '../../constants/npm-registry.mts' + +// Attestation reads are small JSON documents; the registry answers fast or not +// at all, and a release gate must not hang a CI lane on a stalled socket. +const ATTESTATION_TIMEOUT_MS = 15_000 + +/** + * The git source an SLSA provenance statement names: the commit that produced + * the artifact, and the ref URI the build checked out. Either may be absent + * from a malformed statement, so both are optional and the caller decides. + */ +export interface AttestedGitSource { + gitCommit: string | undefined + uri: string | undefined +} + +/** + * A classified attestation read. `unprovenanced` means the registry ANSWERED + * and this version has no SLSA statement — a fact about the release. + * `unreadable` means the question could not be asked (offline lane, 5xx, + * malformed payload) — a fact about the environment. Collapsing the two is how + * a provenance gate reports a false green. + */ +export type AttestationRead = + | { detail: string; kind: 'unprovenanced' } + | { detail: string; kind: 'unreadable' } + | { kind: 'attested'; source: AttestedGitSource } + +/** + * The registry attestation endpoint for one published version. Scoped names + * keep their leading `@` (the registry rejects the percent-encoded form) while + * the scope separator stays encoded, matching `registry.mts`'s packument URLs. + */ +export function npmAttestationUrl(name: string, version: string): string { + const encoded = encodeURIComponent(name).replace('%40', '@') + return `${NPM_REGISTRY_URL}/-/npm/v1/attestations/${encoded}@${version}` +} + +/** + * The one attestation in the endpoint's array whose `predicateType` names + * SLSA. Pure, and the guard against the index-0 publish-attestation trap + * described in this file's header. + */ +export function selectSlsaAttestation( + attestations: readonly unknown[], +): + | { bundle?: unknown | undefined; predicateType?: unknown | undefined } + | undefined { + for (let i = 0, { length } = attestations; i < length; i += 1) { + const entry = attestations[i] as + | { bundle?: unknown | undefined; predicateType?: unknown | undefined } + | undefined + if ( + entry && + typeof entry.predicateType === 'string' && + entry.predicateType.includes('slsa') + ) { + return entry + } + } + return undefined +} + +/** + * Decode a Sigstore bundle's DSSE envelope payload into its in-toto statement. + * Returns undefined when the bundle is not shaped as expected or the payload + * is not base64-encoded JSON — an unparseable bundle is `unreadable`, never a + * pass. Pure. + */ +export function decodeDsseStatement(bundle: unknown): unknown { + const payload = ( + bundle as + | { dsseEnvelope?: { payload?: unknown | undefined } | undefined } + | undefined + )?.dsseEnvelope?.payload + if (typeof payload !== 'string' || payload.length === 0) { + return undefined + } + try { + return JSON.parse(Buffer.from(payload, 'base64').toString('utf8')) + } catch { + return undefined + } +} + +/** + * The git source named by a decoded in-toto SLSA statement, or undefined when + * the statement carries no resolved dependency. Pure. + */ +export function readStatementGitSource( + statement: unknown, +): AttestedGitSource | undefined { + const resolved = ( + statement as + | { + predicate?: + | { + buildDefinition?: + | { resolvedDependencies?: unknown | undefined } + | undefined + } + | undefined + } + | undefined + )?.predicate?.buildDefinition?.resolvedDependencies + if (!Array.isArray(resolved) || resolved.length === 0) { + return undefined + } + const first = resolved[0] as + | { + digest?: { gitCommit?: unknown | undefined } | undefined + uri?: unknown | undefined + } + | undefined + const gitCommit = first?.digest?.gitCommit + const { uri } = first ?? {} + return { + gitCommit: typeof gitCommit === 'string' ? gitCommit : undefined, + uri: typeof uri === 'string' ? uri : undefined, + } +} + +/** + * Classify a raw attestation-endpoint body. Pure — the whole decode path is + * unit-testable from a fixture without touching the network, which is what + * lets the release-tag gate's tests inject a registry seam. + */ +export function classifyAttestationBody(body: unknown): AttestationRead { + const attestations = ( + body as { attestations?: unknown | undefined } | undefined + )?.attestations + if (!Array.isArray(attestations) || attestations.length === 0) { + return { + detail: 'the attestation endpoint returned no attestations', + kind: 'unprovenanced', + } + } + const slsa = selectSlsaAttestation(attestations) + if (!slsa) { + const seen = attestations + .map(a => + String((a as { predicateType?: unknown | undefined })?.predicateType), + ) + .join(', ') + return { + detail: `no SLSA predicateType among the attestations (saw: ${seen})`, + kind: 'unprovenanced', + } + } + const statement = decodeDsseStatement(slsa.bundle) + if (statement === undefined) { + return { + detail: 'the SLSA attestation bundle carried no decodable DSSE payload', + kind: 'unreadable', + } + } + const source = readStatementGitSource(statement) + if (!source || !source.gitCommit) { + return { + detail: + 'the SLSA statement named no predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit', + kind: 'unreadable', + } + } + return { kind: 'attested', source } +} + +/** + * A provenance reader — the seam the release-tag gate injects so its tests + * exercise every branch without a network call. + */ +export type ProvenanceReader = ( + name: string, + version: string, +) => Promise + +/** + * Read `@`'s SLSA provenance from the npm registry. A 404 is + * the registry ANSWERING that the version has no attestations + * (`unprovenanced`); every other failure is `unreadable`, so an offline lane + * can never be mistaken for a clean release. + */ +export async function fetchAttestedGitSource( + name: string, + version: string, +): Promise { + try { + const body = await httpJson(npmAttestationUrl(name, version), { + headers: { accept: 'application/json' }, + timeout: ATTESTATION_TIMEOUT_MS, + }) + return classifyAttestationBody(body) + } catch (e) { + if (e instanceof HttpResponseError && e.response.status === 404) { + return { + detail: 'the registry has no attestations for this version (404)', + kind: 'unprovenanced', + } + } + return { + detail: `the attestation endpoint could not be read (${e instanceof HttpResponseError ? `HTTP ${e.response.status}` : 'network error'})`, + kind: 'unreadable', + } + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/registry.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/registry.mts new file mode 100644 index 00000000..ea785c76 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/registry.mts @@ -0,0 +1,429 @@ +/** + * @file Npm-registry reads for the publish flow: the already-published probe + * and the packument trust-metadata fetch (provenance attestations, + * staged-publish approver, trusted-publisher attribution). + */ + +import crypto from 'node:crypto' + +import { httpJson, HttpResponseError } from '@socketsecurity/lib/http-request' + +import { packumentUrl } from '../../constants/npm-registry.mts' + +import type { RegistryLatestRead } from '../../lib/release-anchor.mts' + +/** + * A cache-busting registry read: the packument URL with a unique `_cb` nonce + * query param appended, plus no-cache request headers layered over `accept`. + * + * WHY: the npm registry serves packuments through a CDN that caches them for + * MINUTES. A release gate that trusts a cached read can see a version that is + * already LIVE on the registry as ABSENT — or read a stale `dist-tags.latest` + * — and mis-decide. The @socketregistry/packageurl-js@X.Y.Z near-miss staged + * an already-published version because both `npm view` and a raw packument + * fetch were served a stale CDN copy that still showed the prior version. A + * unique query param defeats the CDN cache key; `Cache-Control: no-cache` + + * `Pragma: no-cache` defeat any intermediary proxy. Pure — `nonce` is + * injectable so a test can assert the exact busting applied. + */ +export function cacheBustedRead( + url: string, + accept: string, + nonce: string = crypto.randomUUID(), +): { headers: Record; url: string } { + const separator = url.includes('?') ? '&' : '?' + return { + headers: { + accept, + 'cache-control': 'no-cache', + pragma: 'no-cache', + }, + url: `${url}${separator}_cb=${nonce}`, + } +} + +/** + * The registry `dist-tags.latest` for a package, distinguishing "the registry + * answered: never published" (a 404 — `reachable: true, latest: undefined`) + * from "the registry could not be consulted" (network failure, timeout, 5xx — + * `reachable: false`). Reads the packument (not `npm view`, which trips this + * repo's pnpm devEngines). The changelog anchor derivation hard-stops on + * `reachable: false`: offline, the released base cannot be confirmed and a + * stale local tag would silently widen the range. + */ +export async function fetchLatestPublishedVersionChecked( + name: string, +): Promise { + const url = packumentUrl(name) + const read = cacheBustedRead(url, 'application/vnd.npm.install-v1+json') + try { + const json = await httpJson<{ + 'dist-tags'?: { latest?: string | undefined } | undefined + }>(read.url, { + headers: read.headers, + timeout: 15_000, + }) + return { latest: json['dist-tags']?.latest, reachable: true } + } catch (e) { + if (e instanceof HttpResponseError && e.response.status === 404) { + return { latest: undefined, reachable: true } + } + return { reachable: false } + } +} + +/** + * The registry `dist-tags.latest` for a package — the currently-published + * version — or undefined on any failure/unpublished. The tolerant twin of + * reconcile's throwing reader and of `fetchLatestPublishedVersionChecked`: + * callers that only display or compare a best-effort latest (version-ahead + * check, reconcile) must NOT throw on a first-publish / offline registry — it + * returns undefined and the caller falls back. + */ +export async function fetchLatestPublishedVersion( + name: string, +): Promise { + const read = await fetchLatestPublishedVersionChecked(name) + return read.reachable ? read.latest : undefined +} + +/** + * The registry state the backfill gate reads in one packument fetch: the + * `dist-tags.latest` pointer plus the `time` map. The time map is the + * registry's PERMANENT publish ledger — it keeps an entry for every version + * ever published, including versions later unpublished — so it is the one + * source that can prove a version was NEVER published. Requires the full + * packument; the abbreviated format drops `time`. + */ +export interface RegistryReleaseState { + latest: string | undefined + timeMap: Record + /** + * The LIVE `versions` set — what is public right now. Together with the + * time map it splits publish history: never published, currently + * published, and published-then-unpublished. + */ + versions: string[] +} + +/** + * Fetch `RegistryReleaseState` for a package, or undefined on ANY failure — + * network, 404, or a packument without a `time` map. The backfill gate fails + * CLOSED on undefined: an unreadable publish ledger is never treated as an + * empty one. + */ +export async function fetchRegistryReleaseState( + name: string, +): Promise { + const url = packumentUrl(name) + // Full packument — the abbreviated install-v1 format drops `time`. + const read = cacheBustedRead(url, 'application/json') + try { + const json = await httpJson<{ + 'dist-tags'?: { latest?: string | undefined } | undefined + time?: Record | undefined + versions?: Record | undefined + }>(read.url, { + headers: read.headers, + timeout: 15_000, + }) + if (!json.time || typeof json.time !== 'object') { + return undefined + } + return { + latest: json['dist-tags']?.latest, + timeMap: json.time, + versions: Object.keys(json.versions ?? {}), + } + } catch { + return undefined + } +} + +/** + * Whether `@` exists on the public registry. An abbreviated + * packument read (not `npm view`: bare npm invocations die on EBADDEVENGINES + * inside repos whose devEngines pin pnpm, which made this probe false-negative + * everywhere — including the release stage's registry-liveness gate). Staged + * entries are absent from the public packument, so a staged-only version + * correctly reads as not published. Returns false on any network failure, + * matching the old exit-code semantics. + */ +export async function isAlreadyPublished( + name: string, + version: string, +): Promise { + const url = packumentUrl(name) + const read = cacheBustedRead(url, 'application/vnd.npm.install-v1+json') + try { + const json = await httpJson<{ + versions?: Record | undefined + }>(read.url, { + headers: read.headers, + timeout: 15_000, + }) + return Boolean(json.versions && version in json.versions) + } catch { + return false + } +} + +/** + * The registry publish state the verify-before-stage guard reads in one + * cache-busted packument fetch: the `dist-tags.latest` pointer and every + * published version string. Returns `{ latest: undefined, versions: [] }` on + * ANY failure — a version absent from an unreadable packument reads as "not + * published", so the guard falls through to an ordinary stage attempt (which + * the registry itself rejects with a 409 if the read was wrong), never a false + * "already published" skip. + */ +export interface PublishedState { + latest: string | undefined + versions: string[] +} + +export async function fetchPublishedState( + name: string, +): Promise { + const url = packumentUrl(name) + const read = cacheBustedRead(url, 'application/vnd.npm.install-v1+json') + try { + const json = await httpJson<{ + 'dist-tags'?: { latest?: string | undefined } | undefined + versions?: Record | undefined + }>(read.url, { + headers: read.headers, + timeout: 15_000, + }) + return { + latest: json['dist-tags']?.latest, + versions: Object.keys(json.versions ?? {}), + } + } catch { + return { latest: undefined, versions: [] } + } +} + +/** + * Subset of `https://registry.npmjs.org/` packument fields the fleet's + * publish scripts care about. The full shape is much larger; we project to what + * we use so callers don't have to know the rest. + */ +export interface RegistryVersionInfo { + /** + * `_npmUser.approver` — set when the version landed through pnpm's staged- + * publish flow (a human approver clicked through 2FA). Used by + * `npm/shared.mts:isStagingExpected` to refuse a --direct downgrade when any + * prior version of the package chose the staged path. + */ + approver?: string | undefined + /** + * `dist.attestations` — present when the upload included npm provenance + * (`--provenance` flag). The URL fetches the SLSA provenance bundle. + */ + attestations?: + | { + url: string + provenance: { predicateType: string } + } + | undefined + /** + * `dist.integrity` — the SRI digest (`sha512-`) npm recorded for the + * published tarball. The strong axis of the three-way release hash gate + * (`lib/verify-release-hashes.mts`). + */ + integrity?: string | undefined + /** + * `dist.shasum` — the sha1 hex digest npm recorded for the published tarball. + * The fallback axis when `integrity` is unavailable (e.g. a staged version + * before it is approved). + */ + shasum?: string | undefined + /** + * `_npmUser.trustedPublisher` — set when the version was uploaded via OIDC + * trusted publisher (GitHub Actions). Omit when classic token was used. + */ + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined +} + +/** + * Fetch a package's registry packument and return the per-version trust + * metadata. Returns `{}` for any package that isn't on the registry (or that + * the fetch itself failed for). + * + * The npm registry exposes two packument formats: + * + * - Full (~100KB+): includes per-version `_npmUser.trustedPublisher` (OIDC + * trusted-publisher attribution) AND `dist.attestations` (SLSA provenance + * bundle URL). + * - Abbreviated (~10-20KB, Accept: application/vnd.npm.install-v1+json): drops + * `_npmUser` but keeps `dist.attestations`. + * + * Callers pick: `'abbreviated'` for cheap attestation-only checks (Stop-hook, + * approve-flow enrich), `'full'` for audits that need to confirm + * trusted-publisher attribution (check/provenance-is-attested.mts). + * + * Use this from `check/provenance-is-attested.mts` (CLI audit), the approve + * flow, show prior-version status, and the Stop-hook (verify a freshly- bumped + * version landed with provenance). + */ +export async function fetchVersionTrustInfo( + name: string, + variant: 'abbreviated' | 'full' = 'abbreviated', +): Promise> { + const url = packumentUrl(name) + let json: { + versions?: + | Record< + string, + { + dist?: + | { + attestations?: + | { + url: string + provenance: { predicateType: string } + } + | undefined + integrity?: string | undefined + shasum?: string | undefined + } + | undefined + _npmUser?: + | { + approver?: string | undefined + trustedPublisher?: + | { id: string; oidcConfigId?: string | undefined } + | undefined + } + | undefined + } + > + | undefined + } + try { + const accept = + variant === 'abbreviated' + ? 'application/vnd.npm.install-v1+json' + : 'application/json' + const read = cacheBustedRead(url, accept) + json = await httpJson(read.url, { + headers: read.headers, + timeout: 15_000, + }) + } catch { + return {} + } + const result: Record = {} + for (const [version, info] of Object.entries(json.versions ?? {})) { + result[version] = { + ...(info._npmUser?.approver !== undefined + ? { approver: info._npmUser.approver } + : {}), + ...(info.dist?.attestations + ? { attestations: info.dist.attestations } + : {}), + ...(info.dist?.integrity !== undefined + ? { integrity: info.dist.integrity } + : {}), + ...(info.dist?.shasum !== undefined ? { shasum: info.dist.shasum } : {}), + ...(info._npmUser?.trustedPublisher + ? { trustedPublisher: info._npmUser.trustedPublisher } + : {}), + } + } + return result +} + +/** + * Post-failure diagnosis for a staged upload under CI OIDC. pnpm's token + * exchange 404s (`ERR_PNPM_AUTH_TOKEN_EXCHANGE`, logged as "Skipped OIDC") + * when the registry has NO trusted-publisher registration matching this + * run's OIDC claims — the upload then proceeds tokenless and fails. The + * packument's per-version `_npmUser.trustedPublisher` splits the two causes: + * never registered vs. registered-but-claims-drifted. Returns the diagnosis + * lines to log (empty outside GitHub Actions). + */ +/** + * Post-failure diagnosis for a stage-conflict (E409-shaped) upload failure: + * the target version is NOT publicly published, yet the stage was refused — + * a staged (unpublished) entry for that exact version already exists, and + * staging is one-shot per version while an entry lives. The remedy is + * REJECT-AND-RETRY THE SAME VERSION, never a bump past it: an unpublished + * version number is not burned, and bumping strands it (the incident shape: + * a hollow stage survived an incomplete reject, the flow bumped to the next + * patch, and the hollow entry later went public beside the good one). + * Returns the lines to log, or [] when the target is already public (the + * verify-before-stage gate owns that case) or the packument is unreachable. + */ +export async function diagnoseStageConflict( + name: string, + version: string, + options?: + | { + fetchState?: ((name: string) => Promise) | undefined + } + | undefined, +): Promise { + const { fetchState = fetchPublishedState } = { + __proto__: null, + ...options, + } as { fetchState?: ((name: string) => Promise) | undefined } + const published = await fetchState(name) + if (published.versions.includes(version)) { + return [] + } + return [ + `Probable cause: a staged (unpublished) entry for ${name}@${version} already exists.`, + ` Where: npm staging — staging is one-shot per version while an entry lives.`, + ` Saw: the stage was refused, yet ${version} is not visible on the public registry.`, + ` Fix: as a package maintainer, run \`pnpm stage list\`, then`, + ` \`node scripts/socket-release/npm-web-auth.mts stage reject \` for the stale entry, and re-stage the`, + ` SAME version. Do NOT bump past it: the number is only burned once`, + ` published, and a surviving stale stage can be approved by mistake later.`, + ] +} + +export async function diagnoseStagedAuthFailure( + name: string, +): Promise { + if (process.env['GITHUB_ACTIONS'] !== 'true') { + return [] + } + const trust = await fetchVersionTrustInfo(name, 'full') + const trusted = Object.entries(trust).filter( + ([, info]) => info.trustedPublisher !== undefined, + ) + const repo = process.env['GITHUB_REPOSITORY'] ?? '/' + const workflowRef = process.env['GITHUB_WORKFLOW_REF'] ?? '' + const workflow = + /\/(\.github\/workflows\/[^@]+)@/.exec(workflowRef)?.[1] ?? + '.github/workflows/npm-publish.yml' + if (trusted.length === 0) { + return [ + `Probable cause: npm trusted publishing is NOT registered for ${name}.`, + ` Where: npmjs.com -> ${name} -> Settings -> Trusted publisher.`, + ` Saw: the packument shows no version ever published via a trusted`, + ` publisher, and pnpm's token exchange 404 (ERR_PNPM_AUTH_TOKEN_EXCHANGE,`, + ` logged as "Skipped OIDC") is the no-registration signature; wanted a`, + ` registration matching repository ${repo}, workflow ${workflow}, and`, + ` the GitHub environment this workflow binds.`, + ` Fix: add the trusted publisher with those exact values, then`, + ` re-dispatch the publish workflow.`, + ] + } + const [latestTrustedVersion, latestInfo] = trusted[trusted.length - 1]! + return [ + `Probable cause: this run's OIDC claims do not match ${name}'s`, + ` trusted-publisher registration.`, + ` Where: npmjs.com -> ${name} -> Settings -> Trusted publisher.`, + ` Saw: ${latestTrustedVersion} published via trusted publisher`, + ` ${latestInfo.trustedPublisher?.id ?? ''}, but this run presents`, + ` repository ${repo} and workflow ${workflow}; wanted the registration and`, + ` the run's claims (repository, workflow file, environment) to agree.`, + ` Fix: align the npm trusted-publisher entry with this workflow, then`, + ` re-dispatch.`, + ] +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/scan.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/scan.mts new file mode 100644 index 00000000..d5c09ae3 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/scan.mts @@ -0,0 +1,547 @@ +/** + * @file Pre-approve Socket full-scan gate, CLI-free: everything runs through + * `@socketsecurity/sdk` against the Socket API directly — no `socket` + * binary. The shasum gate has already proven the staged bytes are identical + * to the local `pnpm pack`, so scanning the local artifact's extract IS + * scanning the staged upload. Each verified entry is packed, extracted to a + * temp dir, submitted as a `tmp` full scan (hidden from the dashboard scan + * list — a promotion gate, not a tracked branch scan), and gated on the + * org's OWN security policy: any alert whose policy action is `error` fails + * the entry, mirroring the report-level:error semantics. Fail-closed by + * design: promotion includes a full scan unless `--no-scan` skips it + * explicitly. Auth is verified ONCE up front (`preflightSocketScanAuth`) + * with a cheap quota read; an interactive run with no token in the + * environment opens the Socket dashboard in the browser and prompts for a + * pasted key (masked — the token never echoes). + */ + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { SocketSdk } from '@socketsecurity/sdk' + +import { logger, rootPath, runCapture } from '../shared.mts' +import { + acquireSocketTokenViaOAuth, + socketOAuthConfigured, +} from '../socket-oauth.mts' +import { defaultPackTarball } from './staged.mts' +import { collectThreatFailures, runLocalThreatScan } from './threat-scan.mts' +import type { ThreatManifest } from './threat-scan.mts' +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { spawn } from '@socketsecurity/lib/process/spawn/child' +import { password } from '@socketsecurity/lib/stdio/prompts' + +// The canonical fleet env name for the Socket API token — bootstrap hooks +// normalize the legacy aliases into it, so only this one is read. +export const SOCKET_TOKEN_ENV_VAR = 'SOCKET_API_TOKEN' + +// Where a human mints a token when none is in the environment: dashboard → +// org settings → API tokens. The gate needs `full-scans` + `report` scopes. +export const SOCKET_TOKEN_MINT_URL = 'https://socket.dev/dashboard' + +/** + * Everything a gate run needs: an authenticated SDK bound to one org. + */ +export interface SocketScanContext { + orgSlug: string + sdk: SocketSdk +} + +/** + * Read the Socket API token from the environment. + */ +export function resolveSocketApiToken( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const value = env[SOCKET_TOKEN_ENV_VAR] + return typeof value === 'string' && value !== '' ? value : undefined +} + +// Best-effort platform browser opener (`open` / `xdg-open` / `start`), +// fire-and-forget so the gate never waits on the browser process. A failure +// to open is non-fatal — the URL is printed and the human opens it by hand. +function openInBrowser(url: string): void { + const win32 = process.platform === 'win32' + const opener = + process.platform === 'darwin' ? 'open' : win32 ? 'start' : 'xdg-open' + try { + const child = spawn(opener, [url], { + detached: true, + shell: win32, + stdio: 'ignore', + }) + child.catch(() => { + // Non-fatal: the printed URL is the fallback. + }) + } catch { + // Non-fatal: the printed URL is the fallback. + } +} + +/** + * One-shot pre-gate auth setup, SDK-only. Resolves the API token from the + * environment — or, on an interactive terminal, opens the Socket dashboard + * and prompts for a pasted key (masked input; the token is never echoed) — + * verifies it with a cheap `getQuota()` call, and resolves the org slug the + * full scans run under (`SOCKET_ORG_SLUG` override, else the token's single + * org). Run ONCE before the per-entry loop so a missing/expired token + * surfaces before any human selection, not mid-gate. Every dependency is an + * injectable seam so tests drive the flow with no network, browser, or TTY. + */ +export async function preflightSocketScanAuth( + options?: + | { + env?: NodeJS.ProcessEnv | undefined + interactive?: boolean | undefined + openUrl?: ((url: string) => void) | undefined + promptForToken?: (() => Promise) | undefined + sdkFactory?: ((token: string) => SocketSdk) | undefined + } + | undefined, +): Promise { + const { + env = process.env, + interactive = Boolean(process.stdout.isTTY), + openUrl = openInBrowser, + promptForToken, + sdkFactory = token => new SocketSdk(token), + } = { __proto__: null, ...options } as NonNullable + + let token = resolveSocketApiToken(env) + if (!token && socketOAuthConfigured(env)) { + // Browser OAuth (authorization-code + PKCE + loopback) — no key to copy. + // The browser opens on the operator's screen, so this path does not need + // a TTY; a thrown failure falls through to the paste/fail paths below. + try { + token = await acquireSocketTokenViaOAuth({ env, openUrl }) + } catch (e) { + logger.warn(errorMessage(e)) + } + } + if (!token && interactive) { + logger.log( + `Scan gate: no Socket API token in the environment — opening ${SOCKET_TOKEN_MINT_URL} ` + + '(org settings → API tokens; the gate needs full-scans + report scopes).', + ) + openUrl(SOCKET_TOKEN_MINT_URL) + const prompt = + promptForToken ?? + (async () => + String( + (await password({ message: 'Paste the Socket API token:' })) ?? '', + )) + const pasted = (await prompt()).trim() + if (pasted) { + token = pasted + } + } + if (!token) { + logger.fail( + 'Scan gate: no Socket API token.\n' + + ` Where: env (${SOCKET_TOKEN_ENV_VAR})\n` + + ' Saw: none set; wanted a token the Socket SDK can scan with.\n' + + ` Fix: mint one at ${SOCKET_TOKEN_MINT_URL} (org settings → API ` + + 'tokens) and export it, or load it from sockeye Touch-ID credential ' + + 'storage; --no-scan skips the gate explicitly.', + ) + return undefined + } + + const sdk = sdkFactory(token) + let quotaOk = false + try { + const quota = await sdk.getQuota() + quotaOk = Boolean((quota as { success?: boolean | undefined }).success) + } catch (e) { + logger.fail( + 'Scan gate: the Socket API is unreachable.\n' + + ` Where: getQuota() (${errorMessage(e)})\n` + + ' Saw: no API response; wanted a cheap authenticated read.\n' + + ' Fix: check network/proxy and retry; --no-scan skips the gate explicitly.', + ) + return undefined + } + if (!quotaOk) { + logger.fail( + 'Scan gate: the Socket API token was rejected.\n' + + ' Where: getQuota()\n' + + ' Saw: an unauthenticated response; wanted a valid token.\n' + + ` Fix: re-mint at ${SOCKET_TOKEN_MINT_URL} and export it; ` + + '--no-scan skips the gate explicitly.', + ) + return undefined + } + + const orgOverride = env['SOCKET_ORG_SLUG'] + if (typeof orgOverride === 'string' && orgOverride !== '') { + return { orgSlug: orgOverride, sdk } + } + let slugs: string[] = [] + try { + const orgs = await sdk.listOrganizations() + if (orgs.success) { + slugs = Object.values(orgs.data.organizations) + .map(o => (o as { slug?: string | undefined }).slug ?? '') + .filter(Boolean) + } + } catch (e) { + logger.fail(`Scan gate: could not list organizations (${errorMessage(e)}).`) + return undefined + } + if (slugs.length !== 1) { + logger.fail( + 'Scan gate: could not resolve the org to scan under.\n' + + ' Where: listOrganizations()\n' + + ` Saw: ${slugs.length === 0 ? 'no orgs on this token' : `multiple orgs (${slugs.join(', ')})`}; wanted exactly one.\n` + + ' Fix: export SOCKET_ORG_SLUG= to pick one explicitly.', + ) + return undefined + } + return { orgSlug: slugs[0]!, sdk } +} + +/** + * One policy-failing alert, for the gate's failure report. + */ +export interface PolicyFailingAlert { + artifact: string + severity: string + type: string +} + +/** + * Pure policy evaluation: collect every alert whose org security-policy + * action is `error`. This is the report-level:error gate semantic — the org's + * own policy decides what blocks, not a hardcoded severity floor. + */ +export function collectPolicyFailingAlerts( + artifacts: ReadonlyArray<{ + alerts?: + | ReadonlyArray<{ severity?: string | undefined; type: string }> + | undefined + name?: string | undefined + version?: string | undefined + }>, + policyRules: Readonly>, +): PolicyFailingAlert[] { + const failing: PolicyFailingAlert[] = [] + for (let i = 0, { length } = artifacts; i < length; i += 1) { + const artifact = artifacts[i]! + const alerts = artifact.alerts ?? [] + for (const alert of alerts) { + if (policyRules[alert.type]?.action === 'error') { + failing.push({ + artifact: `${artifact.name ?? ''}@${artifact.version ?? '?'}`, + severity: alert.severity ?? 'unknown', + type: alert.type, + }) + } + } + } + return failing +} + +// Full-scan payload shapes vary by endpoint version (a bare artifact array vs +// an `{ artifacts: [...] }` wrapper); normalize to the artifact array the +// policy evaluation consumes. +export interface FullScanArtifact { + alerts?: Array<{ severity?: string | undefined; type: string }> | undefined + name?: string | undefined + version?: string | undefined +} + +export type SecurityPolicyRules = Record< + string, + { action?: string | undefined } +> + +// Return the artifact list for a RECOGNIZED full-scan response shape (a bare +// array or `{ artifacts: [...] }`), or undefined when the shape is +// unrecognized. The gate fails closed on undefined rather than conflating +// "unknown response shape" with "clean" — the SDK maps an empty HTTP body to +// `{}`, and a future enveloped/paginated shape would otherwise silently pass. +// A recognized-but-empty `[]` is also a fail-closed signal at the call site: a +// real full scan of a package always yields at least the package's own +// artifact, so zero artifacts means nothing was evaluated. +export function normalizeFullScanArtifacts( + data: unknown, +): FullScanArtifact[] | undefined { + if (Array.isArray(data)) { + return data as FullScanArtifact[] + } + if (data && typeof data === 'object') { + const maybe = (data as { artifacts?: unknown | undefined }).artifacts + if (Array.isArray(maybe)) { + return maybe as FullScanArtifact[] + } + } + return undefined +} + +// Return the org security-policy rule map for a RECOGNIZED shape, or undefined +// when `securityPolicyRules` is absent or not an object. The gate fails closed +// on undefined rather than defaulting to an empty map — an empty map matches +// no alert, so a missing/renamed policy would silently approve a package that +// carries genuine error-action alerts. +export function extractSecurityPolicyRules( + data: unknown, +): SecurityPolicyRules | undefined { + if (data && typeof data === 'object') { + const rules = (data as { securityPolicyRules?: unknown | undefined }) + .securityPolicyRules + if (rules && typeof rules === 'object') { + return rules as SecurityPolicyRules + } + } + return undefined +} + +/** + * Scan one staged entry's artifact through the Socket API. Resolves the + * tarball (a local `pnpm pack`, byte-identical to the staged upload once the + * shasum gate has passed, or a provider-supplied download), then submits the + * WHOLE tarball as a `tmp` full scan via the archive endpoint. depscan + * extracts the archive server-side and ingests every bundled manifest and + * lockfile as shipped — the full pinned DEPENDENCY graph, not just a + * hand-picked package.json — and the gate fails on any `error`-action alert + * in the org security policy. Scope note: the archive endpoint scans the + * dependency graph, NOT the package's own source code; non-manifest files are + * matched out and ignored server-side (depscan ingest-tar-hash). Socket's + * code/malware analysis is keyed to PUBLISHED packages by purl, so a + * pre-publish staged tarball's own novel code is not analyzed here. + * `options.packTarball` swaps the artifact source: a generated platform + * package's payload is CI-built with no local twin, so the approve flow passes + * a provider that downloads the STAGED tarball, whose structure the platform + * verify gate has already checked, instead of packing locally. + * `options.context` carries the preflighted SDK+org; when absent the entry + * runs its own preflight (self-contained use). + */ +export async function scanStagedEntry( + entry: { + name: string + version: string + }, + options?: + | { + context?: SocketScanContext | undefined + packTarball?: + | ((name: string, version: string) => Promise) + | undefined + runThreat?: typeof runLocalThreatScan | undefined + threatScan?: boolean | undefined + } + | undefined, +): Promise { + const { + context, + packTarball = defaultPackTarball, + runThreat = runLocalThreatScan, + threatScan = false, + } = { + __proto__: null, + ...options, + } as { + context?: SocketScanContext | undefined + packTarball?: + | ((name: string, version: string) => Promise) + | undefined + runThreat?: typeof runLocalThreatScan | undefined + threatScan?: boolean | undefined + } + const scanContext = context ?? (await preflightSocketScanAuth()) + if (!scanContext) { + return false + } + const { orgSlug, sdk } = scanContext + const { name, version } = entry + const tarballPath = await packTarball(name, version) + if (!tarballPath) { + logger.fail( + `Scan gate: could not pack ${name}@${version} locally; refusing to approve unscanned bytes.`, + ) + return false + } + const tmpRoot = os.tmpdir() + try { + // Upload the WHOLE tarball via the archive endpoint. depscan extracts it + // server-side and ingests every bundled manifest + lockfile AS SHIPPED, so + // the scan sees the full pinned dependency graph — not just the top-level + // package.json a manifest-only createFullScan would send. This scans + // DEPENDENCIES, not the package's own code (non-manifest files are matched + // out and ignored server-side). Mirrors socket-webext's staged-review + // full-scan, which uses the same archive endpoint. + logger.log( + `Scan gate: Socket full scan (tmp, archive) on ${name}@${version} via the API…`, + ) + let scanId: string | undefined + try { + const created = await sdk.createOrgFullScanFromArchive( + orgSlug, + tarballPath, + { repo: 'staged-publish-gate', tmp: true }, + ) + if (created.success) { + scanId = (created.data as { id?: string | undefined }).id + } else { + logger.fail( + `Scan gate: archive full-scan create failed for ${name}@${version} ` + + `(status ${created.status}${created.error ? `: ${String(created.error)}` : ''}).`, + ) + return false + } + } catch (e) { + logger.fail( + `Scan gate: archive full-scan create threw for ${name}@${version} (${errorMessage(e)}).`, + ) + return false + } + if (!scanId) { + logger.fail( + `Scan gate: archive full-scan create returned no scan id for ${name}@${version}; not approving.`, + ) + return false + } + let artifacts: FullScanArtifact[] + let policyRules: SecurityPolicyRules + try { + const [scan, policy] = await Promise.all([ + sdk.getFullScan(orgSlug, scanId), + sdk.getOrgSecurityPolicy(orgSlug), + ]) + if (!scan.success || !policy.success) { + logger.fail( + `Scan gate: could not read the scan or the org security policy for ${name}@${version}; not approving.`, + ) + return false + } + // Fail closed on an unrecognized or empty scan/policy: an unknown + // response shape (or the SDK's empty-body → `{}`) must never read as + // "clean". A real full scan yields at least the package's own artifact, + // and a real org carries a policy rule map; the absence of either means + // nothing was actually evaluated. + const rawArtifacts = normalizeFullScanArtifacts(scan.data) + if (!rawArtifacts || rawArtifacts.length === 0) { + logger.fail( + `Scan gate: full scan for ${name}@${version} returned no recognizable ` + + 'artifacts; refusing to approve bytes the scan did not evaluate.', + ) + return false + } + const rules = extractSecurityPolicyRules(policy.data) + if (!rules) { + logger.fail( + `Scan gate: org security policy for ${name}@${version} was empty or ` + + 'unrecognized; refusing to approve without a policy to evaluate against.', + ) + return false + } + artifacts = rawArtifacts + policyRules = rules + } catch (e) { + logger.fail( + `Scan gate: reading scan results threw for ${name}@${version} (${errorMessage(e)}).`, + ) + return false + } + const failing = collectPolicyFailingAlerts(artifacts, policyRules) + if (failing.length > 0) { + logger.fail( + `Scan gate: ${failing.length} policy-failing alert(s) for ${name}@${version}; not approving.`, + ) + for (let i = 0, { length } = failing; i < length; i += 1) { + const f = failing[i]! + logger.fail(` - ${f.type} (${f.severity}) in ${f.artifact}`) + } + return false + } + // Opt-in local code-threat leg: the dependency scan above cannot see the + // package's OWN source, so when requested, extract the tarball and run the + // keyless on-device triage over it. Fail closed on a blocking verdict AND + // when the scan was requested but no local model resolved — the operator + // asked for it, so a silent skip must not read as a pass. + if (threatScan) { + const passed = await runThreatLeg(tarballPath, entry, runThreat) + if (!passed) { + return false + } + } + return true + } finally { + // Clean the tarball when a packTarball provider downloaded it into a temp + // dir (the registry-API `stage download` and the browser-read passback + // both mkdtemp under os.tmpdir()). A repo-local `pnpm pack` output lands + // in the package dir, NOT under tmpdir, so it is never touched — + // pnpm/repo hygiene owns that one. + if (tarballPath.startsWith(tmpRoot + path.sep)) { + await safeDelete(path.dirname(tarballPath)) + } + } +} + +// Extract the tarball and run the keyless local threat scan over its `package/` +// root. Returns true only when the scan ran AND every file triaged clean. +// Fails closed (returns false) on a blocking verdict, an extraction failure, or +// `available:false` — the scan was explicitly requested, so a missing local +// model must not read as a pass. The extract dir is always cleaned. +async function runThreatLeg( + tarballPath: string, + entry: { name: string; version: string }, + runThreat: typeof runLocalThreatScan, +): Promise { + const { name, version } = entry + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-threat-')) + try { + const untar = await runCapture( + 'tar', + ['-xzf', tarballPath, '-C', dir], + rootPath, + ) + if (untar.code !== 0) { + logger.fail( + `Threat scan: extracting ${name}@${version} failed (tar exited ${untar.code}); not approving.`, + ) + return false + } + const packageDir = path.join(dir, 'package') + let manifest: ThreatManifest = {} + try { + manifest = JSON.parse( + await fs.readFile(path.join(packageDir, 'package.json'), 'utf8'), + ) as ThreatManifest + } catch { + // A tarball with no readable package.json still gets a code scan; the + // manifest only refines file prioritization. + } + const result = await runThreat(packageDir, { manifest }) + if (!result.available) { + logger.fail( + `Threat scan: requested (--threat-scan) but no on-device model resolved for ${name}@${version}; ` + + 'failing closed. Provision a local backend (ODAI_BACKEND / node:smol-ai / llama-server) or drop --threat-scan.', + ) + return false + } + const failing = collectThreatFailures(result.findings) + if (failing.length > 0) { + logger.fail( + `Threat scan: ${failing.length} threat finding(s) for ${name}@${version}; not approving.`, + ) + for (let i = 0, { length } = failing; i < length; i += 1) { + const f = failing[i]! + logger.fail( + ` - ${f.verdict} (${f.confidence}) ${f.file}: ${f.reasons.join('; ')}`, + ) + } + return false + } + logger.log( + `Threat scan: ${result.findings.length} file(s) triaged clean for ${name}@${version}.`, + ) + return true + } finally { + await safeDelete(dir) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/shared.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/shared.mts new file mode 100644 index 00000000..f0567dca --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/shared.mts @@ -0,0 +1,389 @@ +/** + * @file Npm-specific shared helpers for the npm-publish modes: the + * staged-entry shape, package.json + staged-shasum readers, the + * `pnpm stage list` fetch, prior-provenance lookup, and the + * staging-expected trust check consumed by both --staged and --direct. + */ + +import { readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + extractFirstJson, + logApproveHandoff, + rootPath, + runCapture, +} from '../shared.mts' +import { fetchVersionTrustInfo } from './registry.mts' +import { resolveNpmWorkspaceLayout } from './workspace.mts' + +export type NpmAccess = 'public' | 'restricted' + +function accessFromValue(value: unknown): NpmAccess | undefined { + return value === 'public' || value === 'restricted' ? value : undefined +} + +/** + * Resolve the npm access level for a release-time publish: the kit config wins, + * then the subject manifest's publishConfig.access, then npm's own default for + * the name (scoped defaults to restricted, unscoped to public). A literal + * `--access public` on the publish command would override publishConfig, so the + * resolved value is passed explicitly to keep the configured intent. + */ +export function resolveNpmAccess(config: { + kitConfigAccess?: NpmAccess | undefined + packageName: string + publishConfigAccess?: NpmAccess | undefined +}): NpmAccess { + const cfg = { __proto__: null, ...config } as typeof config + return ( + cfg.kitConfigAccess ?? + cfg.publishConfigAccess ?? + (cfg.packageName.startsWith('@') ? 'restricted' : 'public') + ) +} + +export function readKitNpmAccess( + root: string = rootPath, +): NpmAccess | undefined { + try { + const raw = readFileSync( + path.join(root, '.config', 'socket-release.json'), + 'utf8', + ) + const doc = JSON.parse(raw) as { npm?: { access?: unknown } | undefined } + return accessFromValue(doc.npm?.access) + } catch { + return undefined + } +} + +export function readKitDistTag(root: string = rootPath): string | undefined { + try { + const raw = readFileSync( + path.join(root, '.config', 'socket-release.json'), + 'utf8', + ) + const doc = JSON.parse(raw) as { npm?: { distTag?: unknown } | undefined } + const distTag = doc.npm?.distTag + return typeof distTag === 'string' && distTag ? distTag : undefined + } catch { + return undefined + } +} + +export function readPublishConfigAccess( + manifestPath: string, +): NpmAccess | undefined { + try { + const raw = readFileSync(manifestPath, 'utf8') + const doc = JSON.parse(raw) as { + publishConfig?: { access?: unknown } | undefined + } + return accessFromValue(doc.publishConfig?.access) + } catch { + return undefined + } +} + +/** + * The release-time access level for a publish subject, reading both config + * sources from disk and applying {@link resolveNpmAccess}. + */ +export function resolveReleaseAccess(config: { + manifestPath: string + packageName: string + root?: string | undefined +}): NpmAccess { + const cfg = { __proto__: null, ...config } as typeof config + return resolveNpmAccess({ + kitConfigAccess: readKitNpmAccess(cfg.root ?? rootPath), + packageName: cfg.packageName, + publishConfigAccess: readPublishConfigAccess(cfg.manifestPath), + }) +} + +// The approve leg an operator runs after staging. +export const NPM_APPROVE_COMMAND = + 'node scripts/socket-release/npm-publish.mts --approve' + +// Who owns the promotion, stated once so nobody reads approve.mts to find out. +// It runs `pnpm stage approve` against the registry itself (approve.mts's +// runApprove), so the operator's only manual step is the 2FA challenge. +export const NPM_APPROVE_OWNERSHIP = + 'That command performs the npm promotion itself: it runs `pnpm stage ' + + 'approve` against the registry from your machine, then creates the git tag ' + + 'and GitHub release once the version resolves as live. Your only manual ' + + 'step is the 2FA challenge it prompts for.' + +/** + * Print the staged-to-approve handoff for the npm registry. Called ONCE at the + * end of a staging run — single subject or multi-package workspace — so the + * actionable command is the last thing on screen rather than a tail repeated + * per package. + */ +export function logNpmApproveHandoff(): void { + logApproveHandoff(NPM_APPROVE_COMMAND, NPM_APPROVE_OWNERSHIP) +} + +/** + * The working directory for bare `npm` invocations (whoami/login/logout). Two + * constraints pin it to the OS temp dir and nowhere else: it must sit outside + * the repo, whose devEngines pins pnpm and vetoes bare `npm`, and it must sit + * outside the OS home dir, because lib's spawn treats the child cwd as the + * UNTRUSTED ROOT and drops every PATH entry under it — with a home-dir cwd + * that is fnm/nvm/`~/Library/pnpm`/the sfw shims, i.e. every npm a + * version-manager user has, and the bare-name fallback then ENOENTs. + */ +export function npmScratchCwd(): string { + return os.tmpdir() +} + +/** + * Raised when the staged-entry listing could not be AUTHENTICATED. The stage + * endpoints 401 without npm auth and `pnpm stage list`'s failure output + * parses as an EMPTY list — the 6.2.1 run recorded that as verify=failed + * "0 staged entries", a false negative that stranded the pipeline. Callers + * must treat this error as "auth unavailable", never as an empty stage list. + */ +export class StageListAuthError extends Error {} + +export interface StageListEntry { + name?: string | undefined + version?: string | undefined + stageId?: string | undefined + // sha1 hex npm recorded for the staged tarball. `pnpm stage list --json` is + // the ONLY pre-approve source of the server-side digest — a staged version is + // not in the public packument, so fetchVersionTrustInfo can't see it. Live + // pnpm emits it top-level as `shasum`, verified against a real staged run; + // readStagedShasum keeps the `dist.shasum` probe as a fallback and the gate + // fails LOUD, never silently skips, when none resolve. + shasum?: string | undefined +} + +/** + * The PUBLISH SUBJECT's name/version/repository — the root package.json for a + * plain repo, the `publishConfig.directory` manifest for a redirected monorepo + * like socket-registry, and the MAIN package (+ lockstep version source) for + * a multi-package workspace like decmpfs/stuie. Every guard that keys on + * "this repo's package" (already-published refusal, cross-repo pack refusal, + * approve's local-entry match) must see the subject, never a private root. + * `root` is injectable for tests. + */ +export function readPackageJson(root: string = rootPath): { + name: string + version: string + repository?: string | { url?: string | undefined } | undefined +} { + const layout = resolveNpmWorkspaceLayout(root) + return { + name: layout.versionSource.name, + repository: layout.repository, + version: layout.versionSource.version, + } +} + +/** + * Extract the staged tarball's sha1 from a `pnpm stage list --json` entry. + * Live pnpm emits top-level `shasum`, verified against a real staged run; + * `dist.shasum` stays as a fallback probe. Returns undefined when none + * resolve; the pre-approve gate then fails LOUD, never silently skips, so a + * field-name drift surfaces as a hard stop, not a false-green. (`integrity` is + * sha512 — a different axis — so it is not reduced to sha1 here.) + */ +export function readStagedShasum(entry: { + dist?: { shasum?: unknown | undefined } | undefined + shasum?: unknown | undefined +}): string | undefined { + if (typeof entry.shasum === 'string' && entry.shasum) { + return entry.shasum + } + if (typeof entry.dist?.shasum === 'string' && entry.dist.shasum) { + return entry.dist.shasum + } + return undefined +} + +// A raw `pnpm stage list --json` entry across the shapes we've seen: live +// pnpm emits `{ id, packageName, version, shasum, … }`; the older keyed-map +// shape used `{ stageId, name, … }`. +interface RawStageEntry { + dist?: { shasum?: unknown | undefined } | undefined + id?: unknown | undefined + name?: unknown | undefined + packageName?: unknown | undefined + shasum?: unknown | undefined + stageId?: unknown | undefined + version?: unknown | undefined +} + +function normalizeStageEntry(raw: RawStageEntry): StageListEntry | undefined { + const stageId = + typeof raw.id === 'string' && raw.id + ? raw.id + : typeof raw.stageId === 'string' && raw.stageId + ? raw.stageId + : undefined + if (!stageId) { + return undefined + } + const name = + typeof raw.packageName === 'string' && raw.packageName + ? raw.packageName + : typeof raw.name === 'string' && raw.name + ? raw.name + : undefined + return { + name, + shasum: readStagedShasum(raw), + stageId, + version: typeof raw.version === 'string' ? raw.version : undefined, + } +} + +/** + * Parse `pnpm stage list --json` output into normalized entries. Live pnpm + * verified against a real staged run, emits an ARRAY of + * `{ id, packageName, version, shasum, … }`; the older keyed-map shape + * (`{ '@': { stageId, name, … } }`) is kept as a fallback. + * Entries that don't resolve a stage id are dropped (defensive). Pure — + * exported for tests. + */ +export function parseStageListJson(stdout: string): StageListEntry[] { + let parsed: unknown + try { + parsed = JSON.parse(stdout.trim()) + } catch { + const json = extractFirstJson(stdout) + if (!json) { + return [] + } + try { + parsed = JSON.parse(json) + } catch { + return [] + } + } + const rawEntries: Array = Array.isArray(parsed) + ? (parsed as Array) + : parsed && typeof parsed === 'object' + ? (Object.values(parsed) as Array) + : [] + const result: StageListEntry[] = [] + for (let i = 0, { length } = rawEntries; i < length; i += 1) { + const raw = rawEntries[i] + const entry = raw ? normalizeStageEntry(raw) : undefined + if (entry) { + result.push(entry) + } + } + return result +} + +/** + * Resolve all currently-staged packages by running `pnpm stage list --json` + * and normalizing the output (see parseStageListJson). Auth-honest: an empty + * result, or a non-zero exit, is only trusted after `npm whoami` proves local + * npm auth exists — an unauthenticated `pnpm stage list` 401s and its output + * parses as an EMPTY list, indistinguishable from "nothing staged". Without + * that proof this throws StageListAuthError carrying the whoami evidence, so + * a missing token can never masquerade as "0 staged entries". + */ +export async function listStagedPackages(): Promise { + const { code, stdout } = await runCapture( + 'pnpm', + ['stage', 'list', '--json'], + rootPath, + ) + const entries = parseStageListJson(stdout) + if (code === 0 && entries.length > 0) { + return entries + } + const whoami = await runCapture('npm', ['whoami'], npmScratchCwd()) + if (whoami.code !== 0) { + throw new StageListAuthError( + `\`npm whoami\` exited ${whoami.code} — no npm auth, so the staging ` + + `endpoints 401 — and \`pnpm stage list --json\` exited ${code} with ` + + `${entries.length} parseable entr${entries.length === 1 ? 'y' : 'ies'}. ` + + `An unauthenticated stage list parses as EMPTY; refusing to report ` + + `"0 staged entries" without auth.`, + ) + } + return entries +} + +/** + * For each unique package name in `entries`, fetch the latest version's trust + * info from the registry. Used to annotate the approve multi- select with a + * "this package's last public version had provenance" hint — helps the approver + * spot if their staged upload is a regression (parent name has provenance + * history; staged version's workflow may have lost OIDC). + * + * One registry GET per unique name; abbreviated packument (saves ~80KB per + * popular package, omits `_npmUser` which we don't need here). + */ +export async function fetchPriorProvenanceMap( + entries: StageListEntry[], +): Promise> { + const uniqueNames = new Set() + for (let i = 0, { length } = entries; i < length; i += 1) { + const e = entries[i]! + if (e.name) { + uniqueNames.add(e.name) + } + } + const result = new Map() + // oxlint-disable-next-line socket/prefer-all-settled -- fail-fast: a failed trust-info fetch makes the audit incomplete; abort rather than report partial attestation results. + await Promise.all( + [...uniqueNames].map(async name => { + const versions = await fetchVersionTrustInfo(name, 'abbreviated') + const hasAnyAttestation = Object.values(versions).some( + v => !!v.attestations, + ) + result.set(name, hasAnyAttestation) + }), + ) + return result +} + +export function formatPriorProvenance( + hasPriorProvenance: boolean | undefined, +): string { + if (hasPriorProvenance === undefined) { + return '' + } + return hasPriorProvenance + ? ' [prior: ✓ provenance]' + : ' [prior: ✗ no provenance]' +} + +/** + * Detect whether this package has previously been published via the staged + * path. Returns true when ANY published version of `pkg.name` carries the + * registry packument's `_npmUser.approver` field — the signal pnpm uses for its + * `stagedPublish` trust-evidence tier (see github.com/pnpm/pnpm pull 12056). A + * package with an approver in its history has chosen the strongest trust path + * available; downgrading to --direct for a new version would erase that signal + * in the package's trust chain. + * + * Used by --direct to refuse running when the package's prior versions used + * staging: we want that trade-off to be a deliberate choice, not an accident. + * First-publish packages, no prior versions, get a pass — they have no staged + * history to preserve. + */ +export async function isStagingExpected(pkgName: string): Promise { + try { + const versions = await fetchVersionTrustInfo(pkgName, 'full') + const versionList = Object.values(versions) + for (let i = 0, { length } = versionList; i < length; i += 1) { + if (versionList[i]!.approver !== undefined) { + return true + } + } + } catch { + // Network failure / 404 / unparseable packument — treat as + // "unknown" and don't block the --direct path on it. + } + return false +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-parse.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-parse.mts new file mode 100644 index 00000000..5cbf667f --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-parse.mts @@ -0,0 +1,151 @@ +/** + * @file Pure parsers for the browser-read staged-tarball passback — no + * playwright, no I/O, so the challenge detection and payload mapping are + * unit-testable in isolation. The browser side (`staged-browser-read.mts`) + * reads npm's signed-in `/settings//staged-packages?format=json` + * endpoint and feeds the raw body here. Mirrors socket-webext's + * `src/trusted-publisher/background/staged-fetch.mts` classifier so both the + * extension and the publish gate treat a Cloudflare interstitial the same + * way (a transient challenge, never a fatal "not valid JSON"). + */ + +// Coarse outcome of a staged-packages fetch. `challenge` is the case this +// exists for: Cloudflare (or any edge interstitial) answers the ?format=json +// request with a 200 HTML page instead of JSON, so classify the BODY, not just +// the status — a naive JSON.parse would throw a misleading parse error. +export type StagedFetchState = 'auth' | 'challenge' | 'error' | 'ok' + +/** + * One staged tarball's identity + its session-scoped download URL. + */ +export interface StagedTarball { + createdAt?: string | undefined + id: string + packageName: string + shasum?: string | undefined + tag?: string | undefined + tarballUrl?: string | undefined + version: string +} + +/** + * The fields the gate needs off the staged-packages payload envelope. + */ +export interface StagedPayload { + approveUrl: string + csrfToken: string + rejectUrl: string + tarballs: StagedTarball[] + total: number +} + +// The Cloudflare bot-challenge markers — a challenge body is a 200 (or a +// 403/503) carrying JS-challenge markup, never the JSON we asked for. +export function isCloudflareChallenge(body: string): boolean { + if (!body) { + return false + } + return ( + /Just a moment/i.test(body) || + /cf-(?:browser-verification|challenge|chl-)/i.test(body) || + /_cf_chl_/i.test(body) || + /cdn-cgi\/challenge-platform\//i.test(body) || + /challenges\.cloudflare\.com\/turnstile/i.test(body) || + /Checking if the site connection is secure/i.test(body) + ) +} + +// True when a body that should be JSON is actually an HTML document — the +// tell-tale of a challenge/interstitial served in place of the API response. +export function looksLikeHtmlBody(body: string): boolean { + return /^\s*<(?:!doctype\s+html|body|head|html|title)\b/i.test(body) +} + +// Classify a staged-packages response by body + status. HTML (challenge markup +// OR a bare HTML document where JSON was expected, incl. a captured 403/503 +// challenge body) is a `challenge`; a plain 401/403 is `auth`; any other +// non-200 is `error`; a 200 with a non-HTML body is `ok`. +export function classifyStagedFetch(config: { + body?: string | undefined + status: number +}): StagedFetchState { + const cfg = { __proto__: null, ...config } as typeof config + const body = cfg.body ?? '' + if (isCloudflareChallenge(body) || looksLikeHtmlBody(body)) { + return 'challenge' + } + if (cfg.status === 401 || cfg.status === 403) { + return 'auth' + } + if (cfg.status !== 200) { + return 'error' + } + return 'ok' +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +// Map one raw npm staged item to a StagedTarball. The web payload mirrors the +// npm CLI's staged-item fields; read defensively in case the web names differ. +export function mapStagedTarball(raw: Record): StagedTarball { + const stagedBy = + raw['stagedBy'] && typeof raw['stagedBy'] === 'object' + ? (raw['stagedBy'] as Record) + : {} + return { + createdAt: + asString(raw['dateStaged']) ?? + asString(raw['createdAt']) ?? + asString(raw['created']), + id: asString(raw['stageId']) ?? asString(raw['id']) ?? '', + packageName: asString(raw['packageName']) ?? asString(raw['name']) ?? '', + shasum: asString(raw['shasum']), + tag: asString(raw['tag']), + tarballUrl: + asString(stagedBy['tarballUrl']) ?? + asString(raw['tarballUrl']) ?? + asString(raw['tarball']), + version: asString(raw['version']) ?? '', + } +} + +// Parse the staged-packages JSON body into the envelope + tarball list, +// optionally narrowed to a single package (the list is per-user). Throws on +// non-JSON — the caller classifies challenge bodies BEFORE calling this, so a +// throw here is a genuine malformed payload. +export function parseStagedPayload( + body: string, + packageFilter?: string | undefined, +): StagedPayload { + const parsed: unknown = JSON.parse(body) + const payload = (parsed && typeof parsed === 'object' ? parsed : {}) as { + approveURL?: unknown | undefined + csrftoken?: unknown | undefined + rejectURL?: unknown | undefined + stagedVersions?: + | { objects?: unknown | undefined; total?: unknown | undefined } + | undefined + } + const objects = Array.isArray(payload.stagedVersions?.objects) + ? (payload.stagedVersions.objects as Array>) + : [] + let tarballs = objects.map(mapStagedTarball) + const filter = (packageFilter ?? '').trim().replace(/^@/, '').toLowerCase() + if (filter) { + tarballs = tarballs.filter(t => + t.packageName.replace(/^@/, '').toLowerCase().includes(filter), + ) + } + return { + approveUrl: asString(payload.approveURL) ?? '', + csrfToken: asString(payload.csrftoken) ?? '', + rejectUrl: asString(payload.rejectURL) ?? '', + tarballs, + total: + typeof payload.stagedVersions?.total === 'number' + ? payload.stagedVersions.total + : objects.length, + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-read.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-read.mts new file mode 100644 index 00000000..746ae622 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-read.mts @@ -0,0 +1,239 @@ +/** + * @file Browser-read staged-tarball passback for the publish gate. Drives + * system Chrome via playwright-core in a durable profile: the operator signs + * in to npmjs.com once (OAuth / 2FA in the window), then the SAME signed-in + * session reads `/settings//staged-packages?format=json` — the staged + * view is session-only, invisible to the registry API — and downloads each + * staged tarball's bytes THROUGH that session. Those bytes + identities feed + * the Socket scan gate (`scan.mts`) so it scans exactly what npm has staged, + * without a registry token. The session, the launch shape, the sign-in wait, + * and the human-verification PAUSE all come from the sanctioned + * `browser-session.mts` — this file adds no launch logic of its own. A + * Cloudflare interstitial pauses for the operator with a visible countdown + * and is never mis-parsed as JSON nor retried on a ladder. The playwright + * I/O is isolated here; the pure parsers live in `staged-browser-parse.mts` + * and are unit-tested there. + */ + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import type { Page } from 'playwright-core' + +import { logger } from '../shared.mts' +import { + fetchInPage, + NPM_ORIGIN, + openNpmBrowserSession, + pauseForChallenge, + sleep, +} from './browser-session.mts' +import type { NpmBrowserSessionOptions } from './browser-session.mts' +import { + classifyStagedFetch, + parseStagedPayload, +} from './staged-browser-parse.mts' +import type { StagedPayload, StagedTarball } from './staged-browser-parse.mts' +import { errorMessage } from '@socketsecurity/lib/errors/message' + +// Browser-read tarball size ceiling: the in-page base64 round-trip peaks at +// several times the tarball size and would OOM the renderer or exceed V8's max +// string length on a huge artifact. 256 MB is generous for a package tarball +// and well under that ceiling; a larger staged artifact falls back to the +// registry/local pack path. +const MAX_STAGED_TARBALL_BYTES = 256 * 1024 * 1024 + +// A status-0 result is a mid-navigation race from a destroyed execution +// context, not a challenge; it clears almost immediately, so it gets a small +// bounded number of fast retries and nothing more. +const RACE_RETRY_MS = 2000 +const RACE_MAX_ATTEMPTS = 3 + +// Read the staged-packages payload. A human-verification challenge PAUSES for +// the operator through the sanctioned helper — never a retry ladder, which +// against a bot challenge earns a rate limit. +async function readStagedPayload( + page: Page, + scope: string, + packageFilter: string | undefined, + options?: + | { + challengeBudgetMs?: number | undefined + challengePollMs?: number | undefined + raceRetryMs?: number | undefined + } + | undefined, +): Promise { + const opts = { __proto__: null, ...options } as NonNullable + const url = `${NPM_ORIGIN}/settings/${encodeURIComponent(scope)}/staged-packages?format=json` + const started = Date.now() + let announced = false + let raceAttempts = 0 + for (;;) { + // eslint-disable-next-line no-await-in-loop -- serial poll: one live page, one challenge at a time. + const last = await fetchInPage(page, url, 'application/json') + const state = classifyStagedFetch({ body: last.body, status: last.status }) + if (state === 'ok') { + return parseStagedPayload(last.body, packageFilter) + } + if (state === 'auth') { + throw new Error( + `Staged-packages read needs sign-in (HTTP ${last.status}). Re-run and sign in.`, + ) + } + if (state === 'error') { + // A status-0 result is fetchInPage's documented mid-navigation race from + // a destroyed execution context — retry it a couple of times, fast. + if (last.status === 0 && raceAttempts < RACE_MAX_ATTEMPTS) { + raceAttempts += 1 + // eslint-disable-next-line no-await-in-loop -- serial short retry for a navigation race. + await sleep(opts.raceRetryMs ?? RACE_RETRY_MS) + continue + } + throw new Error( + `Staged-packages read failed (HTTP ${last.status}). Re-run and sign in.`, + ) + } + // eslint-disable-next-line no-await-in-loop -- serial pause while the operator solves the challenge. + const pause = await pauseForChallenge(page, { + announced, + budgetMs: opts.challengeBudgetMs, + elapsedMs: Date.now() - started, + label: 'the staged-packages read', + pollMs: opts.challengePollMs, + url, + }) + announced = pause.announced + } +} + +/** + * Download one staged tarball's bytes through the signed-in page session (the + * staged tarball URL is not publicly resolvable) and write them to a temp + * file, returning its path. Returns undefined when the entry has no URL or the + * fetch fails, so the gate can fall back to the registry-API download. + */ +export async function downloadStagedTarballInPage( + page: Page, + tarball: StagedTarball, +): Promise { + const url = tarball.tarballUrl + if (!url) { + return undefined + } + const label = `${tarball.packageName}@${tarball.version}` + let result: + | { base64: string; kind: 'ok' } + | { bytes: number; kind: 'too-large' } + | { kind: 'error' } + try { + result = await page.evaluate( + async ({ fetchUrl, maxBytes }) => { + // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- runs in the npm page's MAIN world; only the page session can read the staged tarball. + const r = await fetch(fetchUrl, { + cache: 'no-store', + credentials: 'same-origin', + }) + if (!r.ok) { + return { kind: 'error' as const } + } + // Reject before buffering when the server declares an oversize body, + // and again after reading in case it was chunked with no length. The + // base64 round-trip below peaks at several times the tarball size and + // would OOM the renderer or blow V8's max string length on a huge + // artifact; a too-large result falls back to the registry/local pack. + const declared = Number(r.headers.get('content-length') || '0') + if (declared > maxBytes) { + return { bytes: declared, kind: 'too-large' as const } + } + const buf = new Uint8Array(await r.arrayBuffer()) + if (buf.byteLength > maxBytes) { + return { bytes: buf.byteLength, kind: 'too-large' as const } + } + let binary = '' + for (let i = 0, { length } = buf; i < length; i += 1) { + binary += String.fromCharCode(buf[i]!) + } + return { base64: btoa(binary), kind: 'ok' as const } + }, + { fetchUrl: url, maxBytes: MAX_STAGED_TARBALL_BYTES }, + ) + } catch (e) { + logger.warn( + `Could not read staged tarball for ${label} in the browser (${errorMessage(e)}).`, + ) + return undefined + } + if (result.kind === 'too-large') { + logger.warn( + `Staged tarball for ${label} is ${result.bytes} bytes, over the ${MAX_STAGED_TARBALL_BYTES}-byte browser-read cap; falling back to the registry/local pack.`, + ) + return undefined + } + if (result.kind === 'error' || !result.base64) { + return undefined + } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-staged-tar-')) + const file = path.join(dir, 'staged.tgz') + await fs.writeFile(file, Buffer.from(result.base64, 'base64')) + return file +} + +/** + * A live browser-read session: the staged tarballs plus the page + context. + */ +export interface StagedBrowserSession { + close: () => Promise + page: Page + scope: string + tarballs: StagedTarball[] +} + +/** + * Open a signed-in npm browser session and enumerate the staged tarballs. The + * caller uses the returned `page` with `downloadStagedTarballInPage` to pull + * each artifact's bytes, then MUST call `close()`. `scope` defaults to the + * signed-in user; `packageFilter` narrows the list. Seams (`launch`) are + * injectable so tests never launch a browser. + */ +export async function openStagedBrowserSession( + options?: + | (NpmBrowserSessionOptions & { packageFilter?: string | undefined }) + | undefined, +): Promise { + const { packageFilter, ...sessionOptions } = { + __proto__: null, + ...options, + } as NonNullable + const session = await openNpmBrowserSession(sessionOptions) + const { page, user } = session + try { + const payload = await readStagedPayload(page, user, packageFilter) + logger.log( + `Browser-read staged: ${payload.tarballs.length} of ${payload.total} staged package(s) for ${user}.`, + ) + return { + close: session.close, + page, + scope: user, + tarballs: payload.tarballs, + } + } catch (e) { + await session.close() + throw e + } +} + +/** + * Whether the caller asked for the browser-read passback via argv/env. + */ +export function browserStagedRequested( + argv: readonly string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + argv.includes('--staged-browser') || env['SOCKET_STAGED_BROWSER'] === '1' + ) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-workspace.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-workspace.mts new file mode 100644 index 00000000..e1984151 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged-workspace.mts @@ -0,0 +1,549 @@ +/** + * @file `--staged` / `--direct` publish over a MULTI-PACKAGE workspace layout + * decmpfs, stuie: gate the whole set first — version lockstep, every + * declared platform package present on disk, no hollow platform package, an + * orderable dependency graph — then publish each member + * in dependency order (platform packages before the loader that + * optional-depends on them; `pnpm -r publish`'s topological semantics, + * computed via computePublishOrder so the per-package gates run in the same + * order the registry receives the uploads). Every member also stands behind + * the pack preflight (pack-preflight.mts) — its packed tarball must carry + * every declared payload file before the publish command runs. + * Already-published members are skipped LOUD (the partial-publish recovery + * path); the first failed upload aborts the rest so a dependent never + * publishes ahead of its missing dependency. Single-package repos never reach + * this module — staged.mts delegates here only for `kind: 'multi'` layouts. + */ + +import crypto from 'node:crypto' +import { existsSync, promises as fs, readFileSync, statSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { safeDelete } from '@socketsecurity/lib/fs/safe' + +import { releaseBehindLiveGate } from '../release.mts' +import { + logger, + provenanceAllowed, + runCapture, + runInherit, +} from '../shared.mts' +import { withPinnedReadme } from '../pin-readme.mts' +import { withPrunedPackManifest } from './pack-manifest.mts' +import { verifyPackedPayload } from './pack-preflight.mts' +import { + diagnoseStageConflict, + diagnoseStagedAuthFailure, + isAlreadyPublished, +} from './registry.mts' +import { + isStagingExpected, + logNpmApproveHandoff, + resolveReleaseAccess, +} from './shared.mts' +import { + checkVersionLockstep, + computePublishOrder, + findAbsentPlatformPackages, + findHollowPackages, + requiredPayloadFiles, +} from './workspace-plan.mts' +import { tarExecutable } from '../../_shared/tar-executable.mts' +import { writeThroughMirrorLock } from '../../_shared/mirror-lock.mts' + +import type { StageListEntry } from './shared.mts' +import type { NpmWorkspaceLayout, WorkspacePackage } from './workspace.mts' + +function pinTargetForPackage( + layout: NpmWorkspaceLayout, + pkg: WorkspacePackage, +): { + readmePath: string + repository: string | { url?: string | undefined } | undefined + rootPath: string + version: string +} { + return { + readmePath: 'README.md', + // The member manifest's repository wins; the layout's (root/main) is the + // fallback so a generated platform manifest that omits it still pins. + repository: pkg.manifest.repository ?? layout.repository, + rootPath: pkg.dir, + version: layout.versionSource.version, + } +} + +/** + * Pack the workspace member that publishes `name` from its own directory + * pnpm packs the cwd package and writes the tarball there, with the same + * README-pin + manifest-prune brackets as the publish itself so the + * approve-time verify pack sees identical bytes. Fails loud (returning + * undefined) when no member publishes `name` — the stage list is + * account-scoped, so a foreign repo's entry must never pack here. + */ +export async function packWorkspaceMemberTarball( + layout: NpmWorkspaceLayout, + name: string, + version: string, +): Promise { + const member = layout.packages.find(pkg => pkg.name === name) + if (!member) { + logger.fail( + `Refusing to pack ${name}@${version} from ${layout.rootPath}: this ` + + `workspace publishes ${layout.packages.map(pkg => pkg.name).join(', ')}. ` + + `A cross-repo pack would pin the README against the wrong ` + + `repository/version. Run the publish flow from ${name}'s own repo.`, + ) + return undefined + } + const packed = await withPinnedReadme( + pinTargetForPackage(layout, member), + () => + withPrunedPackManifest(member.dir, () => + runCapture('pnpm', ['pack'], member.dir), + ), + ) + const tarballPath = path.join( + member.dir, + `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`, + ) + return packed.code === 0 && existsSync(tarballPath) ? tarballPath : undefined +} + +/** + * Run the pre-publish gates every multi-package publish stands behind, in + * fail-loud order: version lockstep across every member, every declared + * platform package present on disk, no hollow platform package, an orderable + * dependency graph. Returns the publish order, or undefined after failing loud + * (process.exitCode set). Exported for tests. + */ +export function gateWorkspaceForPublish( + layout: NpmWorkspaceLayout, +): WorkspacePackage[] | undefined { + const drift = checkVersionLockstep(layout) + if (drift.length > 0) { + logger.fail( + `Version lockstep is broken across the workspace's publishable ` + + `packages.\n Where: ${layout.rootPath}\n Saw vs wanted:\n` + + drift.map(line => ` ${line}`).join('\n') + + `\n Fix: run the bump (scripts/socket-release/bump.mts) so every manifest ` + + `and sibling pin moves to ${layout.versionSource.version} in ` + + `lockstep; never hand-edit one member.`, + ) + process.exitCode = 1 + return undefined + } + const absent = findAbsentPlatformPackages(layout.packages) + if (absent.length > 0) { + const detail = absent + .map( + report => + ` ${report.owner.relDir} (${report.owner.name}) declares ` + + `${report.missing.join(', ')}`, + ) + .join('\n') + logger.fail( + `Refusing to publish a loader whose declared platform package(s) are ` + + `ABSENT from the workspace — an optionalDependency that never ` + + `publishes 404s on every consumer install.\n` + + ` Where:\n${detail}\n` + + ` Saw vs wanted: the loader's optionalDependencies name platform ` + + `siblings with NO package directory on disk (repos gitignore their ` + + `generated npm// dirs, so a clean checkout has none); ` + + `wanted every declared name backed by a real package directory ` + + `carrying its payload before any upload.\n` + + ` Fix: run the platform matrix build so the artifacts exist — ` + + `decmpfs's build-addons job builds each runner's .node, runs ` + + `make-npm-dirs.mts, and stages the payload into ` + + `napi/decmpfs/npm// before the publish leg — or, if these ` + + `names are genuinely unpublished, reserve and publish them FIRST; a ` + + `loader whose optionalDependencies 404 breaks every consumer install.`, + ) + process.exitCode = 1 + return undefined + } + const hollow = findHollowPackages(layout.packages) + if (hollow.length > 0) { + const detail = hollow + .map( + report => + ` ${report.pkg.relDir} (${report.pkg.name}): missing ` + + report.missing.join(', '), + ) + .join('\n') + logger.fail( + `Refusing to publish HOLLOW platform package(s) — a platform dir ` + + `without its prebuilt payload breaks every consumer install.\n` + + ` Where:\n${detail}\n` + + ` Saw vs wanted: declared payload files absent on disk; wanted ` + + `every literal files/main entry present before any upload.\n` + + ` Fix: stage the CI-built binaries into the platform dirs (the ` + + `repo's make-npm-dirs script copies the host build), then re-run.`, + ) + process.exitCode = 1 + return undefined + } + const { cycle, order } = computePublishOrder(layout.packages) + if (cycle) { + logger.fail( + `The workspace dependency graph cannot be publish-ordered.\n` + + ` Where: ${layout.rootPath}\n` + + ` Saw vs wanted: a dependency cycle among ${cycle.join(', ')}; ` + + `wanted an acyclic graph (platform packages → loader → consumers).\n` + + ` Fix: break the cycle (a workspace member must not depend on its ` + + `own dependent), then re-run.`, + ) + process.exitCode = 1 + return undefined + } + return order +} + +/** + * Approve-time verify for a GENERATED PLATFORM package. Its prebuilt payload + * comes from the CI build matrix, so a local re-pack can never byte-match the + * staged tarball (the local checkout has no — or a differently-built — .node + * binary); the byte-compare gate (verifyStagedEntry) is the wrong axis here. + * The honest axis is STRUCTURAL, on the staged bytes themselves: download the + * staged tarball, and require (1) its manifest names exactly + * `entry.name@entry.version` and (2) every declared payload file (literal + * `files` entries + `main`) present AND non-empty inside it — a hollow + * platform tarball never reaches the approve prompt. Fails LOUD and returns + * false on any missing evidence. `downloadStagedTarball` is injected by the + * caller, approve passes the stage-download helper — also the test seam. + */ +export async function verifyStagedPlatformEntry( + entry: StageListEntry, + pkg: WorkspacePackage, + options?: + | { + downloadStagedTarball?: + | ((stageId: string) => Promise) + | undefined + } + | undefined, +): Promise { + const { downloadStagedTarball } = { __proto__: null, ...options } as { + downloadStagedTarball?: + | ((stageId: string) => Promise) + | undefined + } + const { name, stageId, version } = entry + if (!name || !version || !stageId || !downloadStagedTarball) { + logger.fail( + `Pre-approve verify: staged platform entry is missing ` + + `name/version/stageId (or no downloader was supplied).\n` + + ` Where: ${JSON.stringify(entry)}\n` + + ` Fix: re-stage the package; do not approve an entry the registry ` + + `can't identify.`, + ) + return false + } + const tarballPath = await downloadStagedTarball(stageId) + if (!tarballPath) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}.\n` + + ` Where: the staged tarball could not be downloaded (stageId ` + + `${stageId}) — a platform package verifies on the STAGED bytes (its ` + + `CI-built payload has no local twin to byte-compare).\n` + + ` Fix: check npm auth (pnpm stage download ${stageId}), or reject + ` + + `re-stage. Not approving unverified bytes.`, + ) + return false + } + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-platform-')) + try { + const untar = await runCapture( + tarExecutable(), + ['-xzf', tarballPath, '-C', tmpDir], + tmpDir, + ) + if (untar.code !== 0) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}: extracting the ` + + `staged tarball failed (tar exited ${untar.code}).`, + ) + return false + } + // npm tarballs root their contents at `package/`. + const packageDir = path.join(tmpDir, 'package') + let staged: { name?: unknown | undefined; version?: unknown | undefined } + try { + staged = JSON.parse( + readFileSync(path.join(packageDir, 'package.json'), 'utf8'), + ) as typeof staged + } catch { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}: the staged ` + + `tarball carries no readable package.json.`, + ) + return false + } + if (staged.name !== name || staged.version !== version) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}.\n` + + ` Saw vs wanted: the staged tarball's manifest reads ` + + `${String(staged.name)}@${String(staged.version)}; wanted ` + + `${name}@${version}.\n` + + ` Fix: reject the staged publish (node scripts/socket-release/npm-web-auth.mts stage reject ${stageId}) ` + + `and re-stage.`, + ) + return false + } + const hollow: string[] = [] + const payload = requiredPayloadFiles(pkg.manifest) + for (let i = 0, { length } = payload; i < length; i += 1) { + const rel = payload[i]! + const filePath = path.join(packageDir, rel) + let size = -1 + try { + // oxlint-disable-next-line socket/prefer-exists-sync -- the SIZE is the point: a zero-byte payload is as hollow as a missing one. + size = statSync(filePath).size + } catch { + // Missing file — recorded below. + } + if (size <= 0) { + hollow.push(rel) + } + } + if (hollow.length > 0) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}: the staged ` + + `tarball is HOLLOW.\n` + + ` Saw vs wanted: missing/empty payload file(s) ` + + `${hollow.join(', ')}; wanted every declared platform payload ` + + `present and non-empty.\n` + + ` Fix: reject the staged publish (node scripts/socket-release/npm-web-auth.mts stage reject ${stageId}) ` + + `and re-stage from a CI run whose build artifacts landed.`, + ) + return false + } + logger.success( + `Verified ${name}@${version}: staged platform tarball carries its ` + + `declared payload (structural verify on the staged bytes).`, + ) + return true + } finally { + await safeDelete(tmpDir) + } +} + +/** + * Pack this workspace's release assets: one tarball per publishable member + * (packed from its own dir with the shared pin/prune brackets) plus a + * checksums.txt (sha1 + sha512 per tarball) for the GitHub release. Members + * whose pack fails are reported loud and skipped — the release still lands + * with the assets that packed, matching the single-subject fail-open asset + * behavior. + */ +export async function packWorkspaceReleaseAssets( + layout: NpmWorkspaceLayout, +): Promise { + const version = layout.versionSource.version + const assets: string[] = [] + const checksumLines: string[] = [] + for (const pkg of layout.packages) { + // eslint-disable-next-line no-await-in-loop -- serial packs; each rewrites its member's manifest in place + const tarballPath = await packWorkspaceMemberTarball( + layout, + pkg.name, + version, + ) + if (!tarballPath) { + logger.warn( + `pnpm pack failed for ${pkg.name}@${version}; releasing without its ` + + `tarball asset.`, + ) + continue + } + const bytes = readFileSync(tarballPath) + const tarballName = path.basename(tarballPath) + checksumLines.push( + `sha1: ${crypto.createHash('sha1').update(bytes).digest('hex')} ${tarballName}`, + `sha512-base64: ${crypto.createHash('sha512').update(bytes).digest('base64')} ${tarballName}`, + ) + assets.push(tarballPath) + } + if (assets.length > 0) { + const checksumsPath = path.join(layout.rootPath, 'checksums.txt') + writeThroughMirrorLock(checksumsPath, `${checksumLines.join('\n')}\n`) + assets.push(checksumsPath) + } + return assets +} + +/** + * `--staged` / `--direct` over a multi layout. Mirrors the single-subject + * modes per member: already-published refusal (as a loud SKIP — the + * partial-publish recovery path), the `--direct` trust-downgrade refusal, + * README pin + manifest prune around every pack, provenance in CI. The first + * non-zero upload aborts the remainder — dependency order guarantees nothing + * publishes ahead of a failed dependency. + */ +export async function runWorkspacePublish( + mode: 'direct' | 'staged', + tag: string, + layout: NpmWorkspaceLayout, + options?: { dryRun?: boolean | undefined } | undefined, +): Promise { + const { dryRun = false } = { __proto__: null, ...options } as { + dryRun?: boolean | undefined + } + const order = gateWorkspaceForPublish(layout) + if (!order) { + return + } + const { version } = layout.versionSource + const verb = mode === 'staged' ? 'Staging' : 'Direct-publishing' + logger.log( + `${verb} ${order.length} workspace package(s) at ${version} ` + + `(tag=${tag})${dryRun ? ' [dry-run]' : ''}: ` + + order.map(pkg => pkg.name).join(', '), + ) + let published = 0 + let skipped = 0 + for (const pkg of order) { + // eslint-disable-next-line no-await-in-loop -- serial by design: dependency order is the point + if (await isAlreadyPublished(pkg.name, version)) { + logger.log( + `Skipping ${pkg.name}@${version} — already on the registry ` + + `(partial-publish recovery); publishing the remaining members.`, + ) + skipped += 1 + continue + } + if (mode === 'direct') { + // Trust-downgrade refusal, same as the single-subject --direct: a + // member with staged-published history must not silently downgrade. + // eslint-disable-next-line no-await-in-loop -- serial by design + if (await isStagingExpected(pkg.name)) { + logger.fail( + `${pkg.name} has prior staged-published versions (per registry ` + + `_npmUser.approver). --direct would downgrade the trust signal. ` + + `Use --staged instead. Aborting the remaining members.`, + ) + process.exitCode = 1 + return + } + } + const access = resolveReleaseAccess({ + manifestPath: pkg.manifestPath, + packageName: pkg.name, + }) + const args = mode === 'staged' ? ['stage', 'publish'] : ['publish'] + args.push( + '--access', + access, + '--tag', + tag, + '--no-git-checks', + '--ignore-scripts', + ) + if (process.env['GITHUB_ACTIONS'] === 'true') { + if (provenanceAllowed()) { + args.push('--provenance') + } else { + logger.warn( + 'Provenance skipped: npm only verifies sigstore bundles from ' + + 'PUBLIC source repositories, and this run is not one. The ' + + 'upload proceeds unattested; provenance turns back on ' + + 'automatically when the repo is public.', + ) + } + } + if (dryRun) { + args.push('--dry-run') + } + // Same README-pin + manifest-prune brackets as the single-subject modes, + // per member, so the approve-time verify pack sees identical bytes. The + // pack preflight runs inside them, before the command, so a member whose + // tarball is missing declared payload never stages or publishes. + let preflightOk = true + // eslint-disable-next-line no-await-in-loop -- serial by design + const code = await withPinnedReadme(pinTargetForPackage(layout, pkg), () => + withPrunedPackManifest(pkg.dir, async () => { + preflightOk = await verifyPackedPayload({ + dir: pkg.dir, + manifest: pkg.manifest, + name: pkg.name, + version, + }) + if (!preflightOk) { + return 1 + } + return await runInherit('pnpm', args, pkg.dir) + }), + ) + if (!preflightOk) { + logger.fail( + `Pack preflight failed for ${pkg.name}@${version} ` + + `(${path.relative(layout.rootPath, pkg.dir)}). Aborting the ` + + `remaining members — a hollow tarball must never stage or publish.`, + ) + process.exitCode = 1 + return + } + if (code !== 0) { + logger.fail( + `pnpm ${mode === 'staged' ? 'stage publish' : 'publish'} exited ` + + `${code} for ${pkg.name}@${version} ` + + `(${path.relative(layout.rootPath, pkg.dir)}). Aborting the ` + + `remaining members — a dependent must never publish ahead of a ` + + `failed dependency.`, + ) + // eslint-disable-next-line no-await-in-loop -- failure path, loop exits here + for (const line of await diagnoseStageConflict(pkg.name, version)) { + logger.fail(line) + } + // eslint-disable-next-line no-await-in-loop -- failure path, loop exits here + for (const line of await diagnoseStagedAuthFailure(pkg.name)) { + logger.fail(line) + } + process.exitCode = code + return + } + published += 1 + } + if (published === 0 && skipped === order.length) { + logger.fail( + `Every workspace package is already published at ${version}. Bump the ` + + `version and try again.`, + ) + process.exitCode = 1 + return + } + if (dryRun) { + logger.success( + `Dry-run complete for ${published} package(s) at ${version}. Re-run ` + + `without --dry-run to ${mode === 'staged' ? 'upload' : 'publish'}.`, + ) + return + } + if (mode === 'staged') { + logger.success( + `Staged ${published} package(s) at ${version}` + + `${skipped ? ` (${skipped} already published, skipped)` : ''}.`, + ) + logNpmApproveHandoff() + } else { + logger.success( + `Published ${published} package(s) at ${version} directly` + + `${skipped ? ` (${skipped} already published, skipped)` : ''}.`, + ) + // ONE tag + immutable release per lockstep version (every member shares + // it), cut behind the MAIN package's registry liveness — the last member + // published, so the whole set is live once it resolves. + const main = layout.main! + const released = await releaseBehindLiveGate({ + isLive: () => isAlreadyPublished(main.name, version), + packAssets: () => packWorkspaceReleaseAssets(layout), + pkg: { name: main.name, version }, + registry: 'npm', + }) + if (!released) { + process.exitCode = 1 + } + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/staged.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged.mts new file mode 100644 index 00000000..d782a6fb --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/staged.mts @@ -0,0 +1,756 @@ +/** + * @file `--staged` / `--direct` publish modes, and the pre-approve tarball + * pack + integrity-gate helpers `--approve` verifies against before + * promoting a staged package to public. + */ + +import crypto from 'node:crypto' +import { existsSync, promises as fs, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { safeDelete } from '@socketsecurity/lib/fs/safe' +import { normalizePath } from '@socketsecurity/lib/paths/normalize' + +import type { + HashSource, + TarballDigest, +} from '../../lib/verify-release-hashes.mts' +import { + compareHashSources, + hashTarball, +} from '../../lib/verify-release-hashes.mts' +import { releaseBehindLiveGate } from '../release.mts' +import { + logger, + provenanceAllowed, + rootPath, + runCapture, + runInherit, +} from '../shared.mts' +import { withPinnedReadme } from '../pin-readme.mts' +import { withPrunedPackManifest } from './pack-manifest.mts' +import { verifyPackedPayload } from './pack-preflight.mts' +import { + diagnoseStageConflict, + diagnoseStagedAuthFailure, + fetchPublishedState, + isAlreadyPublished, +} from './registry.mts' +import type { PublishedState } from './registry.mts' +import type { StageListEntry } from './shared.mts' +import { + isStagingExpected, + logNpmApproveHandoff, + resolveReleaseAccess, +} from './shared.mts' +import { + packWorkspaceMemberTarball, + runWorkspacePublish, + verifyStagedPlatformEntry, +} from './staged-workspace.mts' +import { hasMachineBuiltPayload } from './workspace-plan.mts' +import { resolveNpmWorkspaceLayout } from './workspace.mts' +import { resolveReleaseSubject } from '../../_shared/release-subject.mts' +import { tarExecutable } from '../../_shared/tar-executable.mts' + +import type { WorkspaceManifestShape } from './workspace.mts' +import type { ReleaseSubject } from '../../_shared/release-subject.mts' + +// The README-pin bracket target for a publish subject: the pinned README is +// the one that PACKS — the subject's, not the repo root's when +// publishConfig.directory redirects the publish. Shared by runStaged, +// runDirect, and the approve-time verify pack so every pack of one release +// pins identical bytes. +function pinTargetFor(subject: ReleaseSubject): { + readmePath: string + repository: string | { url?: string | undefined } | undefined + rootPath: string + version: string +} { + return { + readmePath: path.relative(subject.rootPath, subject.readmePath), + repository: subject.repository, + rootPath: subject.rootPath, + version: subject.version, + } +} + +export type StageDecision = 'already-published' | 'stage' + +/** + * The verify-BEFORE-stage decision: should a target version be STAGED, or is it + * ALREADY PUBLISHED? Pure so it is unit-tested without the network. + * + * WHY: staging a version that is already live returns a confusing + * `[E409] Cannot stage previously published version`, and an operator who + * retries just re-hits the 409. When the target is already on the registry + * there is nothing to stage — the caller skips straight to + * verify/approve/release+reconcile, where the release stage cuts the tag + GH + * release if they are missing. Both the `versions` list AND `dist-tags.latest` + * are consulted: a match on either is proof the version is published, so a + * partial read that dropped the version from `versions` but still named it + * `latest` is still caught. The reads that feed this MUST be cache-busted (see + * registry.mts:cacheBustedRead) — a stale CDN packument that omits a live + * version would otherwise green-light a doomed stage. + */ +export function stageAction(config: { + publishedLatest: string | undefined + publishedVersions: readonly string[] + target: string +}): StageDecision { + const { publishedLatest, publishedVersions, target } = { + __proto__: null, + ...config, + } as typeof config + const published = + target === publishedLatest || publishedVersions.includes(target) + return published ? 'already-published' : 'stage' +} + +/** + * `--staged` mode: stage this package's tarball. + * + * Reads the local package.json for name + version, refuses to stage an + * already-published version (npm rejects republishes outright; we surface the + * error before the network call). Runs `pnpm stage publish` with --provenance + * when GITHUB_ACTIONS is set AND the source repository is public + * (provenanceAllowed) so the OIDC token gets embedded into the provenance + * attestation; a private-repo run skips the flag loudly instead of hitting + * npm's E422 sigstore-visibility rejection. + */ +export async function runStaged( + tag: string, + config: { dryRun: boolean }, +): Promise { + const { dryRun } = { __proto__: null, ...config } as typeof config + // Multi-package workspace, decmpfs, stuie: the workspace runner publishes + // every member in dependency order behind the lockstep + hollow gates. + // Single-package repos take the identical-to-before subject path below. + const layout = resolveNpmWorkspaceLayout(rootPath) + if (layout.kind === 'multi') { + await runWorkspacePublish('staged', tag, layout, { dryRun }) + return + } + const pkg = resolveReleaseSubject(rootPath) + logger.log( + `Staging ${pkg.name}@${pkg.version} (tag=${tag})${dryRun ? ' [dry-run]' : ''}`, + ) + + // Verify BEFORE staging: a cache-busted packument read (never a stale CDN + // copy) settles whether the target is already live. If it is, staging would + // return a confusing `[E409] Cannot stage previously published version`, so + // skip the stage cleanly and let the pipeline advance — the release stage + // cuts the tag + GH release if they are still missing. + const published = await fetchPublishedState(pkg.name) + if ( + stageAction({ + publishedLatest: published.latest, + publishedVersions: published.versions, + target: pkg.version, + }) === 'already-published' + ) { + logger.success( + `${pkg.name}@${pkg.version} already published — nothing to stage; ` + + `proceed to verify/approve/release+reconcile (the release stage cuts ` + + `the tag + GH release if missing).`, + ) + return + } + + const access = resolveReleaseAccess({ + manifestPath: pkg.manifestPath, + packageName: pkg.name, + }) + const args = [ + 'stage', + 'publish', + '--access', + access, + '--tag', + tag, + '--no-git-checks', + '--ignore-scripts', + ] + if (process.env['GITHUB_ACTIONS'] === 'true') { + if (provenanceAllowed()) { + args.push('--provenance') + } else { + logger.warn( + 'Provenance skipped: npm only verifies sigstore bundles from PUBLIC ' + + 'source repositories, and this run is not one. The upload proceeds ' + + 'unattested; provenance turns back on automatically when the repo ' + + 'is public.', + ) + } + } + if (dryRun) { + // pnpm stage publish --dry-run does everything except the actual + // upload; surfaces packing errors + manifest validation without + // touching the registry. + args.push('--dry-run') + } + // Pin the SUBJECT README's relative asset URLs to the release tag for the + // packed tarball only, restored right after, so the npm page's badge is + // immutable + matches this version instead of a moving HEAD ref, and prune + // repo-only lifecycle scripts from the manifest that packs. The same + // brackets wrap the --approve verify pack (defaultPackTarball) so the + // integrity gate sees identical bytes. The pack preflight runs INSIDE the + // brackets too — the bytes it inspects are the bytes the stage command + // uploads — and a tarball missing any declared payload file stops the + // publish before the command runs. + const subjectManifest = JSON.parse( + readFileSync(pkg.manifestPath, 'utf8'), + ) as WorkspaceManifestShape + let preflightOk = true + const code = await withPinnedReadme(pinTargetFor(pkg), () => + withPrunedPackManifest(pkg.dir, async () => { + preflightOk = await verifyPackedPayload({ + dir: pkg.dir, + manifest: subjectManifest, + name: pkg.name, + version: pkg.version, + }) + if (!preflightOk) { + return 1 + } + return await runInherit('pnpm', args, rootPath) + }), + ) + if (!preflightOk) { + process.exitCode = 1 + return + } + if (code !== 0) { + logger.fail(`pnpm stage publish exited ${code}`) + for (const line of await diagnoseStageConflict(pkg.name, pkg.version)) { + logger.fail(line) + } + for (const line of await diagnoseStagedAuthFailure(pkg.name)) { + logger.fail(line) + } + process.exitCode = code + return + } + if (dryRun) { + logger.success( + `Dry-run complete for ${pkg.name}@${pkg.version}. Re-run without --dry-run to upload.`, + ) + } else { + logger.success(`Staged ${pkg.name}@${pkg.version}.`) + logNpmApproveHandoff() + } +} + +/** + * `--direct` mode: classic single-step `pnpm publish` — upload + make public in + * one call, no stage/approve. Escape hatch for environments where the stage + * endpoint is unreachable. Adds `--provenance` automatically when + * GITHUB_ACTIONS is set and the source repository is public + * (provenanceAllowed) so the OIDC token still embeds into the provenance + * attestation. + * + * Refuses to run when the package's prior versions used staging (per the + * packument's `_npmUser.approver` signal). Downgrading erases the trust signal + * from the package's history. Operators who hit the refusal should either use + * `--staged` (preferred) or accept the trust regression by removing the prior + * staged-published versions from the registry first. + */ +export async function runDirect( + tag: string, + config: { + dryRun: boolean + ensureAlreadyPublishedRelease?: + | ((pkg: { name: string; version: string }) => Promise) + | undefined + fetchPublished?: ((name: string) => Promise) | undefined + root?: string | undefined + }, +): Promise { + const { + dryRun, + ensureAlreadyPublishedRelease, + fetchPublished, + root: rootOverride, + } = { __proto__: null, ...config } as typeof config + const root = rootOverride ?? rootPath + // Multi-package workspace: same delegation as runStaged. + const layout = resolveNpmWorkspaceLayout(root) + if (layout.kind === 'multi') { + await runWorkspacePublish('direct', tag, layout, { dryRun }) + return + } + const pkg = resolveReleaseSubject(root) + logger.log( + `Direct-publishing ${pkg.name}@${pkg.version} (tag=${tag})${dryRun ? ' [dry-run]' : ''}`, + ) + + // Verify BEFORE publishing: a cache-busted packument read settles whether the + // target is already live. If it is, re-publishing errors; skip the upload and + // heal idempotently — ensure the tag + GH release exist behind the liveness + // gate — instead of failing. + const published = await (fetchPublished ?? fetchPublishedState)(pkg.name) + if ( + stageAction({ + publishedLatest: published.latest, + publishedVersions: published.versions, + target: pkg.version, + }) === 'already-published' + ) { + if (dryRun) { + logger.log( + `[dry-run] ${pkg.name}@${pkg.version} already published — would ensure ` + + `the tag + GitHub release exist (no writes).`, + ) + return + } + logger.success( + `${pkg.name}@${pkg.version} already published — nothing to publish; ` + + `ensuring the tag + GH release exist.`, + ) + const ensureRelease = + ensureAlreadyPublishedRelease ?? + ((target: { name: string; version: string }) => + releaseBehindLiveGate({ + isLive: () => isAlreadyPublished(target.name, target.version), + pkg: target, + registry: 'npm', + })) + const released = await ensureRelease({ + name: pkg.name, + version: pkg.version, + }) + if (!released) { + process.exitCode = 1 + } + return + } + + // Trust-downgrade refusal: if any prior version of this package was + // staged-published (carries `_npmUser.approver`), --direct would erase + // that trust signal. Force the operator to use --staged or make the + // downgrade explicit. Skips on first-publish packages (no prior + // versions) and on network failure (which we treat as "unknown"). + if (await isStagingExpected(pkg.name)) { + logger.fail( + `${pkg.name} has prior staged-published versions (per registry _npmUser.approver). ` + + `--direct would downgrade the trust signal. Use --staged instead, or ` + + `(rare) remove the prior staged-published versions first.`, + ) + process.exitCode = 1 + return + } + + const access = resolveReleaseAccess({ + manifestPath: pkg.manifestPath, + packageName: pkg.name, + }) + const args = [ + 'publish', + '--access', + access, + '--tag', + tag, + '--no-git-checks', + '--ignore-scripts', + ] + if (process.env['GITHUB_ACTIONS'] === 'true') { + if (provenanceAllowed()) { + args.push('--provenance') + } else { + logger.warn( + 'Provenance skipped: npm only verifies sigstore bundles from PUBLIC ' + + 'source repositories, and this run is not one. The upload proceeds ' + + 'unattested; provenance turns back on automatically when the repo ' + + 'is public.', + ) + } + } + if (dryRun) { + args.push('--dry-run') + } + // Pin the SUBJECT README to the release tag + prune repo-only lifecycle + // scripts for the published tarball only, and run the pack preflight inside + // the same brackets so a hollow tarball never publishes (see runStaged). + const subjectManifest = JSON.parse( + readFileSync(pkg.manifestPath, 'utf8'), + ) as WorkspaceManifestShape + let preflightOk = true + const code = await withPinnedReadme(pinTargetFor(pkg), () => + withPrunedPackManifest(pkg.dir, async () => { + preflightOk = await verifyPackedPayload({ + dir: pkg.dir, + manifest: subjectManifest, + name: pkg.name, + version: pkg.version, + }) + if (!preflightOk) { + return 1 + } + return await runInherit('pnpm', args, rootPath) + }), + ) + if (!preflightOk) { + process.exitCode = 1 + return + } + if (code !== 0) { + logger.fail(`pnpm publish exited ${code}`) + process.exitCode = code + return + } + if (dryRun) { + logger.success( + `Dry-run complete for ${pkg.name}@${pkg.version}. Re-run without --dry-run to publish.`, + ) + } else { + logger.success(`Published ${pkg.name}@${pkg.version} directly.`) + // The tag + immutable release are the LAST markers: cut them only once + // the version is actually resolvable on the registry. + const released = await releaseBehindLiveGate({ + isLive: () => isAlreadyPublished(pkg.name, pkg.version), + pkg: { name: pkg.name, version: pkg.version }, + registry: 'npm', + }) + if (!released) { + process.exitCode = 1 + } + } +} + +/** + * Pack `@` from the repo root and return the tarball path, or + * undefined if the pack failed / produced no file. pnpm pack names the tarball + * `-.tgz` (e.g. @socketsecurity/lib@6.0.9 → + * socketsecurity-lib-6.0.9.tgz) — from the PUBLISH SUBJECT's manifest, and + * writes it into the subject directory when publishConfig.directory redirects + * the publish. `root` is injectable for tests. + */ +/** + * A tarball provider: resolves the scan-subject bytes for `name@version` to a + * path, or undefined when this source has nothing (a staged entry with no + * tarballUrl, a failed download). + */ +export type TarballProvider = ( + name: string, + version: string, +) => Promise + +/** + * Compose an ordered list of tarball providers into one that tries each in + * turn and returns the first path a source yields, falling THROUGH a source + * that returns undefined instead of hard-failing. Returns undefined only when + * EVERY source came up empty. This is the artifact-source fallback chain + * (browser-read to registry-API to local pack), factored out of the approve + * loop so the fallthrough is unit-testable without a browser. + */ +export function composeTarballProviders( + sources: readonly TarballProvider[], +): TarballProvider { + return async (name: string, version: string) => { + for (let i = 0, { length } = sources; i < length; i += 1) { + // eslint-disable-next-line no-await-in-loop -- serial fallback: try each source until one yields bytes. + const packed = await sources[i]!(name, version) + if (packed) { + return packed + } + } + return undefined + } +} + +export async function defaultPackTarball( + name: string, + version: string, + root: string = rootPath, +): Promise { + // Multi-package workspace: pack the member that publishes `name` from its + // own directory, pnpm packs the cwd package; a name no member publishes + // gets the same cross-repo refusal as the single-subject path below. + const layout = resolveNpmWorkspaceLayout(root) + if (layout.kind === 'multi') { + return await packWorkspaceMemberTarball(layout, name, version) + } + // Refuse a cross-repo pack outright: the stage list is account-scoped, so a + // caller can hand this an entry staged from ANOTHER repo. Packing it here + // would pin the README against the wrong manifest — this repo's repository + // slug with the foreign entry's version — before failing anyway on the + // tarball-name lookup. Fail loud, with zero pack side effects. The name + // check runs against the SUBJECT manifest, so a redirected monorepo's + // private root name never trips it. + const subject = resolveReleaseSubject(root) + if (subject.name !== name) { + logger.fail( + `Refusing to pack ${name}@${version} from ${root}: this repo's ` + + `package is ${subject.name}. A cross-repo pack would pin the README ` + + `against the wrong repository/version. Run the publish flow from ` + + `${name}'s own repo.`, + ) + return undefined + } + // Same README-pin + manifest-prune brackets as runStaged, so the + // approve-time verify pack is byte-identical to the staged tarball (the + // integrity gate compares them). + const packed = await withPinnedReadme( + { ...pinTargetFor(subject), version }, + () => + withPrunedPackManifest(subject.dir, () => + runCapture('pnpm', ['pack'], root), + ), + ) + const tarballName = `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz` + // pnpm pack writes into the subject directory under a publishConfig + // redirect; probe there first, then the root for belt-and-braces. + for (const dir of [subject.packDir, root]) { + const tarballPath = path.join(dir, tarballName) + if (packed.code === 0 && existsSync(tarballPath)) { + return tarballPath + } + } + return undefined +} + +/** + * Download the staged tarball for `stageId` into a fresh temp dir and return + * its path, undefined on failure. The download endpoint requires the same + * npm auth as the rest of the stage API. + */ +export async function defaultDownloadStagedTarball( + stageId: string, +): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-staged-dl-')) + const dl = await runCapture('pnpm', ['stage', 'download', stageId], tmpDir) + if (dl.code !== 0) { + return undefined + } + const entries = await fs.readdir(tmpDir) + const tgz = entries.find(e => e.endsWith('.tgz')) + return tgz ? path.join(tmpDir, tgz) : undefined +} + +// Relative path → sha1-of-content for every file under `dir`, sorted walk. +async function hashDirContents(dir: string): Promise> { + const result = new Map() + const entries = await fs.readdir(dir, { + recursive: true, + withFileTypes: true, + }) + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + const abs = path.join(entry.parentPath, entry.name) + const rel = normalizePath(path.relative(dir, abs)) + // eslint-disable-next-line no-await-in-loop + const bytes = await fs.readFile(abs) + result.set(rel, crypto.createHash('sha1').update(bytes).digest('hex')) + } + return result +} + +/** + * Compare two tarballs by EXTRACTED CONTENT (per-file sha1 over relative + * paths). The tarball-level sha1 embeds the gzip envelope — platform + tool + * metadata that legitimately differs between CI (linux) and a local pack + * (macOS) even when every shipped byte is identical — so content equality is + * the honest integrity axis. Returns a human-readable detail on mismatch. + */ +export async function compareExtractedTarballs( + tarA: string, + tarB: string, +): Promise<{ equal: boolean; detail: string }> { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-tar-cmp-')) + try { + const dirA = path.join(tmpDir, 'a') + const dirB = path.join(tmpDir, 'b') + await fs.mkdir(dirA) + await fs.mkdir(dirB) + for (const [tar, dir] of [ + [tarA, dirA], + [tarB, dirB], + ] as const) { + // eslint-disable-next-line no-await-in-loop + const untar = await runCapture( + tarExecutable(), + ['-xzf', tar, '-C', dir], + tmpDir, + ) + if (untar.code !== 0) { + return { detail: `tar -xzf ${tar} exited ${untar.code}`, equal: false } + } + } + const hashesA = await hashDirContents(dirA) + const hashesB = await hashDirContents(dirB) + const diffs: string[] = [] + for (const [rel, entryHash] of hashesA) { + const other = hashesB.get(rel) + if (other === undefined) { + diffs.push(`only in first: ${rel}`) + } else if (other !== entryHash) { + diffs.push(`content differs: ${rel}`) + } + } + for (const rel of hashesB.keys()) { + if (!hashesA.has(rel)) { + diffs.push(`only in second: ${rel}`) + } + } + return diffs.length === 0 + ? { detail: `${hashesA.size} file(s) byte-identical`, equal: true } + : { detail: diffs.slice(0, 10).join('; '), equal: false } + } finally { + await safeDelete(tmpDir) + } +} + +/** + * Pre-approve integrity gate. Packs the tarball locally and asserts its sha1 + * equals the shasum npm recorded when the tarball was staged — run BEFORE + * `pnpm stage approve` (the 2FA / OAuth promote) so a divergent artifact never + * goes public. Two-source comparison (local pack + npm staging); the + * GitHub-asset compare + `gh attestation verify` are out of scope here (no + * release exists pre-approve — ensureTagAndRelease runs post-approve). Fails + * LOUD and returns false on any mismatch OR when the staged shasum can't be + * resolved — the caller drops the entry. Never returns true on missing + * evidence. Tarball sha1s embed the gzip envelope (platform metadata that + * differs between CI linux packs and local macOS packs), so a sha1 mismatch + * falls back to downloading the staged tarball and comparing EXTRACTED + * CONTENTS per-file — equality there is the honest integrity axis. `pack`, + * `hashLocalTarball`, and `downloadStagedTarball` are injectable for tests. + */ +/** + * Route a staged entry to the verification axis its payload supports. A + * generated platform package or a machine-built payload (.wasm / .node) has + * no local byte-twin, so it verifies STRUCTURALLY on the staged bytes + * (verifyStagedPlatformEntry) — and the downloaded staged tarball is copied + * to `/-.tgz` so the release-asset checksum pickup + * hashes the bytes that actually shipped, never a divergent local re-pack. + * Everything else keeps the local-pack byte-compare gate (verifyStagedEntry). + */ +export async function verifyStagedEntryRouted( + entry: StageListEntry, +): Promise { + const layout = resolveNpmWorkspaceLayout(rootPath) + const member = + entry.name && layout.kind === 'multi' + ? layout.packages.find(pkg => pkg.name === entry.name) + : undefined + if (member && (member.platform || hasMachineBuiltPayload(member.manifest))) { + const ok = await verifyStagedPlatformEntry(entry, member, { + downloadStagedTarball: defaultDownloadStagedTarball, + }) + if (ok && entry.name && entry.version && entry.stageId) { + const staged = await defaultDownloadStagedTarball(entry.stageId) + if (staged) { + const assetName = `${entry.name.replace(/^@/, '').replace('/', '-')}-${entry.version}.tgz` + await fs.copyFile(staged, path.join(rootPath, assetName)) + } + } + return ok + } + return verifyStagedEntry(entry) +} + +export async function verifyStagedEntry( + entry: StageListEntry, + options?: + | { + downloadStagedTarball?: + | ((stageId: string) => Promise) + | undefined + hashLocalTarball?: ((filePath: string) => TarballDigest) | undefined + packTarball?: + | ((name: string, version: string) => Promise) + | undefined + } + | undefined, +): Promise { + const opts = { __proto__: null, ...options } as { + downloadStagedTarball?: + | ((stageId: string) => Promise) + | undefined + hashLocalTarball?: ((filePath: string) => TarballDigest) | undefined + packTarball?: + | ((name: string, version: string) => Promise) + | undefined + } + const hashLocal = opts.hashLocalTarball ?? hashTarball + const packTarball = opts.packTarball ?? defaultPackTarball + const downloadStaged = + opts.downloadStagedTarball ?? defaultDownloadStagedTarball + const { name, shasum: stagedShasum, stageId, version } = entry + if (!name || !version || !stageId) { + logger.fail( + `Pre-approve verify: staged entry is missing name/version/stageId.\n` + + ` Where: ${JSON.stringify(entry)}\n` + + ` Fix: re-stage the package; do not approve an entry the registry can't identify.`, + ) + return false + } + if (!stagedShasum) { + logger.fail( + `Pre-approve verify: no server-side shasum for ${name}@${version}.\n` + + ` Where: pnpm stage list --json (stageId ${stageId}) exposed no shasum field.\n` + + ` Saw vs wanted: an entry with no digest; wanted npm's staged sha1 to compare against the local pack.\n` + + ` Fix: reject + re-stage (node scripts/socket-release/npm-web-auth.mts stage reject ${stageId}); if pnpm's stage-list shape changed, update readStagedShasum. Refusing to approve unverified bytes.`, + ) + return false + } + const tarballPath = await packTarball(name, version) + if (!tarballPath) { + logger.fail( + `Pre-approve verify: could not pack ${name}@${version} locally.\n` + + ` Where: pnpm pack in ${rootPath}\n` + + ` Saw vs wanted: no local tarball; wanted one to hash against npm's staged shasum.\n` + + ` Fix: fix the pack (check the build), then re-run --approve. Not approving without a local comparison.`, + ) + return false + } + const local = hashLocal(tarballPath) + const sources: HashSource[] = [ + { integrity: local.integrity, label: 'local pack', shasum: local.shasum }, + { integrity: undefined, label: 'npm staging', shasum: stagedShasum }, + ] + const comparison = compareHashSources(sources) + if (!comparison.ok) { + // The tarball sha1 covers the gzip envelope too — CI (linux) and a local + // pack (macOS) legitimately wrap identical contents differently. Fall + // back to comparing what actually ships: the extracted files. + logger.log( + `Tarball sha1 differs for ${name}@${version} (envelope is platform-` + + `sensitive); downloading the staged tarball to compare contents…`, + ) + const stagedTarball = await downloadStaged(stageId) + if (!stagedTarball) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}.\n` + + ` Where: tarball sha1 mismatch AND the staged tarball could not be downloaded for a content compare.\n` + + ` local pack: ${local.shasum}\n` + + ` npm staging: ${stagedShasum}\n` + + ` Fix: check npm auth (pnpm stage download ${stageId}), or reject + re-stage. Not approving unverified bytes.`, + ) + return false + } + const contents = await compareExtractedTarballs(stagedTarball, tarballPath) + if (!contents.equal) { + logger.fail( + `Pre-approve verify FAILED for ${name}@${version}.\n` + + ` Where: comparing staged vs local pack EXTRACTED CONTENTS (after tarball sha1 mismatch).\n` + + ` Saw vs wanted: ${contents.detail}\n` + + ` local pack: ${local.shasum}\n` + + ` npm staging: ${stagedShasum}\n` + + ` Fix: reject the staged publish (node scripts/socket-release/npm-web-auth.mts stage reject ${stageId}) and re-stage — never approve a divergent artifact.`, + ) + return false + } + logger.success( + `Verified ${name}@${version}: staged contents byte-identical to the local pack (${contents.detail}); only the gzip envelope differs.`, + ) + return true + } + logger.log( + `Verified ${name}@${version}: local pack sha1 matches npm staging (${comparison.algorithm}).`, + ) + return true +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/threat-scan.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/threat-scan.mts new file mode 100644 index 00000000..7138c9bc --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/threat-scan.mts @@ -0,0 +1,382 @@ +/** + * @file Opt-in local code-threat scan for the staged-publish gate. Where the + * archive full scan vets the DEPENDENCY graph, this reads the staged + * package's OWN source and asks a keyless on-device model to flag threats + * (install-hook abuse, network exfiltration, obfuscated/eval'd payloads). + * Keyless and no-spend: it drives socket-lib's `builtinLocalProvider` + * (`getLanguageModel()` → `node:smol-ai` on the node-smol runtime, Chrome + * built-in AI, Apple FM, or a loopback llama-server) via `spawnLocalAgent`; + * `ODAI_BACKEND` selects among them. A Gemini-Nano-class model is a coarse + * red-flag triage, not a Claude-grade analyst, so this is a first-pass filter + * behind `--threat-scan` — never the sole gate. The pure file-selection, + * prompt, verdict-parse, and failure-collection are unit-tested here; the + * model I/O is behind an injectable provider so tests never load a model. + */ + +import { promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import { + builtinLocalProvider, + spawnLocalAgent, +} from '@socketsecurity/lib/ai/spawn-local' +import type { LocalAgentProvider } from '@socketsecurity/lib/ai/spawn-local' +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { normalizePath } from '@socketsecurity/lib/paths/normalize' + +import { logger } from '../shared.mts' + +// A coarse triage verdict for one scanned file. +export type ThreatVerdict = 'clean' | 'malicious' | 'suspicious' + +// One file's verdict, with the model's reasons. `error` marks a file the model +// could not evaluate (a generation failure) so the gate can fail closed on it. +export interface ThreatFinding { + confidence: number + file: string + reasons: string[] + verdict: ThreatVerdict | 'error' +} + +// The gate-facing outcome. `available:false` means no local model resolved — +// the caller decides whether that fails closed (it does when the scan was +// explicitly requested). +export interface ThreatScanResult { + available: boolean + findings: ThreatFinding[] +} + +// How a verdict blocks the publish. `suspicious` blocks only at/above the +// confidence floor; `malicious` and an unevaluable `error` always block. +export interface ThreatPolicy { + suspiciousConfidenceFloor: number +} + +export const DEFAULT_THREAT_POLICY: ThreatPolicy = { + suspiciousConfidenceFloor: 0.6, +} + +// Bound the work so a large package can't blow up prompt volume or memory: at +// most this many files, each truncated to this many bytes before prompting. +const MAX_THREAT_FILES = 24 +const MAX_FILE_BYTES = 64 * 1024 + +// Source extensions worth reading. A tarball ships built JS; TS is included for +// packages that publish sources. +const CODE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']) + +// Path/name red-flags that raise a file's scan priority: install-lifecycle +// entry points and the shapes malware hides behind. +const HIGH_SIGNAL_RE = + /(?:^|\/)(?:post|pre)?install|(?:^|\/)(?:bootstrap|gyp|index|loader|setup)[.-]|\.min\.(?:c|m)?js$/i + +// One staged package's manifest, only the fields that point at executable +// entry points. +export interface ThreatManifest { + bin?: Record | string | undefined + main?: string | undefined + scripts?: Record | undefined +} + +function manifestReferencedFiles(manifest: ThreatManifest): string[] { + const cfg = { __proto__: null, ...manifest } as ThreatManifest + const out: string[] = [] + if (typeof cfg.main === 'string' && cfg.main) { + out.push(cfg.main) + } + if (typeof cfg.bin === 'string' && cfg.bin) { + out.push(cfg.bin) + } else if (cfg.bin && typeof cfg.bin === 'object') { + const values = Object.values(cfg.bin) + for (let i = 0, { length } = values; i < length; i += 1) { + const value = values[i]! + if (typeof value === 'string' && value) { + out.push(value) + } + } + } + // A `scripts` value is a shell command, not a path, but a bare `node x.js` + // form names a file worth reading; pull any token that looks like a path. + if (cfg.scripts && typeof cfg.scripts === 'object') { + const cmds = Object.values(cfg.scripts) + for (let i = 0, { length } = cmds; i < length; i += 1) { + const cmd = cmds[i]! + if (typeof cmd !== 'string') { + continue + } + const tokens = cmd.split(/\s+/) + for (let j = 0, jn = tokens.length; j < jn; j += 1) { + const token = tokens[j]! + // A path-ish token ending in .js/.cjs/.mjs: the leading [./] requires a + // relative or directory marker so a bare word (a flag, a bin name) skips. + if (/[./].*\.(?:c|m)?js$/i.test(token)) { + out.push(token.replace(/^\.\//, '')) + } + } + } + } + return out +} + +/** + * Prioritize which of a tarball's files to scan. Pure over the file list plus + * the manifest's executable entry points: `package.json` always, then every + * manifest-referenced entry (main / bin / script-named file), then high-signal + * code files (install hooks, loaders, minified blobs), then remaining code + * files, deduped and capped at `MAX_THREAT_FILES`. Paths are normalized so the + * selection is separator-stable across platforms. + */ +export function selectThreatFiles( + entryNames: readonly string[], + manifest: ThreatManifest = {}, +): string[] { + const files = entryNames + .map(normalizePath) + .map(p => p.replace(/^\.\//, '').replace(/^package\//, '')) + const present = new Set(files) + const ordered: string[] = [] + const seen = new Set() + const add = (candidate: string) => { + const p = candidate.replace(/^\.\//, '').replace(/^package\//, '') + if (present.has(p) && !seen.has(p)) { + seen.add(p) + ordered.push(p) + } + } + add('package.json') + for (const ref of manifestReferencedFiles(manifest)) { + add(normalizePath(ref)) + } + const isCode = (p: string) => CODE_EXTENSIONS.has(path.extname(p)) + for (let i = 0, { length } = files; i < length; i += 1) { + const p = files[i]! + if (isCode(p) && HIGH_SIGNAL_RE.test(p)) { + add(p) + } + } + for (let i = 0, { length } = files; i < length; i += 1) { + const p = files[i]! + if (isCode(p)) { + add(p) + } + } + return ordered.slice(0, MAX_THREAT_FILES) +} + +/** + * Build the per-file threat-triage prompt. Instructs the model to answer with + * ONLY a JSON verdict object so `parseThreatVerdict` can harden it. + */ +export function buildThreatPrompt(relPath: string, contents: string): string { + return [ + 'You are a package-security triage analyst. Assess ONLY the file below for', + 'signs of malicious intent: install-hook abuse, network exfiltration,', + "credential/env harvesting, obfuscated or dynamically-eval'd payloads,", + 'or a data-stealing postinstall. Benign code is "clean".', + 'Answer with ONLY a JSON object, no prose:', + '{"verdict":"clean|suspicious|malicious","confidence":0..1,"reasons":["…"]}', + '', + `FILE: ${relPath}`, + '```', + contents, + '```', + ].join('\n') +} + +/** + * Harden a small model's reply into a verdict. Extracts the first JSON object + * in the text, since a small model often wraps its JSON in prose, validates + * the verdict enum and the confidence range, and defaults defensively: an + * unparseable or off-enum reply is treated as `suspicious` at full confidence, + * so a garbled answer fails closed rather than passing. + */ +export function parseThreatVerdict(text: string): { + confidence: number + reasons: string[] + verdict: ThreatVerdict +} { + const match = text.match(/\{[\s\S]*\}/) + if (match) { + try { + const parsed = JSON.parse(match[0]) as { + confidence?: unknown | undefined + reasons?: unknown | undefined + verdict?: unknown | undefined + } + const verdict = + parsed.verdict === 'clean' || + parsed.verdict === 'malicious' || + parsed.verdict === 'suspicious' + ? parsed.verdict + : 'suspicious' + const confidence = + typeof parsed.confidence === 'number' && + parsed.confidence >= 0 && + parsed.confidence <= 1 + ? parsed.confidence + : 1 + const reasons = Array.isArray(parsed.reasons) + ? parsed.reasons.filter((r): r is string => typeof r === 'string') + : [] + return { confidence, reasons, verdict } + } catch { + // Fall through to the fail-closed default. + } + } + return { + confidence: 1, + reasons: ['unparseable model reply; treated as suspicious'], + verdict: 'suspicious', + } +} + +/** + * Pure policy evaluation: which findings block the publish. `malicious` and an + * unevaluable `error` always block; `suspicious` blocks at/above the policy's + * confidence floor. + */ +export function collectThreatFailures( + findings: readonly ThreatFinding[], + policy: ThreatPolicy = DEFAULT_THREAT_POLICY, +): ThreatFinding[] { + const floor = policy.suspiciousConfidenceFloor + return findings.filter(f => { + if (f.verdict === 'error' || f.verdict === 'malicious') { + return true + } + return f.verdict === 'suspicious' && f.confidence >= floor + }) +} + +/** + * Run the local threat scan over an extracted tarball directory. Probes the + * on-device model once via the injected provider (default: + * socket-lib's keyless `builtinLocalProvider`); when none resolves, returns + * `available:false` and no findings so the caller decides the fail-closed + * policy. Otherwise reads each selected file (truncated), prompts the model, + * and collects a verdict per file. Every dependency — the provider, the file + * reader, the manifest — is injectable so tests drive it with no model and no + * disk. + */ +export async function runLocalThreatScan( + packageDir: string, + options?: + | { + listFiles?: ((dir: string) => Promise) | undefined + manifest?: ThreatManifest | undefined + model?: string | undefined + provider?: LocalAgentProvider | undefined + readFile?: ((abs: string) => Promise) | undefined + } + | undefined, +): Promise { + const { + listFiles = defaultListFiles, + manifest, + model, + provider, + readFile = defaultReadFile, + } = { __proto__: null, ...options } as NonNullable + + const localProvider = provider ?? builtinLocalProvider() + let availability: string + try { + availability = await localProvider.availability() + } catch (e) { + logger.warn(`Threat scan: local model probe failed (${errorMessage(e)}).`) + return { available: false, findings: [] } + } + if (availability !== 'available') { + logger.log( + `Threat scan: no on-device model ready (availability: ${availability}); skipping.`, + ) + return { available: false, findings: [] } + } + + const entryNames = await listFiles(packageDir) + const selected = selectThreatFiles(entryNames, manifest ?? {}) + const findings: ThreatFinding[] = [] + for (let i = 0, { length } = selected; i < length; i += 1) { + const rel = selected[i]! + // eslint-disable-next-line no-await-in-loop -- serial: one small-model prompt at a time keeps memory + a single-session engine sane. + const contents = await readCapped(readFile, path.join(packageDir, rel)) + const prompt = buildThreatPrompt(rel, contents) + // eslint-disable-next-line no-await-in-loop -- serial model generation. + const result = await spawnLocalAgent( + { cwd: packageDir, model, prompt }, + localProvider, + ) + if (result.unavailable) { + // The engine dropped out mid-run; report what we have and mark + // unavailable so the caller fails closed on an incomplete scan. + return { available: false, findings } + } + if (result.exitCode !== 0) { + findings.push({ + confidence: 1, + file: rel, + reasons: [result.stderr || 'model generation failed'], + verdict: 'error', + }) + continue + } + const parsed = parseThreatVerdict(result.stdout) + findings.push({ + confidence: parsed.confidence, + file: rel, + reasons: parsed.reasons, + verdict: parsed.verdict, + }) + } + return { available: true, findings } +} + +async function defaultListFiles(dir: string): Promise { + const out: string[] = [] + const walk = async (rel: string): Promise => { + const entries = await fs.readdir(path.join(dir, rel), { + withFileTypes: true, + }) + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + const childRel = rel ? `${rel}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') { + // eslint-disable-next-line no-await-in-loop -- serial dir walk. + await walk(childRel) + } + } else if (entry.isFile()) { + out.push(childRel) + } + } + } + await walk('') + return out +} + +async function defaultReadFile(abs: string): Promise { + return await fs.readFile(abs, 'utf8') +} + +async function readCapped( + reader: (abs: string) => Promise, + abs: string, +): Promise { + try { + const text = await reader(abs) + return text.length > MAX_FILE_BYTES ? text.slice(0, MAX_FILE_BYTES) : text + } catch { + return '' + } +} + +/** + * Whether the caller asked for the local threat scan via argv/env. + */ +export function threatScanRequested( + argv: readonly string[] = process.argv.slice(2), + env: NodeJS.ProcessEnv = process.env, +): boolean { + return argv.includes('--threat-scan') || env['SOCKET_THREAT_SCAN'] === '1' +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts new file mode 100644 index 00000000..4a1beb44 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts @@ -0,0 +1,405 @@ +/** + * @file Bulk trusted-publisher sweep over `npm trust` — the registry API + * lane. The browser driver cannot WRITE these settings anymore: npm's bot + * management blocks state-changing transactions from a CDP-driven browser + * (saves silently never land; observed 2026-07-31, 132/132 failed), and + * the access-page challenges carry no cooldown opt-in. `npm trust` wraps + * the documented registry endpoints, is designed for bulk loops, and its + * web-2FA flow DOES carry the cooldown checkbox — so the sweep runs + * unchallenged inside the operator's approval window and the PTY wrapper + * re-opens the browser when the window lapses. + * The law per package matches the shape the browser plan derived: github · + * file npm-publish.yml · repo · environment + * npm-publish · permissions createPackage + + * createStagedPackage. The create endpoint 409s on an existing config, so + * a stale config (the dead `_local-not-for-reuse-provenance.yml` one-off) + * is REVOKED first — delete-and-recreate is the API's own contract, and + * deleting the stale reference is the point. + * Dry-run by default; `--drive` performs revoke + create. Fail-soft per + * package, 2s spacing (the npm-trust docs' rate-limit guidance), summary + * at the end, non-zero exit if anything failed. Verification is the + * registry's own answer: a post-create `npm trust list` must echo the law. + * Usage: node scripts/socket-release/publish-infra/npm/trust-sweep.mts + * … --repo [--drive] + */ + +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { isMainModule } from '../../_shared/is-main-module.mts' +import { extractNpmAuthUrl } from '../../npm-web-auth.mts' +import { logger, runCapture } from '../shared.mts' +import { npmScratchCwd } from './shared.mts' +import { sleep } from './browser-session.mts' + +/** + * The one lawful trusted-publisher shape for a kit consumer: github · + * workflow npm-publish.yml · environment npm-publish · permissions + * createPackage + createStagedPackage — only the repository slug varies. + */ +export interface TrustedPublisherLaw { + environment: string + file: string + permissions: readonly string[] + repository: string + type: string +} + +/** + * The fleet law bound to one repository slug, stated once. Everything but + * the slug is a constant of the kit's publish model. + */ +export function trustedPublisherLaw(slug: string): TrustedPublisherLaw { + return { + environment: 'npm-publish', + file: 'npm-publish.yml', + permissions: ['createPackage', 'createStagedPackage'], + repository: slug, + type: 'github', + } +} + +export const PACE_MS = 2000 + +export interface TrustConfig { + environment?: string | undefined + file?: string | undefined + id?: string | undefined + permissions?: string[] | undefined + repository?: string | undefined + type?: string | undefined +} + +type SweepStatus = 'applied' | 'conforms' | 'failed' | 'planned' + +interface SweepResult { + detail?: string | undefined + pkg: string + status: SweepStatus +} + +/** + * Whether an existing config already IS the law — the conforming no-op that + * makes the sweep idempotent and re-runnable after partial failures. + */ +export function conformsToLaw( + config: TrustConfig, + law: TrustedPublisherLaw, +): boolean { + const perms = [...(config.permissions ?? [])].toSorted() + const wanted = [...law.permissions].toSorted() + return ( + config.type === law.type && + config.file === law.file && + config.repository === law.repository && + config.environment === law.environment && + perms.length === wanted.length && + perms.every((p, i) => p === wanted[i]) + ) +} + +// The PTY auth wrapper: `npm trust` create/revoke are 2FA-gated, and the +// wrapper opens the browser when the cooldown window lapses. Resolved +// relative to THIS file so the sweep works from any cwd. +const AUTH_WRAPPER = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../npm-web-auth.mts', +) + +async function npmTrust( + args: string[], +): Promise<{ code: number; stdout: string }> { + // Through the wrapper for the 2FA-gated writes; scratch cwd dodges the + // repo's devEngines pnpm veto. + return await runCapture( + process.execPath, + [AUTH_WRAPPER, 'trust', ...args], + npmScratchCwd(), + ) +} + +/** + * Raised when the trust API refuses AUTH — `npm trust` demands a 2FA-fresh + * session even for reads, and outside the cooldown window every call 401s. + * Fail CLOSED and stop the sweep: classifying a 401 as "(no config)" is the + * unauthenticated-reads-as-empty trap (it made a whole audit report + * "132 planned / no config" against a registry that was fully configured). + */ +export class TrustAuthDiedError extends Error {} + +async function trustList(pkg: string): Promise { + const { code, stdout } = await runCapture( + 'npm', + ['trust', 'list', pkg, '--json'], + npmScratchCwd(), + ) + // FAIL CLOSED on ANY error envelope. The auth failures keep changing + // costume — E401 "must be logged in" when the token dies, EOTP "requires a + // one-time password" when only the 2FA-fresh window lapses — and each new + // phrasing that slips through reads as "(no config)", producing an audit + // that says 132 unconfigured against a fully configured registry (happened + // TWICE, 2026-07-31). Only a clean exit parses; a genuinely unconfigured + // package is the clean-exit-without-config shape, never an error. + const jsonStart = stdout.indexOf('{') + const parsed = + jsonStart === -1 + ? undefined + : (() => { + try { + return JSON.parse(stdout.slice(jsonStart)) as TrustConfig & { + error?: { authUrl?: string | undefined } | undefined + } + } catch { + return undefined + } + })() + if (code !== 0 || parsed?.error) { + const authUrl = parsed?.error?.authUrl + throw new TrustAuthDiedError( + `npm trust list ${pkg} refused (exit ${code}) — auth or 2FA window is stale.\n` + + (authUrl ? ` Approve here (expires in minutes): ${authUrl}\n` : '') + + ' Fix: re-approve auth, then re-run — the sweep is idempotent.', + ) + } + return parsed +} + +/** + * Reopen the 2FA-fresh window MID-RUN: hold a live PTY-wrapped write (the + * one shape npm's cooldown actually honors — approving a dead URL grants + * nothing; a waiting command completing through the approval does), surface + * its auth URL loudly for the operator, and block until they approve. The + * windows are short and each one used to cost an abort + a full re-walk; + * in-flow reopening turns N aborted runs into one run with N approvals. + * Returns true when the window reopened (the wrapped write exited — an E409 + * on an already-configured anchor package is the expected success shape). + */ +async function reopenAuthWindow( + anchorPkg: string, + law: TrustedPublisherLaw, +): Promise { + logger.log('') + logger.log( + `2FA window lapsed — reopening with a live waiting write on ${anchorPkg}.`, + ) + return await new Promise(resolve => { + const child = spawn( + process.execPath, + [ + AUTH_WRAPPER, + 'trust', + 'github', + anchorPkg, + '--file', + law.file, + '--repo', + law.repository, + '--env', + law.environment, + '--allow-publish', + '--allow-stage-publish', + '--yes', + ], + { cwd: npmScratchCwd(), stdio: ['ignore', 'pipe', 'pipe'] }, + ) + void child.catch(() => undefined) + let buffer = '' + let announced = false + const watch = (chunk: Buffer) => { + if (announced) { + return + } + buffer += chunk.toString('utf8') + const url = extractNpmAuthUrl(buffer) + if (url) { + announced = true + logger.log(`APPROVE HERE (expires in minutes): ${url}`) + logger.log('Tick the cooldown box — the sweep resumes on approval.') + } + } + child.process.stdout?.on('data', watch) + child.process.stderr?.on('data', watch) + child.process.on('error', () => resolve(false)) + // A reopen only happened if npm actually OFFERED the web-auth flow — a + // dead token E401s immediately with no URL, and counting that exit as + // success spun the reopen budget 12 times against a wall (2026-07-31). + // No URL means no window: the fix is a LOGIN (the wrapper's pnpm lane + + // token bridge), not another write. + child.process.on('exit', () => resolve(announced)) + }) +} + +/** + * Sweep one package to the law: conforming configs no-op; a stale config is + * revoked by id, the law created, and the registry re-read must echo it — + * success is the registry's answer, never the exit code alone. + */ +export async function sweepOne( + pkg: string, + config: { drive: boolean; repository: string }, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + // The file/env/permission law is fleet-constant; only the repository varies + // by where the package lives (a member package → its own repo via --repo). + const law = trustedPublisherLaw(cfg.repository) + try { + const current = await trustList(pkg) + if (current && conformsToLaw(current, law)) { + return { pkg, status: 'conforms' } + } + if (!cfg.drive) { + const from = current + ? `${current.file ?? '(none)'} / env ${current.environment ?? '(empty)'}` + : '(no config)' + return { + detail: `[dry-run] ${from} -> ${law.file} @ ${law.repository} / env ${law.environment}`, + pkg, + status: 'planned', + } + } + if (current?.id) { + const revoke = await npmTrust(['revoke', pkg, `--id=${current.id}`]) + if (revoke.code !== 0) { + return { detail: `revoke exited ${revoke.code}`, pkg, status: 'failed' } + } + } + const create = await npmTrust([ + 'github', + pkg, + '--file', + law.file, + '--repo', + law.repository, + '--env', + law.environment, + '--allow-publish', + '--allow-stage-publish', + '--yes', + ]) + if (create.code !== 0) { + return { detail: `create exited ${create.code}`, pkg, status: 'failed' } + } + const echoed = await trustList(pkg) + if (!echoed || !conformsToLaw(echoed, law)) { + return { + detail: 'registry re-read does not echo the law after create', + pkg, + status: 'failed', + } + } + return { pkg, status: 'applied' } + } catch (e) { + if (e instanceof TrustAuthDiedError) { + // Auth death is a SWEEP-level stop, never a per-package failure — 89 + // cascading "failed" rows from one lapsed window is noise that buries + // the one actionable fact. + throw e + } + return { detail: errorMessage(e), pkg, status: 'failed' } + } +} + +async function main(): Promise { + const argv = process.argv.slice(2) + const drive = argv.includes('--drive') + const repoFlagAt = argv.indexOf('--repo') + const repoOverride = repoFlagAt !== -1 ? argv[repoFlagAt + 1] : undefined + const packages = argv.filter( + (a, i) => !a.startsWith('--') && i !== repoFlagAt + 1, + ) + if (packages.length === 0) { + logger.fail('no packages: pass package names.') + process.exitCode = 1 + return + } + if (!repoOverride) { + logger.fail( + 'no repository: pass --repo — the law binds each package ' + + 'to the repo whose npm-publish.yml workflow publishes it.', + ) + process.exitCode = 1 + return + } + logger.log( + `npm trust sweep — ${packages.length} package(s)${drive ? ' [drive]' : ' [dry-run]'}`, + ) + const counts: Record = { + applied: 0, + conforms: 0, + failed: 0, + planned: 0, + } + // In-flow window reopens are bounded: each costs the operator one browser + // approval, and past this many something else is wrong. + const MAX_WINDOW_REOPENS = 12 + let reopens = 0 + for (let i = 0, { length } = packages; i < length; i += 1) { + const pkg = packages[i]! + let result: SweepResult + try { + // eslint-disable-next-line no-await-in-loop -- serial by design: the npm-trust docs' rate-limit guidance. + result = await sweepOne(pkg, { drive, repository: repoOverride! }) + } catch (e) { + if (e instanceof TrustAuthDiedError) { + reopens += 1 + if (reopens > MAX_WINDOW_REOPENS) { + logger.fail(e.message) + logger.log( + `Stopped at ${pkg} (${i}/${length} done) after ${MAX_WINDOW_REOPENS} ` + + 'window reopens — something beyond window expiry is wrong.', + ) + process.exitCode = 1 + return + } + // eslint-disable-next-line no-await-in-loop -- the reopen must complete before the walk resumes. + const reopened = await reopenAuthWindow( + pkg, + trustedPublisherLaw(repoOverride!), + ) + if (!reopened) { + logger.fail(e.message) + logger.log( + 'No web-auth flow was offered — the token itself is dead, not ' + + 'just the 2FA window. Fix: node scripts/socket-release/npm-web-auth.mts ' + + 'login (the pnpm lane bridges the token to npm), then re-run.', + ) + process.exitCode = 1 + return + } + i -= 1 + continue + } + throw e + } + counts[result.status] += 1 + const line = `${result.pkg}: ${result.status}${result.detail ? ` — ${result.detail}` : ''}` + if (result.status === 'failed') { + logger.fail(line) + } else { + logger.log(line) + } + if (i < length - 1) { + // eslint-disable-next-line no-await-in-loop -- pacing between registry writes. + await sleep(PACE_MS) + } + } + logger.log('') + logger.log( + `Trust-sweep ${drive ? 'drive' : 'dry-run'} summary: ${counts.applied} applied, ` + + `${counts.planned} planned, ${counts.conforms} conforming, ${counts.failed} failed.`, + ) + if (counts.failed > 0) { + process.exitCode = 1 + } +} + +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.fail(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts new file mode 100644 index 00000000..26924e09 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts @@ -0,0 +1,344 @@ +#!/usr/bin/env node +/* + * @file Npm Trusted Publisher settings driver — reads and mass-applies the + * fleet's canonical GitHub Actions trusted-publisher config across packages + * by driving `https://www.npmjs.com/package//access` in a signed-in + * Chrome — playwright-core against the SAME durable profile and launch + * shape as `staged-browser-read.mts`, so the operator's staged-publish + * sign-in is reused. Modes: `read ` prints each package's + * CURRENT form values as a table (read-only); `apply ` prints the + * current-to-desired diff per package and is DRY-RUN BY DEFAULT — `--drive` (the agent takes the wheel of your signed-in session) + * fills the form (workflow filename, environment name, allowed-action + * checkboxes) and clicks Save, then RE-READS the form and only counts the + * package done when the saved state matches desired: success is the page's + * answer, never the click. `--socket-registry` expands the worklist to + * every published @socketregistry/* package from socket-registry's own + * `registry/manifest.json` (local sibling checkout, else `gh api`). + * Fail-soft per package: one failure never aborts the batch; a summary + * prints at the end. The pure planners live in + * `trusted-publisher-parse.mts` + `trusted-publisher-plan.mts`; the + * page-level form I/O in `trusted-publisher-page.mts`. + * THE SIGN-IN AND CHALLENGE CONTRACT, taken from socket-registry's proven + * configurator (`scripts/npm/configure-staged-publishing-browser.mts`, + * which mass-configured npm package settings across that registry): + * + * - NO login is ever scripted. The operator signs in ONCE in the headed window; + * the profile persists, so it is a per-machine step. No password, OTP, or + * cookie passes through this process. + * - The ONLY auth signal is npm's own `/-/whoami`, and the only auth failure + * reported is "signed out". + * - The launch shape is exactly that module's: + * `launchPersistentContext(profileDir, { channel, chromiumSandbox: true, + * headless, ignoreDefaultArgs: ['--enable-automation', + * '--use-mock-keychain'] })` — no args array, sandbox ON (playwright + * defaults it off and injects --no-sandbox, which current Chrome refuses + * outright), and exactly those two ignored defaults (navigator.webdriver + * bot signal off; a cookie store bare Chrome can share). + * - A human-verification challenge PAUSES the run for the operator with a + * visible elapsed/remaining countdown and is NEVER retried blindly: a retry + * ladder against a bot challenge earns a rate limit, which then masquerades + * as a broken session. Nothing is written while a challenge is outstanding. + * Usage: node scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts + * read|apply […] [--socket-registry] [--drive] [--repo ] + * [--profile-dir ] + */ + +import { promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import type { Page } from 'playwright-core' + +import { errorMessage } from '@socketsecurity/lib/errors/message' + +import { isMainModule } from '../../_shared/is-main-module.mts' +import { logger, rootPath, runCapture } from '../shared.mts' +import { openNpmBrowserSession } from './browser-session.mts' +import type { + NpmBrowserSession, + NpmBrowserSessionOptions, +} from './browser-session.mts' +import { + awaitVerifiedSave, + driveFormEdits, + readTrustedPublisher, +} from './trusted-publisher-page.mts' +import { + desiredTrustedPublisher, + diffTrustedPublisher, + formatApplySummary, + parseSocketRegistryManifest, + renderPlannedEdits, + renderReadTable, + SOCKET_REGISTRY_SCOPE, +} from './trusted-publisher-plan.mts' +import type { AccessReadRow, ApplyResult } from './trusted-publisher-plan.mts' + +/** + * Open the signed-in npm session for the trusted-publisher driver — the + * SHARED fleet bootstrap from `staged-browser-read.mts`: system Chrome via + * `launchPersistentContext` on the ONE durable profile under + * `~/.config/socket-wheelhouse/`, so an operator already signed in for the + * publish gate is signed in here too, and never a second per-tool profile. + * The `launch` seam stays injectable so tests never start a browser. + */ +export async function openTrustedPublisherSession( + options?: NpmBrowserSessionOptions | undefined, +): Promise { + const session = await openNpmBrowserSession(options) + logger.log(`Signed in to npm as ${session.user}.`) + return session +} + +/** + * Plan (and with `drive`, perform + verify) one package's trusted-publisher + * update. Never throws — every outcome is an ApplyResult so the batch keeps + * moving. + */ +export async function applyOne( + page: Page, + pkg: string, + config: { drive: boolean; repoOverride?: string | undefined }, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + try { + const { current, state } = await readTrustedPublisher(page, pkg) + const desired = desiredTrustedPublisher({ + current, + pkg, + repoOverride: cfg.repoOverride, + }) + if (!desired) { + return { + detail: + `${state} and no repo derivable — pass --repo ` + + 'for a non-@socketregistry package with no configured repo.', + pkg, + status: 'skipped', + } + } + const edits = diffTrustedPublisher({ current, desired }) + if (edits.length === 0) { + logger.substep(`${pkg}: conforms — no edits`) + return { pkg, status: 'conforms' } + } + if (!cfg.drive) { + logger.log(`[dry-run] ${renderPlannedEdits(pkg, edits)}`) + return { pkg, status: 'planned' } + } + await driveFormEdits(page, pkg, desired) + const verify = await awaitVerifiedSave(page, pkg, desired) + if (!verify.ok) { + return { + detail: `saved state did not verify: ${verify.mismatches.join('; ')}`, + pkg, + status: 'failed', + } + } + logger.success( + `${pkg}: applied + verified (${desired.repositoryOwner}/${desired.repositoryName} · ${desired.workflowFilename} · ${desired.environmentName}).`, + ) + return { pkg, status: 'applied' } + } catch (e) { + return { detail: errorMessage(e), pkg, status: 'failed' } + } +} + +/** + * Expand `--socket-registry` into every published @socketregistry/* package: + * socket-registry's own `registry/manifest.json`, read from a sibling + * checkout when one exists, else through `gh api`. Throws LOUD when neither + * source yields a manifest — a silent empty expansion would no-op the sweep. + */ +export async function expandSocketRegistryWorklist(): Promise { + const localDir = + process.env['SOCKET_REGISTRY_DIR'] || + path.resolve(rootPath, '..', 'socket-registry') + const localManifest = path.join(localDir, 'registry', 'manifest.json') + let body: string | undefined + try { + body = await fs.readFile(localManifest, 'utf8') + } catch { + const { code, stdout } = await runCapture( + 'gh', + [ + 'api', + 'repos/SocketDev/socket-registry/contents/registry/manifest.json', + '-H', + 'Accept: application/vnd.github.raw', + ], + rootPath, + ) + if (code === 0 && stdout.trim()) { + body = stdout + } + } + if (!body) { + throw new Error( + '--socket-registry expansion failed. Where: ' + + `${localManifest}, then gh api SocketDev/socket-registry. ` + + 'Fix: check out socket-registry as a sibling, or authenticate gh.', + ) + } + const entries = parseSocketRegistryManifest(body) + const names: string[] = [] + const skipped: string[] = [] + let deprecated = 0 + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + // The manifest also lists the rare package socket-registry publishes + // under its ORIGINAL unscoped name, for example shell-quote. This + // expansion's contract is the @socketregistry/* scope only; unscoped rows + // are named out loud so nobody thinks they were silently swept. + if (!entry.name.startsWith(SOCKET_REGISTRY_SCOPE)) { + skipped.push(entry.name) + continue + } + names.push(entry.name) + if (entry.deprecated) { + deprecated += 1 + } + } + logger.log( + `--socket-registry expanded to ${names.length} published @socketregistry/* package(s); ${deprecated} marked deprecated, kept — a stale publisher on a deprecated package still matters if it ever republishes.`, + ) + if (skipped.length) { + logger.substep( + `excluded ${skipped.length} non-@socketregistry manifest row(s): ${skipped.join(', ')} — name them positionally to include them.`, + ) + } + return names +} + +interface CliArgs { + drive: boolean + mode: 'apply' | 'read' + packages: string[] + profileDir?: string | undefined + repo?: string | undefined + socketRegistry: boolean +} + +const USAGE = + 'Usage: trusted-publisher-browser.mts read|apply […] ' + + '[--socket-registry] [--drive] [--repo ] [--profile-dir ]' + +/** + * Parse the CLI: a `read`/`apply` mode word, positional package names, and + * the flags. Exits, usage error, on an unknown flag/mode or a value-taking + * flag with no value. Exported for tests. + */ +export function parseArgs(argv: readonly string[]): CliArgs { + const mode = argv[0] + if (mode !== 'apply' && mode !== 'read') { + logger.fail(USAGE) + process.exit(1) + } + let drive = false + let profileDir: string | undefined + let repo: string | undefined + let socketRegistry = false + const packages: string[] = [] + for (let i = 1, { length } = argv; i < length; i += 1) { + const arg = argv[i]! + if (arg === '--drive') { + drive = true + continue + } + if (arg === '--socket-registry') { + socketRegistry = true + continue + } + if (arg === '--profile-dir' || arg === '--repo') { + const value = argv[i + 1] + if (value === undefined || value.startsWith('-')) { + logger.fail(`Flag ${arg} needs a value.`) + process.exit(1) + } + if (arg === '--repo') { + repo = value + } else { + profileDir = value + } + i += 1 + continue + } + if (arg.startsWith('-')) { + logger.fail(`Unknown flag: ${arg}`) + logger.error(USAGE) + process.exit(1) + } + packages.push(arg) + } + return { drive, mode, packages, profileDir, repo, socketRegistry } +} + +export async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + const packages = [...args.packages] + if (args.socketRegistry) { + packages.push(...(await expandSocketRegistryWorklist())) + } + if (packages.length === 0) { + logger.fail('No packages named.') + logger.error(USAGE) + process.exitCode = 1 + return + } + const session = await openTrustedPublisherSession({ + profileDir: args.profileDir, + }) + try { + if (args.mode === 'read') { + const rows: AccessReadRow[] = [] + for (let i = 0, { length } = packages; i < length; i += 1) { + const pkg = packages[i]! + try { + // eslint-disable-next-line no-await-in-loop -- serial per-package reads share one page session. + const { current, state } = await readTrustedPublisher( + session.page, + pkg, + ) + rows.push({ current, pkg, state }) + } catch (e) { + rows.push({ detail: errorMessage(e), pkg, state: 'error' }) + process.exitCode = 1 + } + } + logger.log(renderReadTable(rows)) + return + } + logger.log( + `npm trusted publishing — ${packages.length} package(s)` + + `${args.drive ? ' [drive]' : ' [dry-run]'}`, + ) + const results: ApplyResult[] = [] + for (let i = 0, { length } = packages; i < length; i += 1) { + // eslint-disable-next-line no-await-in-loop -- serial per-package applies share one page session. + const result = await applyOne(session.page, packages[i]!, { + drive: args.drive, + repoOverride: args.repo, + }) + if (result.status === 'failed' || result.status === 'skipped') { + logger.error(`${result.pkg}: ${result.status} — ${result.detail}`) + } + results.push(result) + } + logger.log('') + logger.log(formatApplySummary(results, { drive: args.drive })) + if (results.some(r => r.status === 'failed')) { + process.exitCode = 1 + } + } finally { + await session.close() + } +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not launch a browser. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(errorMessage(e)) + process.exitCode = 1 + }) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-page.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-page.mts new file mode 100644 index 00000000..baff0b64 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-page.mts @@ -0,0 +1,323 @@ +/** + * @file Page-level playwright I/O for the npm Trusted Publisher settings + * driver: the signed-in access-page read, which PAUSES visibly for the + * operator on a human-verification challenge rather than retrying into a + * rate limit; the form-driving that fills the + * Trusted Publisher fields and clicks Save; and the post-save verify loop + * that RE-READS the form until the page itself reports the desired state — + * success is the page's answer, never the click. The pure classification / + * parsing / diffing live in `trusted-publisher-parse.mts` + + * `trusted-publisher-plan.mts`; the session + CLI live in + * `trusted-publisher-browser.mts`. + */ + +import type { Page } from 'playwright-core' + +import { + NPM_ORIGIN, + optIntoChallengeCooldown, + pauseForChallenge, + sleep, +} from './browser-session.mts' +import { + classifyAccessPage, + parseTrustedPublisherForm, +} from './trusted-publisher-parse.mts' +import type { + AccessPageState, + TrustedPublisherCurrent, +} from './trusted-publisher-parse.mts' +import { verifySavedState } from './trusted-publisher-plan.mts' +import type { TrustedPublisherDesired } from './trusted-publisher-plan.mts' + +// A status-0 result is a mid-navigation race from a destroyed execution +// context, not a challenge; it clears almost immediately, so it gets a small +// bounded number of fast retries and nothing more. +const RACE_RETRY_MS = 2000 +const RACE_MAX_ATTEMPTS = 3 + +// Post-save verify: the operator may be mid-2FA in the window, so poll the +// re-read patiently. The challenge-cooldown opt-in means only the FIRST +// package in a 5-minute window should ever take this long. +const SAVE_VERIFY_POLL_MS = 3000 +const SAVE_VERIFY_TIMEOUT_MS = 3 * 60_000 + +/** + * The access-settings URL for `pkg` — the page carrying the Trusted + * Publisher form. Exported for tests. + */ +export function accessUrl(pkg: string): string { + return `${NPM_ORIGIN}/package/${encodeURIComponent(pkg)}/access` +} + +// Fetch the access page's HTML in the page's MAIN world (the page's cookies +// authenticate it; cache no-store so a post-save re-read never sees stale +// pre-mutation HTML). A destroyed execution context yields status 0 — +// retryable, never fatal. +async function fetchAccessPage( + page: Page, + pkg: string, +): Promise<{ body: string; status: number }> { + try { + return await page.evaluate(async fetchUrl => { + // oxlint-disable-next-line socket/no-fetch-prefer-http-request -- runs in the npm page's MAIN world via page.evaluate; only the page's cookies authenticate this request. + const r = await fetch(fetchUrl, { + cache: 'no-store', + credentials: 'same-origin', + headers: { accept: 'text/html' }, + method: 'GET', + }) + return { body: await r.text(), status: r.status } + }, accessUrl(pkg)) + } catch { + return { body: '', status: 0 } + } +} + +/** + * Read one package's Trusted Publisher form state. A human-verification + * challenge PAUSES the run for the operator — the page is brought to the + * front, the cooldown opt-in is ticked, and each poll prints elapsed and + * remaining time — never a retry ladder, which against a bot challenge earns + * a rate limit. Throws on auth (a signed-out session), on a real HTTP error, + * and when a challenge outlasts its budget; the batch loops catch per + * package. The timings are injectable so tests run in milliseconds. + */ +export async function readTrustedPublisher( + page: Page, + pkg: string, + options?: + | { + challengeBudgetMs?: number | undefined + challengePollMs?: number | undefined + raceRetryMs?: number | undefined + } + | undefined, +): Promise<{ + current: TrustedPublisherCurrent | undefined + state: AccessPageState +}> { + const opts = { __proto__: null, ...options } as NonNullable + const { challengeBudgetMs, challengePollMs } = opts + const raceRetryMs = opts.raceRetryMs ?? RACE_RETRY_MS + const url = accessUrl(pkg) + const started = Date.now() + let raceAttempts = 0 + let announced = false + for (;;) { + // eslint-disable-next-line no-await-in-loop -- serial poll: one live page, one challenge at a time. + const last = await fetchAccessPage(page, pkg) + const state = classifyAccessPage({ body: last.body, status: last.status }) + if (state === 'configured' || state === 'unconfigured') { + return { + current: + state === 'configured' + ? parseTrustedPublisherForm(last.body) + : undefined, + state, + } + } + if (state === 'auth') { + throw new Error( + [ + `What: ${pkg}'s access page could not be read, so its trusted-publisher state is unknown.`, + `Where: ${url}`, + `Saw: npm answered HTTP ${last.status} — the session is signed out or lacks access to this package.`, + 'Wanted: the signed-in access page carrying the trusted-publisher block.', + 'Fix: sign in to npm in the Chrome window, then re-run.', + ].join('\n'), + ) + } + if (state === 'error') { + // A status-0 result is the documented mid-navigation race, not a server + // error: retry it a couple of times, fast, then report honestly. + if (last.status === 0 && raceAttempts < RACE_MAX_ATTEMPTS) { + raceAttempts += 1 + // eslint-disable-next-line no-await-in-loop -- serial short retry for a navigation race. + await sleep(raceRetryMs) + continue + } + throw new Error( + [ + `What: ${pkg}'s access page could not be read.`, + `Where: ${url}`, + `Saw: npm answered HTTP ${last.status}.`, + 'Wanted: the access page HTML.', + 'Fix: open the URL above in the signed-in Chrome window and confirm it loads, then re-run.', + ].join('\n'), + ) + } + // A challenge: PAUSE for the operator, visibly, through the sanctioned + // helper — it owns the countdown and the budget refusal. + // eslint-disable-next-line no-await-in-loop -- serial pause while the operator solves the challenge. + const pause = await pauseForChallenge(page, { + announced, + budgetMs: challengeBudgetMs, + elapsedMs: Date.now() - started, + label: pkg, + pollMs: challengePollMs, + url, + }) + announced = pause.announced + } +} + +// Fill one form field, preferring the wire-contract input name and falling +// back to the visible label — names survive a DOM reshuffle better than +// structure, labels survive a rename of the name attribute. +async function fillField( + page: Page, + config: { label: RegExp; name: string; value: string }, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const byName = page.locator(`input[name="${cfg.name}"]`).first() + if ((await byName.count()) > 0) { + await byName.fill(cfg.value, { timeout: 10_000 }) + return + } + await page.getByLabel(cfg.label).first().fill(cfg.value, { timeout: 10_000 }) +} + +// Set one allowed-action checkbox: the real checkbox by name first, label +// fallback. The name-only locator is NOT enough — npm renders some packages' +// state as a HIDDEN input (`type="hidden" value="on"`) with the same name, +// and setChecked on that throws "Not a checkbox or radio button" (failed +// @socketregistry/array.prototype.flatmap mid-sweep, 2026-07-31). A hidden +// input that already encodes the desired state is a no-op, not an error. +async function setCheckbox( + page: Page, + config: { checked: boolean; label: RegExp; name: string }, +): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const realBox = page + .locator(`input[type="checkbox"][name="${cfg.name}"]`) + .first() + if ((await realBox.count()) > 0) { + await realBox.setChecked(cfg.checked, { timeout: 10_000 }) + return + } + const hidden = page + .locator(`input[type="hidden"][name="${cfg.name}"]`) + .first() + if ((await hidden.count()) > 0) { + const value = (await hidden.getAttribute('value')) ?? '' + const encodesChecked = value === 'on' || value === 'true' + if (encodesChecked === cfg.checked) { + return + } + throw new Error( + `the ${cfg.name} control is a hidden input encoding ${JSON.stringify(value)} ` + + `and no checkbox is rendered to flip it to ${cfg.checked} — the page ` + + 'shape changed; re-derive the form contract before writing.', + ) + } + await page + .getByLabel(cfg.label) + .first() + .setChecked(cfg.checked, { timeout: 10_000 }) +} + +// Bring the GitHub Actions trusted-publisher form on screen: already-open +// form wins; a configured summary needs its Edit affordance clicked; an +// unconfigured page needs the GitHub Actions publisher selected. +async function ensureFormOpen(page: Page): Promise { + const workflowInput = page.locator('input[name="workflowName"]').first() + if ((await workflowInput.count()) > 0) { + return + } + const edit = page.getByRole('button', { name: /edit/i }).first() + if (await edit.isVisible().catch(() => false)) { + await edit.click({ timeout: 10_000 }) + } else { + const gha = page.getByText(/GitHub Actions/i).first() + if (await gha.isVisible().catch(() => false)) { + await gha.click({ timeout: 10_000 }) + } + } + await workflowInput + .or(page.getByLabel(/workflow filename/i)) + .first() + .waitFor({ state: 'visible', timeout: 15_000 }) +} + +/** + * Drive the form to `desired` and click Save. Selector failures throw; the + * caller renders the What/Where/Saw/Fix and fails soft for the package. + */ +export async function driveFormEdits( + page: Page, + pkg: string, + desired: TrustedPublisherDesired, +): Promise { + await page.goto(accessUrl(pkg), { waitUntil: 'domcontentloaded' }) + await optIntoChallengeCooldown(page) + await ensureFormOpen(page) + await fillField(page, { + label: /organization|user|owner/i, + name: 'repositoryOwner', + value: desired.repositoryOwner, + }) + await fillField(page, { + label: /^repository/i, + name: 'repositoryName', + value: desired.repositoryName, + }) + await fillField(page, { + label: /workflow filename/i, + name: 'workflowName', + value: desired.workflowFilename, + }) + await fillField(page, { + label: /environment name/i, + name: 'githubEnvironmentName', + value: desired.environmentName, + }) + await setCheckbox(page, { + checked: desired.allowNpmPublish, + label: /allow npm publish/i, + name: 'allowPublish', + }) + await setCheckbox(page, { + checked: desired.allowNpmStagePublish, + label: /allow npm stage publish/i, + name: 'allowStagePublish', + }) + const save = page + .getByRole('button', { name: /save changes|save|update|set up/i }) + .first() + await save.click({ timeout: 10_000 }) +} + +/** + * Poll the RE-READ until the saved state matches desired or the budget + * elapses — the operator may be answering a 2FA challenge in the window, so + * the cooldown opt-in keeps getting ticked between polls. + */ +export async function awaitVerifiedSave( + page: Page, + pkg: string, + desired: TrustedPublisherDesired, +): Promise<{ mismatches: string[]; ok: boolean }> { + const deadline = Date.now() + SAVE_VERIFY_TIMEOUT_MS + let verify: { mismatches: string[]; ok: boolean } = { + mismatches: ['not yet re-read'], + ok: false, + } + for (;;) { + // eslint-disable-next-line no-await-in-loop -- serial poll while npm settles/2FA completes. + await optIntoChallengeCooldown(page) + let reread: TrustedPublisherCurrent | undefined + try { + // eslint-disable-next-line no-await-in-loop -- serial poll while npm settles/2FA completes. + reread = (await readTrustedPublisher(page, pkg)).current + } catch { + reread = undefined + } + verify = verifySavedState({ desired, reread }) + if (verify.ok || Date.now() >= deadline) { + return verify + } + // eslint-disable-next-line no-await-in-loop -- serial poll interval. + await sleep(SAVE_VERIFY_POLL_MS) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts new file mode 100644 index 00000000..88198d5d --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts @@ -0,0 +1,192 @@ +/** + * @file Pure parsers for the npm Trusted Publisher settings driver — no + * playwright, no network, so the access-page classification and the + * form-value extraction are unit-testable from HTML fixtures. The browser + * side (`trusted-publisher-page.mts`) reads npm's signed-in + * `/package//access` page and feeds the raw HTML here. The read-side + * markers (`id="github-repoInfo"` …) mirror socket-webext's + * `src/trusted-publisher/background/html-parsing.mts` — npm's form wire + * contract, far more stable than DOM structure — so the extension and this + * driver read the same page the same way. + */ + +import { + isCloudflareChallenge, + looksLikeHtmlBody, +} from './staged-browser-parse.mts' + +// Coarse outcome of a GET of `/package//access`. `challenge` exists for +// the Cloudflare interstitial (a 200 HTML page that is NOT the access page); +// `configured`/`unconfigured` are the two readable outcomes. +export type AccessPageState = + | 'auth' + | 'challenge' + | 'configured' + | 'error' + | 'unconfigured' + +/** + * Classify an access-page fetch by body + status. Challenge markup wins over + * everything (a challenge can arrive as a 200, 403, or 503, and treating it + * as auth/error would abort a batch that only needed a cooldown); a plain + * 401/403 or a signed-out page is `auth`; any other non-2xx is `error`; a + * readable page is `configured` when the trusted-publisher summary markers + * are present, `unconfigured` when only the access-settings shell renders. + * Pure — exported for tests. + */ +export function classifyAccessPage(config: { + body?: string | undefined + status: number +}): AccessPageState { + const cfg = { __proto__: null, ...config } as typeof config + const body = cfg.body ?? '' + if (isCloudflareChallenge(body)) { + return 'challenge' + } + if (cfg.status === 401 || cfg.status === 403) { + return 'auth' + } + if (/sign in to npm/i.test(body) && !/Trusted [Pp]ublish/.test(body)) { + return 'auth' + } + if (cfg.status < 200 || cfg.status >= 400) { + return 'error' + } + if (/id="github-repoInfo"/.test(body)) { + return 'configured' + } + // The React initial-data payload sometimes carries the state as JSON keys + // instead of rendered markers; quotes may be escaped when embedded. + if ( + /\\?"trustedPublisher\\?"\s*:/.test(body) || + /\\?"trustedPublisherConfigured\\?"\s*:\s*true/.test(body) + ) { + return 'configured' + } + if ( + /Trusted [Pp]ublish(?:er|ing)/.test(body) || + /Publishing access/i.test(body) || + /publishingAccess/.test(body) + ) { + return 'unconfigured' + } + return looksLikeHtmlBody(body) ? 'unconfigured' : 'error' +} + +/** + * The Trusted Publisher form's CURRENT values as read off the access page. + * `allowedActions` holds the rendered permission strings (`npm publish`, + * `npm stage publish`) in page order. + */ +export interface TrustedPublisherCurrent { + allowedActions: string[] + environmentName: string | undefined + repositoryName: string | undefined + repositoryOwner: string | undefined + workflowFilename: string | undefined +} + +/** + * Parse the configured trusted-publisher summary out of the access page: + * repo (the `github-repoInfo` marker, `owner/name`), workflow filename, + * environment name (marker or JSON fallback; absent/empty reads as + * undefined), and the allowed-action permission strings. Returns undefined + * when not even the repo marker is present — callers classify first, so + * that means an unconfigured page. Pure — exported for tests. + */ +export function parseTrustedPublisherForm( + html: string, +): TrustedPublisherCurrent | undefined { + const repo = html.match(/id="github-repoInfo"[^>]*>([^<]+)]*>([^<]+)]*>([^<]+)() + // The block between the literal `Permissions:` label's closing span and the + // next closing div — the region the permission chips render inside. + const permsBlock = html.match(/Permissions:\s*<\/span>([\s\S]*?)<\/div>/) + if (permsBlock) { + const region = permsBlock[1] ?? '' + // One rendered permission chip: an opening or tag, its + // trimmed text content (captured), then the matching close tag. + const parts = [ + ...region.matchAll( + /<(?:code|span)[^>]*>\s*([^<]+?)\s*<\/(?:code|span)>/g, + ), + ] + for (let i = 0, { length } = parts; i < length; i += 1) { + const t = (parts[i]![1] ?? '').trim().toLowerCase().replace(/\s+/g, ' ') + if (/^npm (?:stage )?publish$/.test(t)) { + actions.add(t) + } + } + } + const checkboxNames: Array<[string, string]> = [ + ['allowPublish', 'npm publish'], + ['allowStagePublish', 'npm stage publish'], + ] + for (let i = 0, { length } = checkboxNames; i < length; i += 1) { + const [name, action] = checkboxNames[i]! + // The whole input tag, whatever the attribute order; checkedness is + // tested on the matched tag text. + const re = new RegExp(`]*\\bname="${name}"[^>]*>`, 'i') + const m = re.exec(html) + if (m && /\bchecked\b/i.test(m[0])) { + actions.add(action) + } + } + return [...actions] +} + +/** + * Whether the allowed-action list grants one of the two publish actions. + * `publish` means the PLAIN action — `npm stage publish` alone does not + * grant it. Pure — exported for tests. + */ +export function allowsAction( + actions: readonly string[], + action: 'publish' | 'stage-publish', +): boolean { + for (let i = 0, { length } = actions; i < length; i += 1) { + const a = actions[i]!.toLowerCase() + const isStage = /\bnpm\s+stage\s+publish\b/.test(a) + if (action === 'stage-publish' && isStage) { + return true + } + if (action === 'publish' && !isStage && /\bnpm\s+publish\b/.test(a)) { + return true + } + } + return false +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-plan.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-plan.mts new file mode 100644 index 00000000..2fe3d2f6 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-plan.mts @@ -0,0 +1,394 @@ +/** + * @file Pure planners for the npm Trusted Publisher settings driver — the + * canonical desired config (law as data), the desired-vs-current diffing, + * the re-read-based save verify, the worklist expansion parser, and the + * human-readable renderers. No playwright, no network — every page value + * arrives already parsed by `trusted-publisher-parse.mts`, so all of this + * is unit-testable from fixtures. The browser side lives in + * `trusted-publisher-browser.mts` / `trusted-publisher-page.mts`. + */ + +import { allowsAction } from './trusted-publisher-parse.mts' +import type { + AccessPageState, + TrustedPublisherCurrent, +} from './trusted-publisher-parse.mts' +import { trustedPublisherLaw } from './trust-sweep.mts' + +// The canonical desired config is derived from the ONE law +// (`trustedPublisherLaw`), so the browser plan and the registry sweep can +// never assert different desired shapes. `createPackage` maps to plain +// `npm publish`, `createStagedPackage` to `npm stage publish` — both allowed. +const LAW = trustedPublisherLaw('') + +export const CANONICAL_WORKFLOW_FILENAME = LAW.file +export const CANONICAL_ENVIRONMENT_NAME = LAW.environment +export const CANONICAL_ALLOW_NPM_PUBLISH = + LAW.permissions.includes('createPackage') +export const CANONICAL_ALLOW_NPM_STAGE_PUBLISH = LAW.permissions.includes( + 'createStagedPackage', +) + +// The pre-rename legacy workflow filename still stored on stale fleet +// configs (seen on @socketregistry/es-iterator-helpers, 2026-07-29). A config +// naming it points npm's OIDC claim matching at a workflow that no longer +// exists, so it NEVER conforms — the diff must always flag it. +export const LEGACY_WORKFLOW_FILENAMES: readonly string[] = [ + '_local-not-for-reuse-provenance.yml', +] + +// Every @socketregistry/* package publishes from the socket-registry monorepo. +export const SOCKET_REGISTRY_SCOPE = '@socketregistry/' +export const SOCKET_REGISTRY_REPO_OWNER = 'SocketDev' +export const SOCKET_REGISTRY_REPO_NAME = 'socket-registry' + +/** + * The Trusted Publisher shape a package SHOULD have — one row of the law. + */ +export interface TrustedPublisherDesired { + allowNpmPublish: boolean + allowNpmStagePublish: boolean + environmentName: string + repositoryName: string + repositoryOwner: string + workflowFilename: string +} + +/** + * The desired config for `pkg`, or undefined when no repo can be derived. + * Repo resolution, in precedence order: the operator's `repoOverride` + * (`owner/name`); the socket-registry monorepo for any `@socketregistry/*` + * package; the package's own CURRENTLY configured repo (fleet packages + * already point at their roster repo — only the workflow/environment/actions + * went stale). Everything else is fixed by the canonical law consts. Pure — + * exported for tests. + */ +export function desiredTrustedPublisher(config: { + current?: TrustedPublisherCurrent | undefined + pkg: string + repoOverride?: string | undefined +}): TrustedPublisherDesired | undefined { + const cfg = { __proto__: null, ...config } as typeof config + let owner: string | undefined + let name: string | undefined + if (cfg.repoOverride) { + const slashIdx = cfg.repoOverride.indexOf('/') + if (slashIdx > 0) { + owner = cfg.repoOverride.slice(0, slashIdx) + name = cfg.repoOverride.slice(slashIdx + 1) || undefined + } + } else if (cfg.pkg.startsWith(SOCKET_REGISTRY_SCOPE)) { + owner = SOCKET_REGISTRY_REPO_OWNER + name = SOCKET_REGISTRY_REPO_NAME + } else if (cfg.current?.repositoryOwner && cfg.current.repositoryName) { + owner = cfg.current.repositoryOwner + name = cfg.current.repositoryName + } + if (!owner || !name) { + return undefined + } + return { + allowNpmPublish: CANONICAL_ALLOW_NPM_PUBLISH, + allowNpmStagePublish: CANONICAL_ALLOW_NPM_STAGE_PUBLISH, + environmentName: CANONICAL_ENVIRONMENT_NAME, + repositoryName: name, + repositoryOwner: owner, + workflowFilename: CANONICAL_WORKFLOW_FILENAME, + } +} + +/** + * One planned form edit, keyed by npm's form field name (the wire contract: + * `repositoryOwner`, `repositoryName`, `workflowName`, + * `githubEnvironmentName`, `allowPublish`, `allowStagePublish`). + */ +export interface FormEdit { + field: string + from: string + to: string +} + +/** + * The exact form edits that take `current` to `desired` — empty means the + * config already conforms. An unconfigured package (undefined `current`) + * yields the full field set. An EMPTY environment is a mismatch, never a + * wildcard: the fleet's branch-restricted `npm-publish` environment only + * engages when the config names it, so a blank field is exactly the staleness + * this driver exists to fix. A legacy workflow filename likewise never + * conforms. Pure — exported for tests. + */ +export function diffTrustedPublisher(config: { + current?: TrustedPublisherCurrent | undefined + desired: TrustedPublisherDesired +}): FormEdit[] { + const cfg = { __proto__: null, ...config } as typeof config + const { current, desired } = cfg + const edits: FormEdit[] = [] + const push = (field: string, from: string | undefined, to: string) => { + const have = from ?? '' + if (have !== to) { + edits.push({ field, from: have === '' ? '(empty)' : have, to }) + } + } + push('repositoryOwner', current?.repositoryOwner, desired.repositoryOwner) + push('repositoryName', current?.repositoryName, desired.repositoryName) + push('workflowName', current?.workflowFilename, desired.workflowFilename) + push( + 'githubEnvironmentName', + current?.environmentName, + desired.environmentName, + ) + const actions = current?.allowedActions ?? [] + const boxes: Array<['allowPublish' | 'allowStagePublish', boolean, boolean]> = + [ + [ + 'allowPublish', + allowsAction(actions, 'publish'), + desired.allowNpmPublish, + ], + [ + 'allowStagePublish', + allowsAction(actions, 'stage-publish'), + desired.allowNpmStagePublish, + ], + ] + for (let i = 0, { length } = boxes; i < length; i += 1) { + const [field, have, want] = boxes[i]! + if (have !== want) { + edits.push({ + field, + from: have ? 'checked' : 'unchecked', + to: want ? 'checked' : 'unchecked', + }) + } + } + return edits +} + +/** + * The verdict after a Save: did the RE-READ page land on `desired`? Success + * is the page's answer, never the click — a `reread` of undefined (the page + * would not re-read, or came back unconfigured) FAILS, because a click whose + * outcome cannot be observed proves nothing. Pure — exported for tests. + */ +export function verifySavedState(config: { + desired: TrustedPublisherDesired + reread: TrustedPublisherCurrent | undefined +}): { mismatches: string[]; ok: boolean } { + const cfg = { __proto__: null, ...config } as typeof config + if (!cfg.reread) { + return { + mismatches: ['form not readable after save — saved state unproven'], + ok: false, + } + } + const edits = diffTrustedPublisher({ + current: cfg.reread, + desired: cfg.desired, + }) + const mismatches: string[] = [] + for (let i = 0, { length } = edits; i < length; i += 1) { + const e = edits[i]! + mismatches.push(`${e.field}: saved ${e.from}, wanted ${e.to}`) + } + return { mismatches, ok: mismatches.length === 0 } +} + +/** + * One published package row from socket-registry's `registry/manifest.json`. + */ +export interface RegistryManifestEntry { + deprecated: boolean + name: string +} + +/** + * Parse socket-registry's `registry/manifest.json` body into its published + * package list. The manifest's `npm` value is an array of `[purl, data]` + * pairs; the name comes from `data.name`, falling back to decoding the purl + * (`pkg:npm/%40socketregistry/abab@1.0.9`). Deduped and sorted so the + * worklist is deterministic. Throws on a body that is not that shape — the + * expansion must never silently produce an empty sweep. Pure — exported for + * tests. + */ +export function parseSocketRegistryManifest( + manifestJson: string, +): RegistryManifestEntry[] { + const parsed = JSON.parse(manifestJson) as { npm?: unknown | undefined } + if (!Array.isArray(parsed.npm)) { + throw new Error( + 'socket-registry manifest has no `npm` array — refusing to expand ' + + 'an empty worklist.', + ) + } + const byName = new Map() + for (let i = 0, { length } = parsed.npm; i < length; i += 1) { + const entry = parsed.npm[i] as unknown[] + if (!Array.isArray(entry)) { + continue + } + const purl = typeof entry[0] === 'string' ? entry[0] : '' + const data = + entry[1] && typeof entry[1] === 'object' + ? (entry[1] as Record) + : {} + let name = typeof data['name'] === 'string' ? data['name'] : '' + if (!name) { + // The npm purl shape: `pkg:npm/` then the percent-encoded package name, + // then a final `@version` that stays outside the name capture. + const m = /^pkg:npm\/(.+?)@[^@]+$/.exec(purl) + name = m ? decodeURIComponent(m[1]!) : '' + } + if (name && !byName.has(name)) { + // First row wins on a duplicate name — the manifest is ordered and the + // dedup is purely defensive. + byName.set(name, { deprecated: data['deprecated'] === true, name }) + } + } + return [...byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)) +} + +/** + * One package's read-mode outcome, ready for the table renderer. + */ +export interface AccessReadRow { + current?: TrustedPublisherCurrent | undefined + detail?: string | undefined + pkg: string + state: AccessPageState +} + +// The read-mode verdict for one row: conforming, stale (with the stale form +// fields named), or the non-configured state. +function readVerdict(row: AccessReadRow): string { + if (row.state !== 'configured') { + return row.detail ? `${row.state}: ${row.detail}` : row.state + } + const desired = desiredTrustedPublisher({ + current: row.current, + pkg: row.pkg, + }) + if (!desired) { + return 'configured (no repo readable)' + } + const edits = diffTrustedPublisher({ current: row.current, desired }) + if (edits.length === 0) { + return 'conforms' + } + const fields: string[] = [] + for (let i = 0, { length } = edits; i < length; i += 1) { + fields.push(edits[i]!.field) + } + return `stale: ${fields.join(', ')}` +} + +/** + * Render read-mode rows as an aligned table: package, repo, workflow, + * environment, allowed actions, verdict. Pure — exported for tests. + */ +export function renderReadTable(rows: readonly AccessReadRow[]): string { + const header = [ + 'package', + 'repo', + 'workflow', + 'environment', + 'allowed actions', + 'verdict', + ] + const lines: string[][] = [header] + for (let i = 0, { length } = rows; i < length; i += 1) { + const row = rows[i]! + const c = row.current + const repo = + c?.repositoryOwner && c.repositoryName + ? `${c.repositoryOwner}/${c.repositoryName}` + : '-' + lines.push([ + row.pkg, + repo, + c?.workflowFilename ?? '-', + c?.environmentName ?? '(empty)', + c?.allowedActions.length ? c.allowedActions.join(' + ') : '-', + readVerdict(row), + ]) + } + const widths: number[] = [] + for (let col = 0, cols = header.length; col < cols; col += 1) { + let w = 0 + for (let i = 0, { length } = lines; i < length; i += 1) { + const cell = lines[i]![col] ?? '' + if (cell.length > w) { + w = cell.length + } + } + widths.push(w) + } + const rendered: string[] = [] + for (let i = 0, { length } = lines; i < length; i += 1) { + const cells = lines[i]! + const padded: string[] = [] + for (let col = 0, cols = cells.length; col < cols; col += 1) { + padded.push((cells[col] ?? '').padEnd(widths[col]!)) + } + rendered.push(padded.join(' ').trimEnd()) + } + return rendered.join('\n') +} + +/** + * Render one package's planned form edits for the apply dry-run. Pure — + * exported for tests. + */ +export function renderPlannedEdits( + pkg: string, + edits: readonly FormEdit[], +): string { + if (edits.length === 0) { + return `${pkg}: conforms — no edits` + } + const lines = [`${pkg}:`] + for (let i = 0, { length } = edits; i < length; i += 1) { + const e = edits[i]! + lines.push(` ${e.field}: ${e.from} -> ${e.to}`) + } + return lines.join('\n') +} + +export type ApplyStatus = + | 'applied' + | 'conforms' + | 'failed' + | 'planned' + | 'skipped' + +export interface ApplyResult { + detail?: string | undefined + pkg: string + status: ApplyStatus +} + +/** + * One-line human summary of an apply run: counts by status, tagged with the + * mode. Pure — exported for tests. + */ +export function formatApplySummary( + results: readonly ApplyResult[], + config: { drive: boolean }, +): string { + const cfg = { __proto__: null, ...config } as { drive: boolean } + const count = (status: ApplyStatus): number => { + let n = 0 + for (let i = 0, { length } = results; i < length; i += 1) { + if (results[i]!.status === status) { + n += 1 + } + } + return n + } + return ( + `Trusted-publisher ${cfg.drive ? 'drive' : 'dry-run'} summary: ` + + `${count('applied')} applied, ${count('planned')} planned, ` + + `${count('conforms')} conforming, ${count('skipped')} skipped, ` + + `${count('failed')} failed.` + ) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace-plan.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace-plan.mts new file mode 100644 index 00000000..8e8f438e --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace-plan.mts @@ -0,0 +1,316 @@ +/** + * @file Pure planning over a resolved multi-package npm workspace layout + * (workspace.mts): version-lockstep drift detection, dependency-aware + * publish-order computation (pnpm -r publish's topological semantics), + * absent- and hollow-platform-package detection, and the + * formatting-preserving lockstep bump-write planner. Everything here is pure + * over its inputs (plus existsSync probes against the real tree for the + * hollow gate) and unit-tested against fixture trees; the fs-reading layout + * resolution lives in workspace.mts. + */ + +import { existsSync } from 'node:fs' +import path from 'node:path' + +import type { + NpmWorkspaceLayout, + WorkspaceManifestShape, + WorkspacePackage, +} from './workspace.mts' + +export interface AbsentPlatformPackageReport { + missing: string[] + owner: WorkspacePackage +} + +export interface HollowPackageReport { + missing: string[] + pkg: WorkspacePackage +} + +export interface LockstepWriteInput { + name: string + raw: string + relManifestPath: string + siblingNames: readonly string[] +} + +export interface LockstepWrite { + relManifestPath: string + updated: string +} + +/** + * Version-lockstep drift check. Every publishable manifest must read the + * version source's version, and every exact sibling reference (a bare + * `X.Y.Z…` spec naming another publishable member — the loader's + * optionalDependencies rows) must match it too. Returns human-readable drift + * lines (empty = lockstep holds). Pure over the layout. + */ +export function checkVersionLockstep(layout: NpmWorkspaceLayout): string[] { + if (layout.kind === 'single') { + return [] + } + const { version } = layout.versionSource + const names = new Set(layout.packages.map(pkg => pkg.name)) + const drift: string[] = [] + for (const pkg of layout.packages) { + if (pkg.version !== version) { + drift.push( + `${pkg.relManifestPath}: version ${pkg.version} != ${version} ` + + `(the version source, ${layout.versionSource.relManifestPath})`, + ) + } + for (const section of [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + ] as const) { + for (const [depName, spec] of Object.entries( + pkg.manifest[section] ?? {}, + )) { + if (names.has(depName) && /^\d/.test(spec) && spec !== version) { + drift.push( + `${pkg.relManifestPath}: ${section}["${depName}"] pins ${spec} ` + + `!= ${version}`, + ) + } + } + } + } + return drift +} + +/** + * Dependency-aware publish order over the publishable members — + * `pnpm -r publish`'s topological semantics, computed here so the staged + * loop's gates, hollow, already-published, drift, run per package in the + * order the registry must receive them: a platform package always precedes + * the loader that optional-depends on it. Kahn's algorithm with a sorted + * ready set for determinism. Returns the cycle members instead of an order + * when the workspace dependency graph cannot be ordered. Pure. + */ +export function computePublishOrder(packages: readonly WorkspacePackage[]): { + cycle: string[] | undefined + order: WorkspacePackage[] +} { + const byName = new Map(packages.map(pkg => [pkg.name, pkg])) + const dependsOn = new Map>() + for (const pkg of packages) { + const edges = new Set() + for (const section of [ + 'dependencies', + 'optionalDependencies', + 'peerDependencies', + ] as const) { + const depNames = Object.keys(pkg.manifest[section] ?? {}) + for (let i = 0, { length } = depNames; i < length; i += 1) { + const depName = depNames[i]! + if (depName !== pkg.name && byName.has(depName)) { + edges.add(depName) + } + } + } + dependsOn.set(pkg.name, edges) + } + const order: WorkspacePackage[] = [] + const placed = new Set() + const names = [...byName.keys()].toSorted() + while (placed.size < names.length) { + const ready = names.filter( + name => + !placed.has(name) && + [...dependsOn.get(name)!].every(dep => placed.has(dep)), + ) + if (ready.length === 0) { + return { + cycle: names.filter(name => !placed.has(name)), + order: [], + } + } + for (let i = 0, { length } = ready; i < length; i += 1) { + placed.add(ready[i]!) + order.push(byName.get(ready[i]!)!) + } + } + return { cycle: undefined, order } +} + +/** + * True when the manifest's declared payload carries a machine-built artifact + * (.wasm / .node). Such a payload has no local byte-twin — it comes from the + * CI build, and a re-build on a different host/toolchain legitimately differs + * byte-for-byte — so pre-approve verification must be STRUCTURAL on the + * staged bytes (verifyStagedPlatformEntry), never a local-pack byte-compare. + */ +export function hasMachineBuiltPayload( + manifest: WorkspaceManifestShape, +): boolean { + return requiredPayloadFiles(manifest).some( + rel => rel.endsWith('.wasm') || rel.endsWith('.node'), + ) +} + +/** + * The concrete payload files a platform package's manifest declares: the + * literal (glob-free) `files` entries plus `main`, sorted. The hollow gate + * requires them on disk pre-publish; the approve-time structural verify + * requires them inside the staged tarball. Pure. + */ +export function requiredPayloadFiles( + manifest: WorkspaceManifestShape, +): string[] { + const required = new Set() + const files = manifest.files ?? [] + for (let i = 0, { length } = files; i < length; i += 1) { + const entry = files[i]! + if (!/[!*?{]/.test(entry)) { + required.add(entry) + } + } + if (typeof manifest.main === 'string' && manifest.main) { + required.add(manifest.main) + } + return [...required].toSorted() +} + +/** + * Hollow-package detection: a generated platform package whose declared + * payload is not on disk must NEVER publish (an empty platform package points + * every consumer install at a broken binary). Required payload = the literal + * (glob-free) `files` entries plus `main`; a platform manifest declaring no + * concrete payload at all is reported hollow too — fail loud beats a silent + * empty tarball. Pure over the discovered packages + the real tree. + */ +export function findHollowPackages( + packages: readonly WorkspacePackage[], +): HollowPackageReport[] { + const reports: HollowPackageReport[] = [] + for (const pkg of packages) { + if (!pkg.platform) { + continue + } + const required = requiredPayloadFiles(pkg.manifest) + if (required.length === 0) { + reports.push({ + missing: [''], + pkg, + }) + continue + } + const missing = required.filter(rel => !existsSync(path.join(pkg.dir, rel))) + if (missing.length > 0) { + reports.push({ missing, pkg }) + } + } + return reports +} + +/** + * True when `depName` is one of `ownerName`'s own generated platform siblings: + * either `@/` (the decmpfs shape — the unscoped loader + * `decmpfs` owns `@decmpfs/darwin-arm64`) or `-` (the stuie + * shape — `@stuie/core` owns `@stuie/core-darwin-arm64`). An unrelated + * third-party optional dependency matches neither, so it is never mistaken for + * a platform package this repo is expected to ship. + */ +function isPlatformSiblingName(ownerName: string, depName: string): boolean { + if (depName.startsWith(`${ownerName}-`)) { + return true + } + const scope = ownerName.startsWith('@') + ? ownerName.slice(1).split('/')[0]! + : ownerName + return depName.startsWith(`@${scope}/`) +} + +/** + * Absent-platform-package detection: a loader that DECLARES sibling platform + * packages in `optionalDependencies` must have every one of them on disk as a + * real package directory at publish time. An absent directory is invisible to + * the hollow gate, which can only inspect dirs that exist, yet publishing the + * loader anyway ships `optionalDependencies` pointing at names that 404 — every + * consumer install breaks. Repos gitignore their generated `npm//` + * dirs, so a clean CI checkout has NONE of them until the platform matrix build + * stages the artifacts; that is exactly the shape this catches. + * + * The expected set comes from the loader's own declaration, never from what + * happens to be on disk: every generator-owning package's exact-version + * (`X.Y.Z…`) `optionalDependencies` row naming one of its platform siblings. + * A name the by-convention discovery in workspace.mts already resolved to a + * package directory is present, its payload is the hollow gate's business; + * anything left over is missing. Pure over the discovered packages. + */ +export function findAbsentPlatformPackages( + packages: readonly WorkspacePackage[], +): AbsentPlatformPackageReport[] { + const discovered = new Set(packages.map(pkg => pkg.name)) + const reports: AbsentPlatformPackageReport[] = [] + for (const owner of packages) { + if (!owner.generatorPath) { + continue + } + const missing: string[] = [] + for (const [depName, spec] of Object.entries( + owner.manifest.optionalDependencies ?? {}, + )) { + if ( + /^\d/.test(spec) && + isPlatformSiblingName(owner.name, depName) && + !discovered.has(depName) + ) { + missing.push(depName) + } + } + if (missing.length > 0) { + reports.push({ missing: missing.toSorted(), owner }) + } + } + return reports +} + +/** + * Replace the root `"version"` field in manifest text, preserving the file's + * existing formatting (a parse → stringify round-trip would reorder keys and + * reflow the file). Matches the first `"version"` — the root field. + */ +export function replaceManifestVersion( + raw: string, + nextVersion: string, +): string { + return raw.replace(/("version":\s*")[^"]+(")/, `$1${nextVersion}$2`) +} + +/** + * Plan the lockstep bump writes: every manifest's root `version` moves to + * `nextVersion`, and every exact sibling reference (a bare `X.Y.Z…` spec + * naming another publishable member) moves with it — `workspace:*` / + * `catalog:` / `npm:` specs are left alone. Formatting-preserving text + * replacement; manifests already at `nextVersion` with no stale refs produce + * no write. Pure — exported for tests; the bump applies the writes and then + * invokes each declared generator so generated platform dirs re-derive from + * the bumped main manifest. + */ +export function planLockstepManifestWrites( + inputs: readonly LockstepWriteInput[], + nextVersion: string, +): LockstepWrite[] { + const writes: LockstepWrite[] = [] + for (const input of inputs) { + let updated = replaceManifestVersion(input.raw, nextVersion) + for (const sibling of input.siblingNames) { + if (sibling === input.name) { + continue + } + const escaped = sibling.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + updated = updated.replace( + new RegExp(`("${escaped}":\\s*")\\d[^"]*(")`, 'g'), + `$1${nextVersion}$2`, + ) + } + if (updated !== input.raw) { + writes.push({ relManifestPath: input.relManifestPath, updated }) + } + } + return writes +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace.mts b/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace.mts new file mode 100644 index 00000000..04d5e293 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/npm/workspace.mts @@ -0,0 +1,458 @@ +/* + * @file Multi-package npm workspace layout resolution for the publish engine. + * A repo's publishable npm surface is DERIVED, never declared twice: the + * pnpm-workspace.yaml `packages:` globs name the members, `private: false` + * (absent) + name + version marks a member publishable, and the + * `/scripts/make-npm-dirs.mts` + `/npm//` + * convention marks generated platform packages (napi prebuilt-binary + * carriers) even when they are not workspace members themselves. Layout + * kinds: + * + * - `single` — the root manifest is publishable (or redirects via + * `publishConfig.directory`), the socket-lib / socket-registry shape. The + * existing single-subject machinery owns everything; this module changes + * NOTHING for these repos. + * - `multi` — the root manifest is private/versionless and the publishable + * packages are workspace members (decmpfs: `napi/decmpfs` + + * `napi/decmpfs/npm/`; stuie: `packages/*` + + * `packages/core/npm/`). The MAIN package anchors the registry + * history; the version source is the root manifest when it carries a + * version, else the main package; every publishable manifest moves in + * lockstep. The pure planning helpers over a resolved layout (lockstep + * writes, publish order, hollow detection) live in workspace-plan.mts; the + * fs-reading resolvers here fail LOUD (What / Where / Saw-vs-wanted / Fix), + * never silently fall back to a private root manifest. + * + * DEPENDENCY-FREE BY DESIGN: node builtins plus the dep-0 leaves + * `lib/workspace-yaml.mts`, `_shared/release-subject.mts`, and + * `_shared/unix-path.mts` — nothing from lib-stable. The release-reconcile + * gap job resolves its npm subject through `resolveNpmWorkspaceLayout` on a + * bare depth-1 checkout with no pnpm install, so ONE layout resolver serves + * both the installed publish engine and the dep-0 healer. Keep it that way: + * a lib-stable import here blinds the healer on every private-root + * workspace repo. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' + +import { parseListBlock } from '../../lib/workspace-yaml.mts' +import { resolveReleaseSubject } from '../../_shared/release-subject.mts' +import { toUnixPath } from '../../_shared/unix-path.mts' + +import type { ReleaseSubject } from '../../_shared/release-subject.mts' + +export interface WorkspaceManifestShape { + cpu?: string[] | undefined + dependencies?: Record | undefined + files?: string[] | undefined + main?: string | undefined + name?: string | undefined + optionalDependencies?: Record | undefined + os?: string[] | undefined + peerDependencies?: Record | undefined + private?: boolean | undefined + publishConfig?: { directory?: unknown | undefined } | undefined + repository?: string | { url?: string | undefined } | undefined + version?: string | undefined +} + +export interface WorkspacePackage { + dir: string + /** + * Absolute path of this package's platform-package generator + * (`/scripts/make-npm-dirs.mts`) when it owns generated + * `npm//` dirs. The engine INVOKES it, never reimplements it. + */ + generatorPath: string | undefined + manifest: WorkspaceManifestShape + manifestPath: string + name: string + /** + * True for a generated platform package — a publishable dir under another + * publishable package's `npm/` directory, carrying a prebuilt payload. + */ + platform: boolean + /** + * Owning loader package's name when `platform` is true. + */ + platformOwner: string | undefined + relDir: string + relManifestPath: string + version: string +} + +export interface WorkspaceVersionSource { + /** + * The package name whose registry history anchors the release. + */ + name: string + relManifestPath: string + version: string +} + +export interface NpmWorkspaceLayout { + kind: 'multi' | 'single' + /** + * The registry-anchor package for a multi layout; undefined for single. + */ + main: WorkspacePackage | undefined + /** + * Publishable packages for a multi layout; empty for single. + */ + packages: WorkspacePackage[] + repository: string | { url?: string | undefined } | undefined + rootPath: string + /** + * The resolved single-package publish subject; undefined for multi. + */ + subject: ReleaseSubject | undefined + versionSource: WorkspaceVersionSource +} + +const GENERATOR_REL_PATH = path.join('scripts', 'make-npm-dirs.mts') + +/** + * One package.json read, tolerant by design: an absent or unparseable manifest + * yields `undefined` so a caller can distinguish "no manifest here" from a + * manifest that says something. Exported so the release-reconcile gap job reads + * a root manifest through the SAME reader the layout resolver uses instead of + * hand-rolling a second JSON read of the same file. + */ +export function readManifest( + manifestPath: string, +): WorkspaceManifestShape | undefined { + let raw: string + try { + raw = readFileSync(manifestPath, 'utf8') + } catch { + return undefined + } + try { + return JSON.parse(raw) as WorkspaceManifestShape + } catch { + return undefined + } +} + +function isPublishableManifest( + manifest: WorkspaceManifestShape | undefined, +): manifest is WorkspaceManifestShape & { name: string; version: string } { + return ( + !!manifest && + manifest.private !== true && + typeof manifest.name === 'string' && + manifest.name.length > 0 && + typeof manifest.version === 'string' && + manifest.version.length > 0 + ) +} + +/** + * Expand one pnpm-workspace `packages:` glob against the real tree. Segment + * -wise: a `*` segment expands to every child directory at that depth + * (matching pnpm's single-level semantics for `dir/*`); literal segments must + * exist. `**` is not expanded — the fleet's workspace files use literal paths + * and single-level `dir/*` globs only. Exported for tests. + */ +export function expandWorkspaceGlob(rootPath: string, glob: string): string[] { + const segments = toUnixPath(glob).split('/').filter(Boolean) + let dirs = [rootPath] + for (let i = 0, { length } = segments; i < length; i += 1) { + const segment = segments[i]! + const next: string[] = [] + for (let j = 0, dirCount = dirs.length; j < dirCount; j += 1) { + const dir = dirs[j]! + if (segment === '*') { + let children: string[] + try { + children = readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(dir, entry.name)) + } catch { + continue + } + next.push(...children.toSorted()) + } else if (segment.includes('*')) { + // Partial-wildcard segment (`@*`, `pkg-*`) — fleet workspace files use + // the scope form (`packages/npm/@*/*`). Match child dirs on the + // literal prefix + suffix around a single `*`. + const star = segment.indexOf('*') + const prefix = segment.slice(0, star) + const suffix = segment.slice(star + 1) + let children: string[] + try { + children = readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + } catch { + continue + } + const matched = children + .filter( + name => + name.length >= prefix.length + suffix.length && + name.startsWith(prefix) && + name.endsWith(suffix), + ) + .toSorted() + .map(name => path.join(dir, name)) + next.push(...matched) + } else { + const child = path.join(dir, segment) + if (existsSync(child)) { + next.push(child) + } + } + } + dirs = next + } + return dirs +} + +/** + * Discover the repo's publishable workspace packages: expand the + * pnpm-workspace.yaml `packages:` globs (honoring `!` negations), keep the + * dirs whose manifest is publishable (`private` !== true, has name + + * version), then add generated platform packages by convention — any + * publishable `npm//` dir under a discovered package that owns a + * `scripts/make-npm-dirs.mts` generator — even when those dirs are not + * workspace members, the stuie shape. Deduped by dir, sorted by relDir. + */ +export function discoverWorkspacePackages( + rootPath: string, +): WorkspacePackage[] { + const workspaceYamlPath = path.join(rootPath, 'pnpm-workspace.yaml') + let globs: string[] = [] + if (existsSync(workspaceYamlPath)) { + globs = parseListBlock(readFileSync(workspaceYamlPath, 'utf8'), { + blockKey: 'packages', + }) + } + const included = new Set() + const excluded = new Set() + for (let i = 0, { length } = globs; i < length; i += 1) { + const glob = globs[i]! + const negated = glob.startsWith('!') + const target = negated ? excluded : included + const dirs = expandWorkspaceGlob(rootPath, negated ? glob.slice(1) : glob) + for (let j = 0, dirCount = dirs.length; j < dirCount; j += 1) { + target.add(dirs[j]!) + } + } + const byDir = new Map() + function addPackage(dir: string): WorkspacePackage | undefined { + const known = byDir.get(dir) + if (known) { + return known + } + const manifestPath = path.join(dir, 'package.json') + const manifest = readManifest(manifestPath) + if (!isPublishableManifest(manifest)) { + return undefined + } + const generatorPath = path.join(dir, GENERATOR_REL_PATH) + const pkg: WorkspacePackage = { + dir, + generatorPath: existsSync(generatorPath) ? generatorPath : undefined, + manifest, + manifestPath, + name: manifest.name, + platform: false, + platformOwner: undefined, + relDir: toUnixPath(path.relative(rootPath, dir)), + relManifestPath: toUnixPath(path.relative(rootPath, manifestPath)), + version: manifest.version, + } + byDir.set(dir, pkg) + return pkg + } + for (const dir of included) { + if (dir === rootPath || excluded.has(dir)) { + continue + } + addPackage(dir) + } + // Generated platform packages by convention: a generator-owning package's + // `npm//` children are publishable platform packages even when + // the workspace globs don't list them (stuie's packages/core/npm/*). + const owners = [...byDir.values()] + for (let i = 0, { length } = owners; i < length; i += 1) { + const owner = owners[i]! + if (!owner.generatorPath) { + continue + } + const npmDir = path.join(owner.dir, 'npm') + if (!existsSync(npmDir)) { + continue + } + const entries = readdirSync(npmDir, { withFileTypes: true }) + for (let j = 0, entryCount = entries.length; j < entryCount; j += 1) { + const entry = entries[j]! + if (entry.isDirectory()) { + addPackage(path.join(npmDir, entry.name)) + } + } + } + // Classify platform packages: any publishable package under another + // publishable package's `npm/` dir carries that owner's prebuilt payload. + const packages = [...byDir.values()] + for (let i = 0, { length } = packages; i < length; i += 1) { + const pkg = packages[i]! + for (let j = 0; j < packages.length; j += 1) { + const owner = packages[j]! + if ( + pkg !== owner && + toUnixPath(pkg.dir).startsWith( + `${toUnixPath(path.join(owner.dir, 'npm'))}/`, + ) + ) { + pkg.platform = true + pkg.platformOwner = owner.name + break + } + } + } + return packages.toSorted((a, b) => a.relDir.localeCompare(b.relDir)) +} + +function selectMainPackage( + packages: readonly WorkspacePackage[], + rootPath: string, +): WorkspacePackage { + const loaders = packages.filter(pkg => !pkg.platform) + if (loaders.length === 1) { + return loaders[0]! + } + const generatorOwners = loaders.filter(pkg => pkg.generatorPath) + if (generatorOwners.length === 1) { + return generatorOwners[0]! + } + throw new Error( + `Cannot determine the MAIN npm package for this workspace.\n` + + ` Where: ${path.join(rootPath, 'pnpm-workspace.yaml')}\n` + + ` Saw vs wanted: ${loaders.length} non-platform publishable ` + + `package(s) (${loaders.map(pkg => pkg.name).join(', ') || 'none'}) and ` + + `${generatorOwners.length} platform-package generator owner(s); wanted ` + + `exactly one of either to anchor the release.\n` + + ` Fix: keep exactly one workspace package owning the ` + + `scripts/make-npm-dirs.mts generator (the loader package), or mark the ` + + `non-publishable members "private": true so one main remains.`, + ) +} + +/** + * Resolve the repo's npm publish layout. `single` — byte-identical to the + * existing engine behavior — whenever the root manifest is itself publishable + * or redirects via `publishConfig.directory`. `multi` when the root is + * private/versionless and publishable workspace members exist. Throws LOUD + * when neither shape resolves (a versionless root with no publishable + * members) — a publish must never guess its subject. + */ +export function resolveNpmWorkspaceLayout( + rootPath: string, +): NpmWorkspaceLayout { + const rootManifestPath = path.join(rootPath, 'package.json') + const root = readManifest(rootManifestPath) + const rootRedirects = root?.publishConfig?.directory !== undefined + if (rootRedirects || isPublishableManifest(root)) { + const subject = resolveReleaseSubject(rootPath) + return { + kind: 'single', + main: undefined, + packages: [], + repository: subject.repository, + rootPath, + subject, + versionSource: { + name: subject.name, + relManifestPath: toUnixPath( + path.relative(rootPath, subject.manifestPath), + ), + version: subject.version, + }, + } + } + const packages = discoverWorkspacePackages(rootPath) + if (packages.length === 0) { + if (typeof root?.version === 'string' && root.version) { + // A private, versioned root with no publishable members: the + // bump-only shape. The root stays the subject. + const subject = resolveReleaseSubject(rootPath) + return { + kind: 'single', + main: undefined, + packages: [], + repository: subject.repository, + rootPath, + subject, + versionSource: { + name: subject.name, + relManifestPath: 'package.json', + version: subject.version, + }, + } + } + throw new Error( + `No publishable npm package found in this repo.\n` + + ` Where: ${rootManifestPath}\n` + + ` Saw vs wanted: a root manifest with no version (private workspace ` + + `root) and no pnpm-workspace member with private !== true + name + ` + + `version; wanted either a publishable root or at least one ` + + `publishable workspace package.\n` + + ` Fix: give the publishable package a name + version (drop ` + + `"private": true), or list its directory under packages: in ` + + `pnpm-workspace.yaml.`, + ) + } + const main = selectMainPackage(packages, rootPath) + // A 0.0.0 root is the private-placeholder convention (the root never bumps + // and never publishes) — versionless for layout purposes, so the MAIN + // member is the version source and release versions live on the package + // that actually ships. + const rootHasVersion = + typeof root?.version === 'string' && + !!root.version && + root.version !== '0.0.0' + return { + kind: 'multi', + main, + packages, + repository: root?.repository ?? main.manifest.repository, + rootPath, + subject: undefined, + versionSource: { + name: main.name, + relManifestPath: rootHasVersion ? 'package.json' : main.relManifestPath, + version: rootHasVersion ? String(root!.version) : main.version, + }, + } +} + +/** + * The set of package names this repo publishes — the approve flow's + * "ours" filter. Single layout: the subject name. Multi: every publishable + * member. + */ +export function workspacePublishableNames(rootPath: string): Set { + const layout = resolveNpmWorkspaceLayout(rootPath) + if (layout.kind === 'single') { + return new Set([layout.versionSource.name]) + } + return new Set(layout.packages.map(pkg => pkg.name)) +} + +/** + * Find the publishable package, or single subject, that publishes `name`. + * Returns its directory + platform flag, or undefined when this repo does not + * publish `name`, the cross-repo staged-entry case. + */ +export function findWorkspacePackageByName( + layout: NpmWorkspaceLayout, + name: string, +): WorkspacePackage | undefined { + if (layout.kind === 'single') { + return undefined + } + return layout.packages.find(pkg => pkg.name === name) +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/pin-readme.mts b/release-kit/payload/scripts/socket-release/publish-infra/pin-readme.mts new file mode 100644 index 00000000..ee90b82f --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/pin-readme.mts @@ -0,0 +1,178 @@ +/** + * @file Publish-time README asset pin — registry-agnostic (npm AND cargo). A + * registry renders a package's README (npmjs.com for npm; crates.io + docs.rs + * for cargo), and RELATIVE image paths (`assets/…svg` — the coverage badge, + * the social-media / brand follow badges) only resolve when viewing the repo + * on GitHub; on the registry page they 404. The fix: in the PUBLISHED + * artifact only (npm tarball / `.crate`), rewrite relative asset refs to an + * absolute raw-GitHub URL pinned to the release-tag COMMIT SHA + * (`…//assets/…` — the sha is the truly immutable ref: a tag can be + * deleted or re-pointed, a commit sha cannot), falling back to the tag name + * (`…/v/assets/…`) when the tag doesn't exist locally yet (a + * dry-run pack, or `--direct` mode where ensureTagAndRelease runs after the + * publish) so the badge is immutable + matches exactly what shipped. The + * committed README + * keeps relative paths (GitHub renders those live at HEAD, and the badge + * generators/checks key on the relative form) — so this is applied around the + * pack/publish and restored after (try/finally). Why pack-time + + * orchestrator-driven, not a prepack hook: the fleet npm publish runs `pnpm + * stage publish --ignore-scripts`, so lifecycle hooks never fire; and npm + * `--approve` re-packs locally to integrity-compare against the staged + * tarball, so BOTH packs must see the same pinned README or the gate trips on + * a content diff. For cargo, crates.io embeds the README from disk at `cargo + * publish`/`cargo package` time, and cargo refuses a VCS-dirty tree — so the + * bracketed publish passes `--allow-dirty` when, and only when, a pin was + * written (the [`withPinnedReadme`] callback receives that flag). Pure + * helpers here; the pin/restore bracket wraps each registry's pack. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { runCapture } from './shared.mts' +import { writeThroughMirrorLock } from '../_shared/mirror-lock.mts' + +// The GitHub owner/repo from a package.json `repository` field (string or +// `{ url }`), tolerating the common `git+https://…`, `git@github.com:…`, and +// bare `owner/repo` shapes. Returns `undefined` when it isn't a GitHub repo we +// can pin against (caller then skips pinning — fail-open, never a bad URL). +export function parseGitHubSlug( + repository: string | { url?: string | undefined } | undefined, +): string | undefined { + const raw = + typeof repository === 'string' ? repository : (repository?.url ?? '') + if (!raw) { + return undefined + } + // git@github.com:owner/repo(.git) | https://github.com/owner/repo(.git) | + // git+https://github.com/owner/repo(.git) + const m = + /github\.com[:/]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[#?].*)?$/.exec(raw) ?? + /^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(raw) + if (!m) { + return undefined + } + return `${m[1]}/${m[2]}` +} + +/** + * The `raw.githubusercontent.com` base, trailing slash, for a repo slug + git + * ref, e.g. `SocketDev/socket-lib` + `v1.2.3` → + * `https://raw.githubusercontent.com/SocketDev/socket-lib/v1.2.3/`. + */ +export function rawBaseUrl(slug: string, ref: string): string { + return `https://raw.githubusercontent.com/${slug}/${ref}/` +} + +/** + * Rewrite the README's RELATIVE `assets/…` refs (both `` + * and markdown `](assets/…)`) to absolute `${baseUrl}assets/…`. Absolute refs + * (the socket.dev badge, any https link) are untouched — only the leading + * `assets/` sentinel is matched. Idempotent: an already-absolute ref has no + * leading `assets/` to match. Pure. + */ +export function pinReadmeAssets(readme: string, baseUrl: string): string { + return readme + .replaceAll('src="assets/', `src="${baseUrl}assets/`) + .replaceAll('](assets/', `](${baseUrl}assets/`) +} + +// A full git commit sha — the only thing we'll pin a raw URL to besides the +// tag name itself. +const COMMIT_SHA_RE = /^[0-9a-f]{40}$/ // socket-lint: allow uncommented-regex + +/** + * The commit sha the local tag `tag` points at, or undefined when the tag + * doesn't exist, or the sha can't be read. Probes existence first with + * `show-ref --verify --quiet` — silent on both streams, so the EXPECTED + * missing-tag case (a dry-run pack, `--direct` mode) doesn't spray a + * `fatal: ambiguous argument` into the publish output (runCapture inherits + * stderr by design). `git rev-list -n1` then PEELS annotated tags to their + * commit — `rev-parse` would return the tag object's own sha, which + * raw.githubusercontent does not serve. + */ +export async function resolveTagCommitSha( + rootPath: string, + tag: string, +): Promise { + const probe = await runCapture( + 'git', + ['show-ref', '--tags', '--verify', '--quiet', `refs/tags/${tag}`], + rootPath, + ) + if (probe.code !== 0) { + return undefined + } + const r = await runCapture('git', ['rev-list', '-n1', tag], rootPath) + const sha = r.stdout.trim() + return r.code === 0 && COMMIT_SHA_RE.test(sha) ? sha : undefined +} + +export interface PinTarget { + // Repo-root-relative README path (default 'README.md'). + readmePath?: string | undefined + // package.json `repository` (string or { url }). + repository: string | { url?: string | undefined } | undefined + // Injectable tag→commit-sha resolver (tests); defaults to + // resolveTagCommitSha (a real `git rev-list -n1` in rootPath). + resolveTagSha?: + | ((rootPath: string, tag: string) => Promise) + | undefined + // Repo root the README + pack run from. + rootPath: string + // The release version being published (bare, e.g. '1.2.3'); pinned to tag + // `v`'s commit sha, tag-name fallback pre-tag. + version: string +} + +/** + * Run `fn(pinned)` with the on-disk README temporarily pinned to the release + * tag's COMMIT SHA — the truly immutable ref: a tag can be deleted or + * force-moved after the fact, a commit sha cannot. The release pipeline tags + * at its `release` stage BEFORE the publish pipeline packs, so the tag + * normally resolves locally; when it doesn't yet exist — dry-run packs, or + * `--direct` mode where the tag lands post-publish — the pin falls back to + * the `v` tag name so both packs of one release still agree. Then + * ALWAYS restore the original bytes (try/finally). `pinned` is `true` + * only when a rewrite was actually written — cargo callers use it to pass + * `--allow-dirty` exactly when the README is the sole dirty file, and no wider. + * No-op (runs `fn(false)` untouched) when the repo isn't a pinnable GitHub + * repo, the README is absent, or it has no relative asset refs — pinning is a + * hygiene nicety, never a publish blocker. Returns `fn`'s result. + */ +export async function withPinnedReadme( + target: PinTarget, + fn: (pinned: boolean) => Promise, +): Promise { + const readmePath = path.join( + target.rootPath, + target.readmePath ?? 'README.md', + ) + const slug = parseGitHubSlug(target.repository) + let original: string | undefined + if (slug) { + try { + original = readFileSync(readmePath, 'utf8') + } catch { + original = undefined + } + } + if (original === undefined) { + // Not pinnable (no slug or no README) — publish the artifact as-is. + return await fn(false) + } + const tagName = `v${target.version}` + const resolveSha = target.resolveTagSha ?? resolveTagCommitSha + const ref = (await resolveSha(target.rootPath, tagName)) ?? tagName + const pinnedReadme = pinReadmeAssets(original, rawBaseUrl(slug!, ref)) + if (pinnedReadme === original) { + // No relative asset refs to pin — skip the write/restore churn. + return await fn(false) + } + writeThroughMirrorLock(readmePath, pinnedReadme) + try { + return await fn(true) + } finally { + writeThroughMirrorLock(readmePath, original) + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/reconcile.mts b/release-kit/payload/scripts/socket-release/publish-infra/reconcile.mts new file mode 100644 index 00000000..fbdf103e --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/reconcile.mts @@ -0,0 +1,128 @@ +/* + * @file Publish-flow git reconcile (code-as-law). After a release publishes, + * local main must be aligned with the freshly-released remote — our remaining + * changes on top of the newly-published base, never a divergent local main. + * Two fail-LOUD steps, a publish lineage is never auto-resolved, both run + * ONCE PUBLISHED — after `--approve`, default on a LOCAL publish; + * `--no-reconcile` opts out; CI `--staged` never reconciles: + * + * - Rebase-onto-published: resolve the now-published version from the registry, + * find the origin/main commit that bumped to it, and rebase our remaining + * local commits onto that commit. Any conflict aborts the rebase and + * throws. + * - Fast-forward: pull local main up to the now-updated origin so the local + * tree matches the freshly-released remote. + */ + +import { packumentUrl } from '../constants/npm-registry.mts' +import { fetchLatestPublishedVersion } from './npm/registry.mts' +import { logger, runCapture } from './shared.mts' + +/** + * The registry `dist-tags.latest` for a package — the currently-published + * version. Wraps the tolerant reader, single dist-tags source of truth, and + * throws What/Where/Saw/Fix when the tag can't be resolved, because a reconcile + * lineage is never auto-resolved on a missing base. + */ +export async function fetchPublishedVersion(name: string): Promise { + const latest = await fetchLatestPublishedVersion(name) + if (!latest) { + throw new Error( + `reconcile: could not read the published version of ${name}.\n` + + ` Where: ${packumentUrl(name)}\n` + + ` Saw: no dist-tags.latest (registry unreachable, or first publish / dist-tag lag)\n` + + ` Fix: check network / registry reachability, then re-run — or --no-reconcile.`, + ) + } + return latest +} + +/** + * The origin/main SHA that bumped to `version` — the published release commit + * we rebase onto. Matches the canonical bump subject `chore: bump version to + * `. Fetches origin first. Throws when no such commit exists. + */ +export async function findPublishedBaseSha( + cwd: string, + version: string, +): Promise { + await runCapture('git', ['fetch', 'origin', 'main'], cwd) + const subject = `chore: bump version to ${version}` + const { code, stdout } = await runCapture( + 'git', + ['log', 'FETCH_HEAD', '--format=%H %s', '--max-count=500'], + cwd, + ) + if (code === 0) { + const lines = stdout.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! + const sha = line.slice(0, line.indexOf(' ')) + const msg = line.slice(line.indexOf(' ') + 1) + if (sha && msg === subject) { + return sha + } + } + } + throw new Error( + `reconcile: no "${subject}" commit on origin/main.\n` + + ` Where: git log FETCH_HEAD (origin/main)\n` + + ` Saw: the published version's bump commit is not in the last 500 commits.\n` + + ` Fix: confirm the published version matches a release commit on origin/main.`, + ) +} + +/** + * Rebase the local branch's commits onto `baseSha`, the published release. The + * working tree MUST be clean. Any conflict aborts the rebase and throws — a + * publish lineage is never auto-resolved. No-op when already on `baseSha`. + */ +export async function rebaseOntoPublishedBase( + cwd: string, + baseSha: string, +): Promise { + const status = await runCapture('git', ['status', '--porcelain'], cwd) + if (status.stdout.trim().length > 0) { + throw new Error( + 'reconcile: working tree is dirty — cannot rebase for publish.\n' + + ` Saw: ${status.stdout.trim().split('\n').length} uncommitted path(s).\n` + + ' Fix: commit or set aside your changes, then re-run the publish.', + ) + } + const rebase = await runCapture('git', ['rebase', baseSha], cwd) + if (rebase.code !== 0) { + await runCapture('git', ['rebase', '--abort'], cwd) + throw new Error( + `reconcile: rebase onto ${baseSha.slice(0, 8)} (the published base) hit a conflict.\n` + + ' Where: git rebase (aborted — tree restored).\n' + + " Saw: our local commits don't apply cleanly on the published release.\n" + + ' Fix: resolve the divergence by hand (or re-run with --no-reconcile).', + ) + } + logger.success( + `reconcile: rebased local commits onto published base ${baseSha.slice(0, 8)}.`, + ) +} + +/** + * Fast-forward local main to origin/main. Run AFTER a release is approved (the + * release App has pushed the new bump), so the local tree matches the now- + * updated remote. `--ff-only` refuses if local has diverged — a diverged main + * post-approve means something else pushed, so surface it rather than merge. + */ +export async function syncFromOriginMain(cwd: string): Promise { + const pull = await runCapture( + 'git', + ['pull', '--ff-only', 'origin', 'main'], + cwd, + ) + if (pull.code !== 0) { + throw new Error( + 'reconcile: could not fast-forward local main to origin/main after approve.\n' + + ' Where: git pull --ff-only origin main.\n' + + ' Saw: local main has diverged from origin (a non-ff pull).\n' + + ' Fix: reconcile local main forward by hand — do not force.', + ) + } + logger.success('reconcile: local main fast-forwarded to origin/main.') +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/release.mts b/release-kit/payload/scripts/socket-release/publish-infra/release.mts new file mode 100644 index 00000000..c5e045bc --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/release.mts @@ -0,0 +1,436 @@ +/** + * @file Registry-agnostic post-publish release orchestration: derive the + * GitHub release body from CHANGELOG.md, then create the git tag + the + * IMMUTABLE (draft → upload → undraft) GitHub release carrying the tarball + * \+ a checksums file. A future cargo publish reuses this tier verbatim. + */ + +import crypto from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import { errorMessage } from '@socketsecurity/lib/errors/message' +import { safeDeleteSync } from '@socketsecurity/lib/fs/safe' +import { sleep } from '@socketsecurity/lib/promises/timers' + +import { createTagRef } from '../lib/github-git-refs.mts' +import { formatReleaseGapFailure } from '../_shared/release-gap-recovery.mts' +import { resolveReleaseSubject } from '../_shared/release-subject.mts' +import { writeThroughMirrorLock } from '../_shared/mirror-lock.mts' +import { withPrunedPackManifest } from './npm/pack-manifest.mts' +import { logger, rootPath, runCapture } from './shared.mts' + +/** + * Extract the CHANGELOG.md section for `version` (from its `## ` + * heading to the next `## `) — the PUBLISH SUBJECT's changelog, which is the + * root CHANGELOG.md for a plain repo and the publishConfig.directory one for + * a redirected monorepo. The release body comes from here so the GitHub + * release and the changelog can never tell different stories. Falls back to a + * one-liner when the file or section is missing. `root` is injectable for + * tests. + */ +export function extractChangelogSection( + version: string, + root: string = rootPath, +): string { + const changelogPath = resolveReleaseSubject(root).changelogPath + if (!existsSync(changelogPath)) { + return `Release ${version}.` + } + const text = readFileSync(changelogPath, 'utf8') + const lines = text.split('\n') + // Heading shapes seen across the fleet: `## 1.2.3`, `## [1.2.3]`, + // `## v1.2.3`, each optionally followed by a date. + const isVersionHeading = (line: string): boolean => { + if (!line.startsWith('## ')) { + return false + } + const rest = line.slice(3).trim().replace(/^\[/, '').replace(/^v/, '') + if (!rest.startsWith(version)) { + return false + } + const boundary = rest.charAt(version.length) + return boundary === '' || boundary === ']' || /\s/.test(boundary) + } + const start = lines.findIndex(isVersionHeading) + if (start === -1) { + return `Release ${version}.` + } + let end = lines.length + for (let i = start + 1; i < lines.length; i += 1) { + if (lines[i]!.startsWith('## ')) { + end = i + break + } + } + const body = lines + .slice(start + 1, end) + .join('\n') + .trim() + return body || `Release ${version}.` +} + +/** + * The registry-liveness gate the tag + GitHub release stand behind: the git + * tag and the immutable release are the LAST markers of a release, so they + * may only exist once the version is actually resolvable on its registry. A + * STAGED package is not published — staging may never be approved — and a + * near-miss (v6.2.0) once cut the immutable release first, then the publish + * failed on auth, leaving a release with no artifact that even 422-rejected + * its own checksums upload. Polls `isLive` (registry propagation lags a few + * seconds behind a publish), fails LOUD and returns false when the version + * never turns up. `sleepFn` is injectable for tests. + */ +export async function requireRegistryLive(config: { + attempts?: number | undefined + delayMs?: number | undefined + isLive: () => Promise + registry: string + sleepFn?: ((ms: number) => Promise) | undefined + subject: string +}): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const attempts = cfg.attempts ?? 6 + const delayMs = cfg.delayMs ?? 5000 + const sleepFn = cfg.sleepFn ?? sleep + for (let i = 0; i < attempts; i += 1) { + // eslint-disable-next-line no-await-in-loop + if (await cfg.isLive()) { + return true + } + if (i < attempts - 1) { + logger.log( + `${cfg.subject} not yet resolvable on ${cfg.registry} ` + + `(attempt ${i + 1}/${attempts}); retrying in ${delayMs / 1000}s…`, + ) + // eslint-disable-next-line no-await-in-loop + await sleepFn(delayMs) + } + } + logger.fail( + `Refusing to cut the tag + GitHub release: ${cfg.subject} is not ` + + `resolvable on ${cfg.registry} after ${attempts} attempts.\n` + + ` The immutable release is the FINAL marker of a release — it can only ` + + `follow a live registry publish, never precede one.\n` + + ` Fix: confirm the publish actually completed (auth? staged-but-never-` + + `approved?), then re-run — the release step is idempotent.`, + ) + return false +} + +/** + * The shared post-publish tail every channel funnels through: gate on + * registry liveness (requireRegistryLive), then — and only then — create the + * git tag + immutable GitHub release. + * + * Returns false, after failing loud, on EITHER half: the version never turned + * up, or the tag/release leg itself failed. The second half is the dangerous + * one — the registry write already landed and cannot be undone, so a + * `false`/throwing tag step gets the full four-part release-gap message naming + * the reconcile command, never a bare exit code. A thrown error inside the + * release leg is caught and reported the same way: the caller must never see a + * publish tail die silently mid-window. + * + * `ensureRelease` and `sleepFn` are injectable for tests; an injected seam + * declared `Promise` keeps its old meaning (only an explicit `false` + * counts as a failure). + */ +export async function releaseBehindLiveGate(config: { + attempts?: number | undefined + delayMs?: number | undefined + ensureRelease?: + | (( + pkg: { name: string; version: string }, + options?: + | { packAssets?: (() => Promise) | undefined } + | undefined, + ) => Promise) + | undefined + isLive: () => Promise + packAssets?: (() => Promise) | undefined + pkg: { name: string; version: string } + registry: string + sleepFn?: ((ms: number) => Promise) | undefined +}): Promise { + const cfg = { __proto__: null, ...config } as typeof config + const live = await requireRegistryLive({ + attempts: cfg.attempts, + delayMs: cfg.delayMs, + isLive: cfg.isLive, + registry: cfg.registry, + sleepFn: cfg.sleepFn, + subject: `${cfg.pkg.name}@${cfg.pkg.version}`, + }) + if (!live) { + return false + } + const ensureRelease = cfg.ensureRelease ?? ensureTagAndRelease + let saw: string | undefined + try { + const ensured = await ensureRelease( + cfg.pkg, + cfg.packAssets ? { packAssets: cfg.packAssets } : undefined, + ) + if (ensured === false) { + saw = 'the tag + GitHub release step reported failure (details above)' + } + } catch (e) { + saw = `the tag + GitHub release step threw: ${errorMessage(e)}` + } + if (saw === undefined) { + return true + } + logger.fail( + `Release gap after a successful ${cfg.registry} publish.\n` + + formatReleaseGapFailure({ + name: cfg.pkg.name, + registry: cfg.registry, + saw, + version: cfg.pkg.version, + where: + 'releaseBehindLiveGate (publish-infra/release.mts), post-publish tail', + }), + ) + return false +} + +/** + * Default (npm) release-asset packer: `pnpm pack` the tarball in this same run + * the bytes the registry received + write a `checksums.txt` (sha1 + sha256 + + * sha512), returning both paths. The sha256 line is the one a Homebrew formula + * bump reads: brew-publish.mts derives every formula sha256 from the release's + * own checksum manifest and never re-hashes an asset itself, so a release cut + * without a sha256 line is a release no formula can be bumped against. Returns + * an empty array, with a warning, when the pack fails, so the release still + * lands without assets. Extracted so `ensureTagAndRelease` can accept an + * alternate packer without changing the npm behavior. + */ +async function defaultPackAssets(pkg: { + name: string + version: string +}): Promise { + const subject = resolveReleaseSubject(rootPath) + // Prune repo-only lifecycle scripts for this pack too — the release-asset + // tarball must stay installable, same as the registry-bound packs. + const packed = await withPrunedPackManifest(subject.dir, () => + runCapture('pnpm', ['pack'], rootPath), + ) + const tarballName = `${pkg.name.replace(/^@/, '').replace('/', '-')}-${pkg.version}.tgz` + // pnpm pack writes into the publish subject's directory when + // publishConfig.directory redirects the publish; for a plain repo packDir + // IS the root. + const tarballPath = path.join(subject.packDir, tarballName) + if (packed.code !== 0 || !existsSync(tarballPath)) { + logger.warn(`pnpm pack failed (${packed.code}); releasing without assets.`) + return [] + } + const bytes = readFileSync(tarballPath) + const checksumsPath = path.join(rootPath, 'checksums.txt') + writeThroughMirrorLock( + checksumsPath, + formatReleaseChecksums(tarballName, bytes), + ) + const sha1 = crypto.createHash('sha1').update(bytes).digest('hex') + logger.log(`Tarball sha1 ${sha1} (compare with the npm staged shasum).`) + return [tarballPath, checksumsPath] +} + +/** + * The three checksum lines an asset contributes to `checksums.txt`: sha1 + * (compare with npm's staged shasum), sha256 (the line a Homebrew formula + * bump reads — `parseChecksumsTxt` accepts this grammar), and sha512-base64 + * (npm integrity comparisons). Pure — exported for tests. + */ +export function formatReleaseChecksums( + assetName: string, + bytes: Buffer, +): string { + const sha1 = crypto.createHash('sha1').update(bytes).digest('hex') + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex') + const sha512 = crypto.createHash('sha512').update(bytes).digest('base64') + return ( + `sha1: ${sha1} ${assetName}\n` + + `sha256: ${sha256} ${assetName}\n` + + `sha512-base64: ${sha512} ${assetName}\n` + ) +} + +/** + * Post-publish: make the git tag + GitHub release exist for this version. + * Tag-if-missing, push tolerated when the remote already has it; the release + * body is the version's CHANGELOG section; the release ships IMMUTABLE via the + * 3-step draft → upload → undraft flow. Assets are the tarball packed from this + * same tree in this same run — the identical bytes the registry just received — + * plus a checksums file (sha1 + sha256 + sha512), so the GitHub-release shasum + * is directly comparable to the npm staged/published shasum. + * + * Returns TRUE only when the tag exists ON ORIGIN and the GitHub release is + * published, or already existed. Every failure path returns FALSE and sets a + * non-zero exit code, so the caller (releaseBehindLiveGate) can raise the + * four-part release-gap message: the registry write has already succeeded, so + * a quiet `void` here is exactly how a half-done release escapes unnoticed. + * + * `options.packAssets` generalizes the release asset packing off npm: when + * provided it is called to produce the asset file paths (the cargo tier passes + * a packer that returns `[cratePath, checksumsPath]`); when omitted the exact + * `pnpm pack` behavior is kept, so the npm path is unchanged. + */ +export async function ensureTagAndRelease( + pkg: { + name: string + version: string + }, + options?: + | { + packAssets?: (() => Promise) | undefined + } + | undefined, +): Promise { + const opts = { __proto__: null, ...options } as { + packAssets?: (() => Promise) | undefined + } + const tagName = `v${pkg.version}` + const tagCheck = await runCapture( + 'git', + ['rev-parse', '-q', '--verify', `refs/tags/${tagName}`], + rootPath, + ) + if (tagCheck.code !== 0) { + const created = await runCapture('git', ['tag', tagName], rootPath) + if (created.code !== 0) { + logger.fail(`could not create tag ${tagName}`) + process.exitCode = 1 + return false + } + logger.log(`Created tag ${tagName}.`) + } + // A non-zero push is tolerated ONLY when the remote already carries the tag + // (a parallel/earlier push). Any other push failure is fatal here: an + // unpushed tag leaves no public marker at all, and `gh release create + // --verify-tag` would fail downstream with the push error already scrolled + // away. This is the exact shape that left a promoted version tagless. + const pushed = await runCapture('git', ['push', 'origin', tagName], rootPath) + if (pushed.code !== 0) { + const remote = await runCapture( + 'git', + ['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`], + rootPath, + ) + if (remote.code !== 0 || !remote.stdout.includes(`refs/tags/${tagName}`)) { + // CI checkouts run `persist-credentials: false`, so the plain git push + // above has no credential and exits 128 — retry over the GitHub API + // with the App token the branch-based bump already holds. This is the + // exact shape that stranded a published crate version tagless while + // its release branch was already gone. + const apiRepo = process.env['GITHUB_REPOSITORY'] + const apiToken = + process.env['RELEASE_APP_TOKEN'] || process.env['GH_TOKEN'] || '' + const tagSha = await runCapture( + 'git', + ['rev-parse', `refs/tags/${tagName}`], + rootPath, + ) + if (!apiRepo || !apiToken || tagSha.code !== 0) { + logger.fail( + `could not push tag ${tagName} to origin (git push exited ${pushed.code}) ` + + `and origin does not carry it.\n` + + ` Wanted: refs/tags/${tagName} on origin before the GitHub release is cut.\n` + + ` Fix: resolve the push (auth? protected ref? network?) — in CI, set ` + + `GITHUB_REPOSITORY and an App token env so the GitHub API route can ` + + `create the tag — and re-run the reconcile below.`, + ) + process.exitCode = 1 + return false + } + await createTagRef({ + repo: apiRepo, + sha: tagSha.stdout.trim(), + tag: tagName, + token: apiToken, + }) + logger.log(`Created tag ${tagName} on origin via the GitHub API.`) + } else { + logger.log(`Tag ${tagName} already on origin; continuing.`) + } + } + + const view = await runCapture( + 'gh', + ['release', 'view', tagName, '--json', 'tagName'], + rootPath, + ) + if (view.code === 0) { + logger.log(`Release ${tagName} already exists; leaving it untouched.`) + return true + } + + const notesFile = path.join(os.tmpdir(), `release-notes-${pkg.version}.md`) + writeFileSync(notesFile, extractChangelogSection(pkg.version)) + + // Pack the release assets: a caller-supplied packer (the cargo tier's + // `.crate` + checksums) when provided, else the default `pnpm pack` (npm). + const assets = opts.packAssets + ? await opts.packAssets() + : await defaultPackAssets(pkg) + + // Immutable-release pattern: create as draft, upload assets, then undraft. + // A single-call create would race the Sigstore attestation. + try { + const create = await runCapture( + 'gh', + [ + 'release', + 'create', + tagName, + '--draft', + '--verify-tag', + '--title', + tagName, + '--notes-file', + notesFile, + ], + rootPath, + ) + if (create.code !== 0) { + logger.fail(`gh release create failed (${create.code})`) + process.exitCode = 1 + return false + } + if (assets.length) { + const upload = await runCapture( + 'gh', + ['release', 'upload', tagName, ...assets], + rootPath, + ) + if (upload.code !== 0) { + logger.fail(`gh release upload failed (${upload.code})`) + process.exitCode = 1 + return false + } + } + const undraft = await runCapture( + 'gh', + ['release', 'edit', tagName, '--draft=false'], + rootPath, + ) + if (undraft.code !== 0) { + logger.fail(`gh release edit --draft=false failed (${undraft.code})`) + process.exitCode = 1 + return false + } + logger.success(`Release ${tagName} published from the CHANGELOG entry.`) + return true + } finally { + // The checksums file is written into the repo tree solely so `gh release + // upload` can attach it — remove it once the upload path is done (success + // OR failure) so it never lingers as untracked residue. + for (let i = 0, { length } = assets; i < length; i += 1) { + const asset = assets[i]! + if (path.basename(asset) === 'checksums.txt') { + safeDeleteSync(asset) + } + } + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/shared.mts b/release-kit/payload/scripts/socket-release/publish-infra/shared.mts new file mode 100644 index 00000000..4ab8eb25 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/shared.mts @@ -0,0 +1,350 @@ +/** + * @file Registry-agnostic publish helpers: interactive + capturing process + * spawns, git introspection, first-JSON extraction from noisy CLI output, + * and the logger/root-path setup shared by every publish-infra module. A + * future cargo-publish flow reuses this tier verbatim; registry-specific + * helpers live in the per-registry subfolders (`npm/`). + */ + +import { fstatSync, readFileSync } from 'node:fs' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +// oxlint-disable-next-line socket/prefer-async-spawn -- streaming +// stdio required to forward `pnpm stage approve` 2FA prompts + +// `gh release create` upload progress. lib/spawn returns a Promise +// that resolves only on exit; here we need the live ChildProcess +// stream. +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { REPO_ROOT } from '../paths.mts' + +export const logger = getDefaultLogger() +export const rootPath = REPO_ROOT + +const WIN32 = process.platform === 'win32' + +/** + * The staged-to-approve handoff block, printed ONCE when a staging run + * finishes. Two lines: the copy-pasteable command, anchored with `cd` at the + * absolute repo it must run from (a staged package promoted from the wrong + * checkout releases the wrong project), and one sentence naming what that + * command owns so nobody re-derives it from the source. Pure — every staging + * path shares this shape and a test asserts the text. + */ +export function formatApproveHandoff( + approveCommand: string, + ownership: string, + repoPath: string = rootPath, +): string[] { + return [`Next: cd ${repoPath} && ${approveCommand}`, ownership] +} + +/** + * Print `formatApproveHandoff`'s block through the publish logger. + */ +export function logApproveHandoff( + approveCommand: string, + ownership: string, + repoPath: string = rootPath, +): void { + const lines = formatApproveHandoff(approveCommand, ownership, repoPath) + for (let i = 0, { length } = lines; i < length; i += 1) { + logger.log(lines[i]!) + } +} + +/** + * Spawn a command and forward stdio (interactive). Returns the exit code. Used + * when the user needs to see / interact with the live output stream + * (publish/approve prompts, gh upload progress). + */ +export function runInherit( + cmd: string, + args: string[], + cwd: string, + env?: NodeJS.ProcessEnv | undefined, +): Promise { + return new Promise((resolve, reject) => { + const childPromise = spawn(cmd, args, { + cwd, + // Only override when the caller supplies one; absent = inherit. + ...(env ? { env: { ...process.env, ...env } } : {}), + shell: WIN32, + stdio: 'inherit', + }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit. We resolve with the exit code below, so swallow the + // rejection (same treatment as runCapture) — otherwise a non-zero child + // resolves the code here AND kills the process moments later with an + // unhandled rejection. + void childPromise.catch(() => undefined) + const child = childPromise.process + child.on('error', reject) + child.on('exit', code => { + resolve(code ?? 0) + }) + }) +} + +/** + * Like runInherit, but guarantees the child sees a TTY. pnpm's registry + * web-OTP challenge refuses non-interactive stdio + * (ERR_PNPM_OTP_NON_INTERACTIVE) instead of opening the browser, so + * agent-driven `pnpm stage approve` / `reject` calls wrap the command in + * `script(1)`'s pseudo-terminal. Passthrough when stdio is already a TTY, and + * on Windows (no script(1) there — Windows runs stay interactive-only). + */ +export function buildPtyInvocation( + platform: NodeJS.Platform, + cmd: string, + args: readonly string[], +): { args: string[]; command: string } | undefined { + if (platform === 'win32') { + return undefined + } + if (platform === 'darwin') { + // BSD script: `script -q /dev/null ` runs cmd directly. + return { args: ['-q', '/dev/null', cmd, ...args], command: 'script' } + } + // util-linux script: the command goes through `-c` as a single shell + // string — single-quote each arg (POSIX '\'' escape for embedded quotes). + const quoted = [cmd, ...args] + .map(a => `'${a.replace(/'/g, `'\\''`)}'`) + .join(' ') + return { args: ['-qec', quoted, '/dev/null'], command: 'script' } +} + +// A PTY makes the child believe a human is watching, which is what keeps npm's +// browser web-OTP alive — but it also re-enables every spinner and redraw the +// child suppresses when piped. The Socket scan gate's progress display wrote +// 2.6 GB of frames into a captured PTY in ten minutes. +// +// Two obvious knobs are wrong here, both learned the hard way: +// - `CI=1` — pnpm reads it as "no human here" and refuses the web-OTP +// challenge, killing the interactivity the PTY exists to preserve. +// - `TERM=dumb` — under script(1) it drives `process.stdout.columns` to 0, +// and width-aware rendering dies on that before printing a line. +// NO_COLOR is the safe one: it strips the per-character truecolor escapes that +// made up the bulk of that 2.6 GB while leaving the terminal usable. +export const NON_INTERACTIVE_RENDER_ENV: NodeJS.ProcessEnv = { + NO_COLOR: '1', +} + +/** + * True when fd 1 is a regular FILE (a `> out.log` redirect, or an agent harness + * that captures a background task to disk) rather than a tty or a pipe. + * + * `script(1)` cannot drive a pseudo-terminal into a file-backed stdout: it + * prints `tcgetattr/ioctl: Operation not supported on socket` and the child + * exits 1 having produced NO output at all. That reads as "the command failed" + * when the command never ran, which is worth naming rather than debugging + * twice. + */ +export function stdoutIsFileBacked(): boolean { + try { + return fstatSync(1).isFile() + } catch { + return false + } +} + +export const PTY_FILE_STDOUT_MESSAGE = + 'stdout is a file — pumping the PTY through a pipe.\n' + + ' What: script(1) cannot allocate a pseudo-terminal onto a file-backed\n' + + ' stdout, so the wrapper gives the PTY child a PIPE and pumps its\n' + + ' output into the file itself. The browser web-OTP flow proceeds.\n' + + ' Where: the PTY wrapper used for npm/pnpm browser web-OTP prompts.' + +/** + * The pipe-pump form of the PTY run: script(1) is happy writing to a pipe, + * and the pump writes those bytes on to the file-backed stdout. This is how a + * `> file` redirect or an agent harness capture still gets a working web-OTP + * flow instead of a refusal — the 2026-07-29 odai approve hit exactly that. + * Uses lib-stable spawn's enriched promise: `.process` exposes the child's + * piped streams, so no raw child_process import is needed. + */ +export function runPtyPumped( + pty: { command: string; args: readonly string[] }, + cwd: string, + env?: NodeJS.ProcessEnv | undefined, +): Promise { + return new Promise((resolve, reject) => { + const childPromise = spawn(pty.command, [...pty.args], { + cwd, + ...(env ? { env: { ...process.env, ...env } } : {}), + stdio: ['inherit', 'pipe', 'pipe'], + }) + // Same treatment as runInherit: the exit code resolves below, so the + // enriched promise's non-zero rejection must be swallowed. + void childPromise.catch(() => undefined) + const child = childPromise.process + child.stdout?.on('data', (chunk: Buffer) => process.stdout.write(chunk)) + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk)) + child.on('error', reject) + child.on('exit', code => { + resolve(code ?? 0) + }) + }) +} + +export function runInheritTty( + cmd: string, + args: string[], + cwd: string, + env?: NodeJS.ProcessEnv | undefined, +): Promise { + if (process.stdin.isTTY || WIN32) { + return runInherit(cmd, args, cwd, env) + } + const pty = buildPtyInvocation(process.platform, cmd, args) + if (!pty) { + return runInherit(cmd, args, cwd, env) + } + if (stdoutIsFileBacked()) { + logger.log(`[pty] ${PTY_FILE_STDOUT_MESSAGE}`) + return runPtyPumped(pty, cwd, env) + } + return runInherit(pty.command, pty.args, cwd, env) +} + +/** + * Spawn a command and capture stdout. Stderr goes to the parent process's + * stderr so error messages stay visible. Returns the collected stdout + exit + * code. Used for one-shot queries (git, npm view, pnpm stage list --json). + */ +export function runCapture( + cmd: string, + args: string[], + cwd: string, +): Promise<{ stdout: string; code: number }> { + return new Promise(resolve => { + const childPromise = spawn(cmd, args, { + cwd, + shell: WIN32, + stdio: ['ignore', 'pipe', 'inherit'], + }) + // v6 lib-stable spawn returns an enriched Promise that rejects on + // non-zero exit. We resolve on exit-code below regardless, so swallow + // the Promise rejection to avoid a process-killing unhandled rejection + // when the spawned binary exits non-zero (e.g. `npm view ` + // returning 404 → exit 1, which is the documented signal for + // `isAlreadyPublished` to return false). + void childPromise.catch(() => undefined) + const child = childPromise.process + let stdout = '' + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + }) + child.on('error', (e: Error) => { + process.stderr.write(`spawn ${cmd} failed: ${e.message}\n`) + resolve({ stdout, code: 127 }) + }) + child.on('exit', code => { + resolve({ stdout, code: code ?? 0 }) + }) + }) +} + +/** + * Resolve `git rev-parse --short HEAD`. Returns the literal string `unknown` + * when git fails (detached worktree, missing git, etc.) — callers that need a + * guaranteed-valid SHA should check for that. + */ +export async function gitShortSha(cwd: string): Promise { + const { stdout, code } = await runCapture( + 'git', + ['rev-parse', '--short', 'HEAD'], + cwd, + ) + if (code !== 0) { + return 'unknown' + } + return stdout.trim() +} + +/** + * Extract the first balanced top-level `{ … }` JSON object from a + * possibly-noisy stdout stream (pnpm wraps JSON output in progress lines that + * aren't valid JSON themselves). Returns undefined if no balanced object + * found. + * + * Used by npm-publish.mts to parse `pnpm stage list --json`. + */ +export function extractFirstJson(text: string): string | undefined { + const startIdx = text.indexOf('{') + if (startIdx === -1) { + return undefined + } + let depth = 0 + let inString = false + let escape = false + for (let i = startIdx, { length } = text; i < length; i += 1) { + const ch = text[i]! + if (escape) { + escape = false + continue + } + if (ch === '\\') { + escape = true + continue + } + if (ch === '"') { + inString = !inString + continue + } + if (inString) { + continue + } + if (ch === '{') { + depth += 1 + } else if (ch === '}') { + depth -= 1 + if (depth === 0) { + return text.slice(startIdx, i + 1) + } + } + } + return undefined +} + +/** + * Whether this CI run may request npm provenance. The sigstore bundle is + * verifiable only when the source repository is PUBLIC — npm rejects a + * private-repo attestation with `E422 … Unsupported GitHub Actions source + * repository visibility: "private"`. Reads the Actions event payload + * (`repository.private` / `repository.visibility`); outside Actions, or when + * the payload is unreadable, provenance stays OFF (fail-closed: a wrong + * `--provenance` hard-fails the upload, a missing one only skips the + * attestation). Logs the skip loudly so a private repo going public flips + * provenance back on with zero config. + */ +export function provenanceAllowed(): boolean { + if (process.env['GITHUB_ACTIONS'] !== 'true') { + return false + } + const eventPath = process.env['GITHUB_EVENT_PATH'] + if (!eventPath) { + return false + } + try { + const event = JSON.parse(readFileSync(eventPath, 'utf8')) as { + repository?: + | { + private?: boolean | undefined + visibility?: string | undefined + } + | undefined + } + const repo = event.repository + if (!repo) { + return false + } + if (repo.visibility !== undefined) { + return repo.visibility === 'public' + } + return repo.private === false + } catch { + return false + } +} diff --git a/release-kit/payload/scripts/socket-release/publish-infra/socket-oauth.mts b/release-kit/payload/scripts/socket-release/publish-infra/socket-oauth.mts new file mode 100644 index 00000000..9c76f7da --- /dev/null +++ b/release-kit/payload/scripts/socket-release/publish-infra/socket-oauth.mts @@ -0,0 +1,357 @@ +/* + * @file Client-side OAuth for acquiring a Socket API token without hand- + * copying a key: RFC 8414 issuer discovery → authorization-code + PKCE + * (RFC 7636) → loopback redirect (RFC 8252) → token exchange. The browser + * opens on the operator's screen, they approve, and the access token comes + * back over 127.0.0.1 — no dashboard scavenger hunt, no paste. + * + * ACTIVATION. The flow needs two facts only the deployment knows: the + * issuer (`SOCKET_OAUTH_ISSUER`, same variable socket-mcp's resource server + * reads) and a registered public CLI client id + * (`SOCKET_OAUTH_CLI_CLIENT_ID`) whose registration permits loopback + * redirect URIs. With either unset, `socketOAuthConfigured()` is false and + * callers keep their existing acquisition path — a disabled seam, never a + * silent failure. + * + * SECURITY. PKCE S256 binds the code to this process; a random `state` + * binds the loopback callback to this request; the listener binds to + * 127.0.0.1 on an ephemeral port and accepts exactly one callback; the + * issuer must be https, and a loopback issuer is refused; the token is + * returned to the caller and never written to disk or stdout. + */ + +import crypto from 'node:crypto' +import { createServer } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' +import process from 'node:process' + +import { spawn } from '@socketsecurity/lib/process/spawn/child' + +import { logger } from './shared.mts' + +export const SOCKET_OAUTH_ISSUER_ENV_VAR = 'SOCKET_OAUTH_ISSUER' +export const SOCKET_OAUTH_CLI_CLIENT_ID_ENV_VAR = 'SOCKET_OAUTH_CLI_CLIENT_ID' + +// The scopes the publish scan gate needs on the resulting token. +export const SOCKET_SCAN_SCOPES: readonly string[] = ['full-scans', 'report'] + +// How long the loopback listener waits for the operator to approve in the +// browser before the flow fails loud. +export const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 + +export interface SocketOAuthSettings { + clientId: string + issuer: string +} + +export interface AuthServerMetadata { + authorizationEndpoint: string + tokenEndpoint: string +} + +export interface PkcePair { + challenge: string + verifier: string +} + +/** + * The issuer + client id from the environment, or undefined when the flow is + * not configured for this deployment. + */ +export function resolveSocketOAuthSettings( + env: NodeJS.ProcessEnv = process.env, +): SocketOAuthSettings | undefined { + const issuer = env[SOCKET_OAUTH_ISSUER_ENV_VAR] + const clientId = env[SOCKET_OAUTH_CLI_CLIENT_ID_ENV_VAR] + if (!issuer || !clientId) { + return undefined + } + return { clientId, issuer } +} + +/** + * True when the environment carries everything the OAuth flow needs. + */ +export function socketOAuthConfigured( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return resolveSocketOAuthSettings(env) !== undefined +} + +/** + * A fresh PKCE verifier/challenge pair (S256, RFC 7636 §4). + */ +export function generatePkcePair(): PkcePair { + const verifier = crypto.randomBytes(32).toString('base64url') + const challenge = crypto + .createHash('sha256') + .update(verifier) + .digest('base64url') + return { challenge, verifier } +} + +/** + * The RFC 8414 well-known URL for an issuer, honoring a path component + * (path-inserted form, same probe order socket-mcp's discovery uses first). + */ +export function buildDiscoveryUrl(issuer: string): string { + const url = new URL(issuer) + const path = url.pathname.replace(/\/$/, '') + return `${url.origin}/.well-known/oauth-authorization-server${path}` +} + +/** + * Fetch and validate the issuer's authorization-server metadata. The issuer + * must be https on a non-loopback host — this flow exists to talk to a real + * authorization server, and refusing loopback keeps a poisoned env variable + * from redirecting the browser to a local listener. + */ +export async function discoverAuthServer( + issuer: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const url = new URL(issuer) + if ( + url.protocol !== 'https:' || + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '::1' + ) { + throw new Error( + 'socket-oauth: refusing a non-https or loopback issuer.\n' + + ` Where: ${SOCKET_OAUTH_ISSUER_ENV_VAR}\n` + + ` Saw: ${issuer}; wanted an https URL on a public host.\n` + + ' Fix: point the variable at the real Socket authorization server.', + ) + } + const discoveryUrl = buildDiscoveryUrl(issuer) + const response = await fetchImpl(discoveryUrl) + if (!response.ok) { + throw new Error( + 'socket-oauth: issuer metadata fetch failed.\n' + + ` Where: GET ${discoveryUrl}\n` + + ` Saw: HTTP ${response.status}; wanted 200 with RFC 8414 metadata.\n` + + ' Fix: verify the issuer URL and that the server publishes ' + + '/.well-known/oauth-authorization-server.', + ) + } + const metadata = (await response.json()) as { + authorization_endpoint?: string | undefined + issuer?: string | undefined + token_endpoint?: string | undefined + } + if (metadata.issuer !== issuer) { + throw new Error( + 'socket-oauth: issuer mismatch in metadata.\n' + + ` Where: ${discoveryUrl}\n` + + ` Saw: issuer ${String(metadata.issuer)}; wanted ${issuer} byte for byte (RFC 8414 §3.3).\n` + + ' Fix: set the variable to the exact issuer the server publishes.', + ) + } + if (!metadata.authorization_endpoint || !metadata.token_endpoint) { + throw new Error( + 'socket-oauth: metadata is missing required endpoints.\n' + + ` Where: ${discoveryUrl}\n` + + ' Saw: no authorization_endpoint or token_endpoint; wanted both.\n' + + ' Fix: the authorization server must publish both (RFC 8414 §2).', + ) + } + return { + authorizationEndpoint: metadata.authorization_endpoint, + tokenEndpoint: metadata.token_endpoint, + } +} + +/** + * The full authorization URL the browser opens. + */ +export function buildAuthorizationUrl(config: { + authorizationEndpoint: string + challenge: string + clientId: string + redirectUri: string + scopes: readonly string[] + state: string +}): string { + const url = new URL(config.authorizationEndpoint) + url.searchParams.set('client_id', config.clientId) + url.searchParams.set('code_challenge', config.challenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('redirect_uri', config.redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', config.scopes.join(' ')) + url.searchParams.set('state', config.state) + return url.href +} + +/** + * Parse the loopback callback request: the authorization code when state + * matches, or an error describing what came back instead. + */ +export function parseCallbackRequest( + requestUrl: string, + expectedState: string, +): { code: string } | { error: string } { + const url = new URL(requestUrl, 'http://127.0.0.1') + const err = url.searchParams.get('error') + if (err) { + const description = url.searchParams.get('error_description') + return { error: description ? `${err}: ${description}` : err } + } + if (url.searchParams.get('state') !== expectedState) { + return { error: 'state mismatch — callback not initiated by this run' } + } + const code = url.searchParams.get('code') + if (!code) { + return { error: 'callback carried no authorization code' } + } + return { code } +} + +/** + * Run the full flow: discover, listen, open the browser, exchange the code. + * Resolves with the access token; throws loud on every failure path. + */ +export async function acquireSocketTokenViaOAuth( + options?: + | { + env?: NodeJS.ProcessEnv | undefined + fetchImpl?: typeof fetch | undefined + openUrl?: ((url: string) => void) | undefined + scopes?: readonly string[] | undefined + } + | undefined, +): Promise { + const opts = { __proto__: null, ...options } as NonNullable + const env = opts.env ?? process.env + const fetchImpl = opts.fetchImpl ?? fetch + const scopes = opts.scopes ?? SOCKET_SCAN_SCOPES + const settings = resolveSocketOAuthSettings(env) + if (!settings) { + throw new Error( + 'socket-oauth: flow is not configured.\n' + + ` Where: env\n` + + ` Saw: ${SOCKET_OAUTH_ISSUER_ENV_VAR} or ${SOCKET_OAUTH_CLI_CLIENT_ID_ENV_VAR} unset; wanted both.\n` + + ' Fix: export both, or use the token-paste path.', + ) + } + const metadata = await discoverAuthServer(settings.issuer, fetchImpl) + const pkce = generatePkcePair() + const state = crypto.randomBytes(16).toString('base64url') + + const { code, redirectUri } = await new Promise<{ + code: string + redirectUri: string + }>((resolve, reject) => { + const timer = setTimeout(() => { + server.close() + reject( + new Error( + 'socket-oauth: timed out waiting for the browser approval.\n' + + ' Where: loopback callback listener\n' + + ` Saw: no callback within ${CALLBACK_TIMEOUT_MS / 60_000} minutes; wanted one redirect.\n` + + ' Fix: re-run and complete the approval in the opened browser tab.', + ), + ) + }, CALLBACK_TIMEOUT_MS) + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const parsed = parseCallbackRequest(req.url ?? '/', state) + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end( + 'error' in parsed + ? '

Authentication failed — return to the terminal.

' + : '

Authenticated — you can close this tab.

', + ) + clearTimeout(timer) + server.close() + if ('error' in parsed) { + reject( + new Error( + 'socket-oauth: authorization callback failed.\n' + + ' Where: loopback redirect\n' + + ` Saw: ${parsed.error}; wanted an authorization code.\n` + + ' Fix: re-run and approve the request in the browser.', + ), + ) + return + } + resolve({ code: parsed.code, redirectUri: boundRedirectUri }) + }) + let boundRedirectUri = '' + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + clearTimeout(timer) + server.close() + reject(new Error('socket-oauth: loopback listener failed to bind.')) + return + } + boundRedirectUri = `http://127.0.0.1:${address.port}/callback` + const authUrl = buildAuthorizationUrl({ + authorizationEndpoint: metadata.authorizationEndpoint, + challenge: pkce.challenge, + clientId: settings.clientId, + redirectUri: boundRedirectUri, + scopes, + state, + }) + logger.log( + 'Socket OAuth: opening the browser to authorize the scan-gate token…', + ) + const openUrl = opts.openUrl ?? defaultOpenUrl + openUrl(authUrl) + }) + }) + + const tokenResponse = await fetchImpl(metadata.tokenEndpoint, { + body: new URLSearchParams({ + client_id: settings.clientId, + code, + code_verifier: pkce.verifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }) + if (!tokenResponse.ok) { + throw new Error( + 'socket-oauth: token exchange failed.\n' + + ` Where: POST ${metadata.tokenEndpoint}\n` + + ` Saw: HTTP ${tokenResponse.status}; wanted 200 with an access_token.\n` + + ' Fix: verify the client registration allows loopback redirects and the authorization-code grant.', + ) + } + const body = (await tokenResponse.json()) as { + access_token?: string | undefined + } + if (!body.access_token) { + throw new Error( + 'socket-oauth: token response carried no access_token.\n' + + ` Where: POST ${metadata.tokenEndpoint}\n` + + ' Saw: a 200 without access_token; wanted one.\n' + + ' Fix: verify the authorization server issues bearer access tokens on this grant.', + ) + } + return body.access_token +} + +// Fire-and-forget platform browser opener; a failure is non-fatal because the +// flow's failure mode is the callback timeout, which names the fix. +function defaultOpenUrl(url: string): void { + const win32 = process.platform === 'win32' + const opener = + process.platform === 'darwin' ? 'open' : win32 ? 'start' : 'xdg-open' + try { + const child = spawn(opener, [url], { + detached: true, + shell: win32, + stdio: 'ignore', + }) + child.catch(() => { + // Non-fatal: the callback timeout names the fix. + }) + } catch { + // Non-fatal: the callback timeout names the fix. + } +} diff --git a/release-kit/payload/scripts/socket-release/registry-liveness-gate.d.mts b/release-kit/payload/scripts/socket-release/registry-liveness-gate.d.mts new file mode 100644 index 00000000..3be4914a --- /dev/null +++ b/release-kit/payload/scripts/socket-release/registry-liveness-gate.d.mts @@ -0,0 +1,82 @@ +/* + * @file Hand-authored declarations for registry-liveness-gate.mjs — the gate + * stays plain .mjs because github-release.yml runs it on the runner's + * system Node before any install exists, so the typed test surface is + * declared here. + */ + +export interface FsLike { + existsSync(path: string): boolean + globSync( + pattern: string, + options?: { cwd?: string | undefined } | undefined, + ): string[] + readFileSync(path: string, encoding: string): string +} + +export type GatePlan = + | { name: string; registry: 'npm' } + | { names: string[]; registry: 'crates' } + | { registry: 'none' } + +export interface FetchLike { + ( + url: string, + init?: { headers?: Record | undefined } | undefined, + ): Promise<{ ok: boolean; text(): Promise }> +} + +export declare function versionFromTag(tag: string): string + +export declare function deriveCrateNames( + rootDir: string, + fsLike?: FsLike | undefined, +): string[] + +export declare function planGate( + rootDir: string, + fsLike?: FsLike | undefined, +): GatePlan + +export declare function crateIndexPath(name: string): string + +export declare function indexHasVersion( + indexBody: string, + version: string, +): boolean + +export declare const NO_CACHE_HEADERS: { + 'cache-control': string + pragma: string +} + +export declare function cacheBustedNpmUrl( + url: string, + nonce?: string | undefined, +): string + +export declare function checkNpmLive( + name: string, + version: string, + fetchImpl?: FetchLike | undefined, + logError?: ((message: string) => void) | undefined, +): Promise + +export declare function checkCrateLive( + name: string, + version: string, + fetchImpl?: FetchLike | undefined, + logError?: ((message: string) => void) | undefined, +): Promise + +export declare function runGate( + options?: + | { + fetchImpl?: FetchLike | undefined + log?: ((message: string) => void) | undefined + logError?: ((message: string) => void) | undefined + rootDir?: string | undefined + tag?: string | undefined + } + | undefined, +): Promise diff --git a/release-kit/payload/scripts/socket-release/registry-liveness-gate.mjs b/release-kit/payload/scripts/socket-release/registry-liveness-gate.mjs new file mode 100644 index 00000000..667a6095 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/registry-liveness-gate.mjs @@ -0,0 +1,316 @@ +/** + * @file Registry-liveness gate for the fleet github-release.yml workflow. + * ORDER RULE: the tag + immutable GH release are the FINAL markers of a + * release — they may only exist AFTER the registry publish is live. A STAGED + * npm package is not published, staging may never be approved, so the + * workflow refuses to cut when the tagged version is not resolvable on its + * registry. Registry-less repos skip the gate. + * Branch shape, unchanged from the inline `run:` block this was extracted + * from — the v1.0.13 bundle shipped a regressed single-crate-only gate + * precisely because this logic lived untestable inside workflow YAML: + * + * - public package.json → the npm packument must resolve for the version. + * - Cargo.toml → every publishable crate name must be in the crates.io sparse + * index at the version. Single crate: the root [package] name. Workspace: + * every member's name, `publish = false` members skipped, `members = [...]` + * globs expanded. Empty output + exit 0 = nothing publishable, a stub-only + * workspace; a malformed manifest fails LOUD instead of dying silently + * under the step's `set -e`. + * - neither → skip, a github-release-only repo. Dependency-free on purpose: + * github-release.yml runs it on the runner's system Node BEFORE any install + * exists, so only `node:` builtins are used. Node fetch stands in for the + * old `curl -fsS`: same URLs, same pass/fail mapping. Pure decision + * functions are exported for unit tests; the thin CLI shell at the bottom + * reads TAG from the env and exits non-zero when the gate refuses. Usage: + * TAG=v1.2.3 node scripts/socket-release/registry-liveness-gate.mjs + */ + +import crypto from 'node:crypto' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +// The repo the gate inspects: two levels up from this script's own home at +// scripts/socket-release/, stable however the caller's cwd wanders. +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +) + +function loadGlobSync() { + const { globSync } = createRequire(import.meta.url)('node:fs') + if (typeof globSync !== 'function') { + throw new Error( + '× fs.globSync is unavailable on this Node (added in Node 22) — needed only to expand a crates.io workspace-member glob.\n' + + ' Fix: run the release-liveness gate on Node >=22, or list the workspace members without a glob.', + ) + } + return globSync +} + +const defaultFsLike = { + existsSync, + globSync: (pattern, options) => loadGlobSync()(pattern, options), + readFileSync, +} + +/** + * The version a release tag names — a single leading `v` stripped, the + * `${TAG#v}` the inline gate used. + */ +export function versionFromTag(tag) { + return tag.startsWith('v') ? tag.slice(1) : tag +} + +/** + * Every crate name the gate must find live on crates.io for the repo at + * `rootDir`. Single crate: the root [package] name. Workspace: every + * publishable member's name — `publish = false` members skipped, glob members + * expanded, memberless dirs ignored. Throws with a Fix-bearing message when + * the root manifest has neither a [package] name nor a members list, so the + * gate fails loud instead of silently. + */ +export function deriveCrateNames(rootDir, fsLike = defaultFsLike) { + const root = fsLike.readFileSync(path.join(rootDir, 'Cargo.toml'), 'utf8') + const pkgName = /^\[package\][^]*?^name *= *"([^"]+)"/m.exec(root) + if (pkgName) { + return [pkgName[1]] + } + const members = /^members *= *\[([^\]]*)\]/m.exec(root) + if (!members) { + throw new Error( + '× Cargo.toml has neither a [package] name nor a [workspace] members list — cannot derive crate names for the registry-liveness gate.\n' + + ' Fix: give the root manifest a [package] section or a members = [...] list.', + ) + } + const entries = [...members[1].matchAll(/"([^"]+)"/g)].map(m => m[1]) + const dirs = entries.flatMap(e => + e.includes('*') ? fsLike.globSync(e, { cwd: rootDir }) : [e], + ) + const names = [] + for (let i = 0, { length } = dirs; i < length; i += 1) { + const manifestPath = path.join(rootDir, dirs[i], 'Cargo.toml') + if (!fsLike.existsSync(manifestPath)) { + continue + } + const manifest = fsLike.readFileSync(manifestPath, 'utf8') + if (/^publish *= *false/m.test(manifest)) { + continue + } + const name = /^name *= *"([^"]+)"/m.exec(manifest) + if (name) { + names.push(name[1]) + } + } + return names +} + +/** + * Which registry the repo at `rootDir` must be live on. Public package.json + * wins; a private package.json falls through to Cargo.toml, matching the + * inline gate's if/elif; neither manifest means no gate. May throw — a + * malformed manifest is a loud failure, never a silent skip. + */ +export function planGate(rootDir, fsLike = defaultFsLike) { + const pkgPath = path.join(rootDir, 'package.json') + if (fsLike.existsSync(pkgPath)) { + const manifest = JSON.parse(fsLike.readFileSync(pkgPath, 'utf8')) + if (manifest.private !== true) { + return { name: String(manifest.name), registry: 'npm' } + } + } + if (fsLike.existsSync(path.join(rootDir, 'Cargo.toml'))) { + return { names: deriveCrateNames(rootDir, fsLike), registry: 'crates' } + } + return { registry: 'none' } +} + +/** + * The crates.io sparse-index path for a crate name — the registry's + * length-sharded layout: `1/a`, `2/ab`, `3/a/abc`, `ab/cd/abcdef`. + */ +export function crateIndexPath(name) { + switch (name.length) { + case 1: + return `1/${name}` + case 2: + return `2/${name}` + case 3: + return `3/${name[0]}/${name}` + default: + return `${name.slice(0, 2)}/${name.slice(2, 4)}/${name}` + } +} + +/** + * True when a sparse-index body records the version — the extracted + * `grep -q "\"vers\":\"${VERSION}\""`. + */ +export function indexHasVersion(indexBody, version) { + return indexBody.includes(`"vers":"${version}"`) +} + +async function fetchOk(url, fetchImpl, logError, init) { + try { + return await fetchImpl(url, init) + } catch (error) { + // The old `curl -fsS` printed its transport error and failed the gate; + // map a thrown fetch the same way. + logError(`× ${url} — ${error}`) + return undefined + } +} + +// No-cache request headers for a release-liveness read. +// WHY: the npm registry CDN caches version reads for MINUTES; a liveness gate +// that trusts a cached read can see a version that is already LIVE as absent (a +// stale 404) and refuse to cut the release. The headers defeat any intermediary +// proxy; `cacheBustedNpmUrl` defeats the CDN cache key. +export const NO_CACHE_HEADERS = { + 'cache-control': 'no-cache', + pragma: 'no-cache', +} + +/** + * A liveness URL with a unique `_cb` nonce appended so a stale CDN copy can + * never answer. `nonce` is injectable so a test can assert the exact busting. + */ +export function cacheBustedNpmUrl(url, nonce = crypto.randomUUID()) { + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}_cb=${nonce}` +} + +/** + * True when `name@version` resolves on the npm registry. The read is cache- + * busted (unique nonce + no-cache headers) so a stale CDN packument cannot + * report a live version as absent. + */ +export async function checkNpmLive( + name, + version, + fetchImpl = fetch, + logError = console.error, +) { + const res = await fetchOk( + cacheBustedNpmUrl(`https://registry.npmjs.org/${name}/${version}`), + fetchImpl, + logError, + { headers: NO_CACHE_HEADERS }, + ) + return res !== undefined && res.ok +} + +/** + * True when `name@version` is recorded in the crates.io sparse index. + */ +export async function checkCrateLive( + name, + version, + fetchImpl = fetch, + logError = console.error, +) { + const res = await fetchOk( + `https://index.crates.io/${crateIndexPath(name)}`, + fetchImpl, + logError, + ) + if (res === undefined || !res.ok) { + return false + } + return indexHasVersion(await res.text(), version) +} + +/** + * The whole gate: plan from the manifests at `rootDir`, probe the registry + * for the tag's version, return the process exit code. Injectable fetch + + * loggers keep it drivable end-to-end by the unit suite with the network + * closed. + */ +export async function runGate({ + fetchImpl = fetch, + log = console.log, + logError = console.error, + rootDir = REPO_ROOT, + tag = process.env.TAG, +} = {}) { + if (!tag) { + logError( + '× TAG is not set — the registry-liveness gate needs the release tag.\n' + + ' Fix: run via github-release.yml, which exports TAG from the resolved tag.', + ) + return 1 + } + const version = versionFromTag(tag) + let plan + try { + plan = planGate(rootDir) + } catch (error) { + // Zero-dep on purpose — the lib errorMessage helper is not on disk when + // this runs, so surface the plain message. + logError(String(error?.message ?? error)) + return 1 + } + if (plan.registry === 'npm') { + if (!(await checkNpmLive(plan.name, version, fetchImpl, logError))) { + logError( + `× ${plan.name}@${version} is not resolvable on npm — refusing to cut the GH release before the registry publish.`, + ) + logError( + ' A STAGED package is not published. Fix: approve/complete the publish first, then re-run.', + ) + return 1 + } + log(`✓ ${plan.name}@${version} is live on npm.`) + return 0 + } + if (plan.registry === 'crates') { + if (plan.names.length === 0) { + log( + 'No publishable crate in the workspace — skipping the crates.io liveness gate.', + ) + } + for (let i = 0, { length } = plan.names; i < length; i += 1) { + const name = plan.names[i] + if (!(await checkCrateLive(name, version, fetchImpl, logError))) { + logError( + `× ${name}@${version} is not in the crates.io index — refusing to cut the GH release before the registry publish.`, + ) + return 1 + } + log(`✓ ${name}@${version} is live on crates.io.`) + } + return 0 + } + log( + 'No public npm package or crate manifest — skipping the registry-liveness gate (github-release-only repo).', + ) + return 0 +} + +async function main() { + process.exitCode = await runGate() +} + +// Realpath both sides — the naive argv[1] comparison is symlink-fragile, the +// same pitfall scripts/socket-release/_shared/is-main-module.mts documents; that +// helper is .mts and this script must stay importless-runnable on system +// Node, so the comparison is inlined. +function isEntrypoint(invokedPath) { + if (!invokedPath) { + return false + } + try { + return ( + realpathSync(invokedPath) === realpathSync(fileURLToPath(import.meta.url)) + ) + } catch { + return false + } +} + +if (isEntrypoint(process.argv[1])) { + void main() +} diff --git a/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/action.yml b/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/action.yml new file mode 100644 index 00000000..56618c5c --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/action.yml @@ -0,0 +1,55 @@ +name: Socket release app token +description: >- + Mint a short-lived GitHub App installation token for the Socket RELEASE app + (create releases, push tags, and commit the Homebrew tap formula bump). + Org-wide when owner is set, so the token reaches the tap repo. Ships with + socket-release-kit as `./.github/actions/socket-release-app-token`; the App + credentials are org-wide (vars.SOCKET_RELEASE_CLIENT_ID + + secrets.SOCKET_RELEASE_APP_PRIVATE_KEY) — never per-repo setup. + +inputs: + client-id: + description: 'Release App Client ID — pass vars.SOCKET_RELEASE_CLIENT_ID.' + required: true + private-key: + description: 'Release App private key — pass secrets.SOCKET_RELEASE_APP_PRIVATE_KEY.' + required: true + owner: + description: 'Org/owner to scope the token to (for cross-repo reach). Empty = the current repo owner.' + required: false + default: '' + repositories: + description: 'Newline/comma list of repos to scope the token to. Empty = all repos the app can access in owner.' + required: false + default: '' + +outputs: + token: + description: 'The minted installation token (pass as GH_TOKEN / checkout token).' + value: ${{ steps.app-token.outputs.token }} + slug: + description: 'The app slug — build the `[bot]` committer identity from it (an installation token cannot call `gh api /user`).' + value: ${{ steps.app-token.outputs.slug }} + +runs: + using: composite + steps: + # Socket-owned, dep-0 minter co-located with this action — runs via + # $GITHUB_ACTION_PATH so it travels when a consumer uses the action, and + # as plain .mjs it needs no recent runner Node. Least-privilege: + # PERMISSIONS is the exact scope this app needs. This app is + # contents:write ONLY — tags, releases, branch refs, and the signed tap + # bump commit; a wider request 422s the mint outright. Requesting a scope + # is not the same as HOLDING it: the minter preflights the installation's + # own grant against PERMISSIONS before it mints, and refuses with the App + # settings URL when the grant falls short, so a scope gap surfaces at the + # mint rather than deep inside a release. + - id: app-token + shell: bash + env: + APP_PRIVATE_KEY: ${{ inputs.private-key }} + CLIENT_ID: ${{ inputs.client-id }} + OWNER: ${{ inputs.owner || github.repository_owner }} + PERMISSIONS: '{"contents":"write"}' + REPOSITORIES: ${{ inputs.repositories }} + run: node "${{ github.action_path }}/mint-app-installation-token.mjs" diff --git a/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/mint-app-installation-token.mjs b/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/mint-app-installation-token.mjs new file mode 100644 index 00000000..6a888e77 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/actions/socket-release-app-token/mint-app-installation-token.mjs @@ -0,0 +1,323 @@ +/* + * @file Mint a short-lived GitHub App installation token. Dep-0 (node: builtins + * only) so it runs in CI before any install, and shipped as plain .mjs (no TS + * type-stripping) so it never depends on the runner's Node version. Co-located + * inside each app-token composite action and invoked via + * `node "${{ github.action_path }}/mint-app-installation-token.mjs"`, so it + * travels with the action when a member consumes it cross-repo + * (`uses: ./.github/actions/fleet/`) — the action's + * own directory is always fetched, unlike a `scripts/` path that would resolve + * against the consumer's checkout. RS256 JWT (iss = the app Client ID) -> the + * org installation -> an installation token scoped by the PERMISSIONS env. The + * token is masked, then handed back via $GITHUB_OUTPUT. Least-privilege is the + * fleet check's contract, not GitHub's: zizmor's github-app audit recognizes + * create-github-app-token's `permission-*` inputs, not this minter, so a + * dedicated fleet CI check is the sole enforcement that every action passes a + * scoped (non-blank) PERMISSIONS. + * + * Env: + * CLIENT_ID (required) the GitHub App Client ID + * APP_PRIVATE_KEY (required) the app private key (PEM) + * OWNER (required) org/owner to mint the installation token for + * PERMISSIONS (optional) JSON object, e.g. {"contents":"write"}; an empty + * object is rejected, would mint blanket perms + * REPOSITORIES (optional) newline/comma repo NAMES to scope the token to + * GITHUB_OUTPUT (required) set by the runner; token is written here. + */ + +import crypto from 'node:crypto' +import { appendFileSync } from 'node:fs' +import { request } from 'node:https' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +function die(message) { + process.stderr.write(`[mint-app-token] ${message}\n`) + process.exit(1) +} + +function env(name) { + const value = process.env[name] + if (!value) { + die( + `required env ${name} is not set. ` + + `Where: the app-token composite action's env block. ` + + `Fix: pass ${name} via the action's env (CLIENT_ID/OWNER from inputs, ` + + `APP_PRIVATE_KEY from the secret).`, + ) + } + return value +} + +function gh(method, path, jwt, body) { + const headers = { + accept: 'application/vnd.github+json', + authorization: `Bearer ${jwt}`, + 'user-agent': 'socket-fleet-app-token', + 'x-github-api-version': '2022-11-28', + } + if (body !== undefined) { + headers['content-length'] = String(Buffer.byteLength(body)) + headers['content-type'] = 'application/json' + } + return new Promise((resolve, reject) => { + const req = request( + { headers, host: 'api.github.com', method, path, port: 443 }, + res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => + resolve({ + body: Buffer.concat(chunks).toString('utf8'), + status: res.statusCode ?? 0, + }), + ) + }, + ) + req.setTimeout(15_000, () => + req.destroy(new Error(`${method} ${path} timed out`)), + ) + req.on('error', reject) + if (body !== undefined) { + req.write(body) + } + req.end() + }) +} + +// Parse a PERMISSIONS string (a JSON object) into the access-token request, or +// undefined when blank. Throws on malformed or empty-object input — an empty +// object would mint a blanket-permission token, the opposite of least-privilege. +// Pure, the raw string is the argument + exported so it is unit-testable. +export function parsePermissions(rawInput) { + const raw = rawInput?.trim() + if (!raw) { + return undefined + } + let parsed + try { + parsed = JSON.parse(raw) + } catch { + throw new Error( + `PERMISSIONS is not valid JSON. Where: the action's env. ` + + `Saw: ${raw}. Fix: pass a JSON object like {"contents":"write"}.`, + ) + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + Object.keys(parsed).length === 0 + ) { + throw new Error( + `PERMISSIONS must be a non-empty JSON object. Where: the action's env. ` + + `Saw: ${raw}. Fix: pass e.g. {"contents":"write"}; an empty object would ` + + `mint a blanket-permission token.`, + ) + } + return parsed +} + +// Installation-permission strength, weakest first. A requested `write` is only +// satisfied by `write` or `admin`; a scope the installation does not grant at +// all ranks 0. +const PERMISSION_RANK = { admin: 3, read: 1, write: 2 } + +// Turn an API permission key into the label the GitHub App settings page shows, +// e.g. `pull_requests` -> `Pull requests`. Pure + exported so it is +// unit-testable. +export function formatAppPermissionLabel(scope) { + const words = scope.split('_').join(' ') + return words.charAt(0).toUpperCase() + words.slice(1) +} + +// The scopes the REQUEST asks for that the installation's own grant does not +// cover, each with what was wanted vs what is actually granted. This is the +// PREFLIGHT: an installation missing a scope 422s the mint (or, worse, a widened +// request lands and the permission is only exercised LATER — a promote PR 403ing +// after the irreversible publish). Comparing the grant up front turns that into +// a refusal before anything is published. Pure + exported so it is +// unit-testable. +export function findMissingAppPermissions(config) { + const requested = config?.requested ?? {} + const granted = config?.granted ?? {} + const missing = [] + // oxlint-disable-next-line unicorn/no-array-sort -- fresh copy + const scopes = Object.keys(requested).slice().sort() + for (let i = 0, { length } = scopes; i < length; i += 1) { + const scope = scopes[i] + const wanted = requested[scope] + const have = granted[scope] + if ((PERMISSION_RANK[have] ?? 0) < (PERMISSION_RANK[wanted] ?? 0)) { + missing.push({ granted: have, scope, wanted }) + } + } + return missing +} + +// The four-part (What / Where / Saw vs. wanted / Fix) refusal for a permission +// shortfall, ending in the exact GitHub App settings URL and the clicks to make +// there. Pure + exported so it is unit-testable. +export function formatAppPermissionShortfall(config) { + const missing = config?.missing ?? [] + const owner = config?.owner ?? '' + const slug = config?.slug ?? '' + const url = `https://github.com/organizations/${owner}/settings/apps/${slug}` + const lines = [ + `the ${slug} GitHub App installation on ${owner} does not grant every requested permission.`, + ` Where: GET /orgs/${owner}/installation, before any token is minted or anything is published.`, + ] + for (const entry of missing) { + lines.push( + ` Saw: ${entry.scope} = ${entry.granted ?? ''}; wanted ${entry.wanted}.`, + ) + } + lines.push( + ` A missing scope fails LATE otherwise — the mint 422s, or the permission is first`, + ` exercised after the irreversible publish (the promote PR 403s mid-release).`, + ` Fix: ${url}`, + ) + for (const entry of missing) { + lines.push( + ' -> Permissions & events -> Repository permissions -> ' + + formatAppPermissionLabel(entry.scope) + + ' -> ' + + (entry.wanted === 'read' ? 'Read-only' : 'Read and write'), + ) + } + lines.push( + ` Then accept the pending permission request on the ${owner} installation and re-run.`, + ) + return lines.join('\n') +} + +// Split a REPOSITORIES string (newline/comma repo NAMES) into the access-token +// request's `repositories` array, or undefined when blank. Pure (the raw string +// is the argument) + exported so it is unit-testable. +export function parseRepositories(rawInput) { + const raw = rawInput?.trim() + if (!raw) { + return undefined + } + const names = raw + .split(/[\n,]/) + .map(s => s.trim()) + .filter(Boolean) + return names.length ? names : undefined +} + +async function main() { + const clientId = env('CLIENT_ID') + const privateKey = env('APP_PRIVATE_KEY') + const owner = env('OWNER') + const permissions = parsePermissions(process.env['PERMISSIONS']) + const repositories = parseRepositories(process.env['REPOSITORIES']) + const now = Math.floor(Date.now() / 1000) + const head = Buffer.from( + JSON.stringify({ alg: 'RS256', typ: 'JWT' }), + ).toString('base64url') + const claims = Buffer.from( + JSON.stringify({ exp: now + 540, iat: now - 60, iss: clientId }), + ).toString('base64url') + const signature = crypto + .createSign('RSA-SHA256') + .update(`${head}.${claims}`) + .sign(privateKey, 'base64url') + const jwt = `${head}.${claims}.${signature}` + + const inst = await gh( + 'GET', + `/orgs/${encodeURIComponent(owner)}/installation`, + jwt, + ) + if (inst.status !== 200) { + die( + `installation lookup failed: HTTP ${inst.status}. ` + + `Where: GET /orgs/${owner}/installation. Saw: ${inst.body}. ` + + `Fix: confirm the app (CLIENT_ID) is installed on ${owner}.`, + ) + } + const installation = JSON.parse(inst.body) + const installationId = installation.id + if (typeof installationId !== 'number') { + die(`installation lookup returned no id. Saw: ${inst.body}.`) + } + + // PREFLIGHT: the installation's own grant must already cover every requested + // scope. Runs before the mint and therefore before any publish/promote — the + // widened `pull_requests: write` request is only exercised by the promote PR + // that follows a successful publish, so without this the shortfall surfaces + // as a 403 in the irreversible window. + if (permissions !== undefined) { + const missing = findMissingAppPermissions({ + granted: installation.permissions, + requested: permissions, + }) + if (missing.length) { + die( + formatAppPermissionShortfall({ + missing, + owner, + slug: installation.app_slug ?? '', + }), + ) + } + } + + const tokenBody = {} + if (permissions !== undefined) { + tokenBody.permissions = permissions + } + if (repositories !== undefined) { + tokenBody.repositories = repositories + } + const minted = await gh( + 'POST', + `/app/installations/${installationId}/access_tokens`, + jwt, + JSON.stringify(tokenBody), + ) + if (minted.status !== 201) { + die( + `token mint failed: HTTP ${minted.status}. ` + + `Where: POST /app/installations/${installationId}/access_tokens. ` + + `Saw: ${minted.body}. Fix: the requested permissions/repositories must be ` + + `a subset of what the app's installation on ${owner} grants (a 422 means ` + + `the install lacks a requested scope). Grant it at ` + + `https://github.com/organizations/${owner}/settings/apps/${installation.app_slug ?? ''}` + + ` -> Permissions & events -> Repository permissions.`, + ) + } + const token = JSON.parse(minted.body).token + if (!token) { + die(`token mint returned no token. Saw: ${minted.body}.`) + } + + process.stdout.write(`::add-mask::${token}\n`) + appendFileSync(env('GITHUB_OUTPUT'), `token=${token}\n`) + + // Expose the app slug, from the installation lookup, so the caller can build + // the `[bot]` committer identity. An installation token cannot call + // `gh api /user` (403 — it has no user), so the workflow needs the slug to do + // a by-name `gh api /users/[bot]` lookup instead. + const appSlug = installation.app_slug + if (typeof appSlug === 'string' && appSlug) { + appendFileSync(env('GITHUB_OUTPUT'), `slug=${appSlug}\n`) + } +} + +// Guard the entry IIFE so importing the module (the unit tests import the pure +// parse fns) does NOT run main(). Run only when invoked directly as the script. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + void (async () => { + try { + await main() + } catch (e) { + // oxlint-disable-next-line socket/prefer-error-message, socket/prefer-error-message-helper -- dep-0: this .mjs uses only node: builtins and runs in CI BEFORE `pnpm install`, so it cannot import errorMessage() from the external @socketsecurity/lib. + die(e instanceof Error ? e.message : String(e)) + } + })() +} diff --git a/release-kit/payload/scripts/socket-release/templates/config/socket-release.json b/release-kit/payload/scripts/socket-release/templates/config/socket-release.json new file mode 100644 index 00000000..ffc00aad --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/config/socket-release.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "channels": ["npm", "github-release"], + "npm": { "access": "restricted", "distTag": "latest" }, + "brew": { + "tap": "SocketDev/socket", + "formula": "", + "assetTemplate": "-.tar.gz", + "triplets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"] + } +} diff --git a/release-kit/payload/scripts/socket-release/templates/gitignore-block.txt b/release-kit/payload/scripts/socket-release/templates/gitignore-block.txt new file mode 100644 index 00000000..2361e920 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/gitignore-block.txt @@ -0,0 +1,2 @@ +# socket-release-kit +.cache/ diff --git a/release-kit/payload/scripts/socket-release/templates/workflows/brew-publish.yml b/release-kit/payload/scripts/socket-release/templates/workflows/brew-publish.yml new file mode 100644 index 00000000..1b2c68d1 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/workflows/brew-publish.yml @@ -0,0 +1,67 @@ +# Managed by socket-release-kit — byte-identical to +# scripts/socket-release/templates/workflows/brew-publish.yml. Bumps the +# Homebrew tap formula for a PUBLISHED release: the sha256 authority is the +# release's own checksums.txt (never re-hashed), and the tap commit is a +# GitHub-signed API commit direct to the tap default branch — never a PR. +# The write token is minted per-run by the co-located +# socket-release-app-token composite from the org-wide App credentials. +name: brew publish + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to bump the formula to (vX.Y.Z).' + type: string + required: true + publish: + description: 'Commit the bump for real (false = dry-run, the default).' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: brew-publish-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + bump: + runs-on: ubuntu-latest + # A real bump (release event, or dispatch with publish) runs gated by the + # brew-publish environment; a dry-run is ungated. Expression in + # `environment:` is lawful — not a run body. + environment: ${{ (github.event_name == 'release' || inputs.publish == true) && 'brew-publish' || '' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + with: + fetch-depth: 1 + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 (2026-03-11) + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-05-15) + with: + node-version: '24' + - name: Install + run: | + set -euo pipefail + pnpm install --frozen-lockfile + - name: Mint tap write token + id: app-token + uses: ./.github/actions/socket-release-app-token + with: + client-id: ${{ vars.SOCKET_RELEASE_CLIENT_ID }} + private-key: ${{ secrets.SOCKET_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Bump formula + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PUBLISH: ${{ inputs.publish }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + ARGS=(--tag "$TAG") + if [ "$PUBLISH" = "true" ] || [ "$GITHUB_EVENT_NAME" = "release" ]; then ARGS+=(--apply); fi + node scripts/socket-release/brew-publish.mts "${ARGS[@]}" diff --git a/release-kit/payload/scripts/socket-release/templates/workflows/cargo-publish.yml b/release-kit/payload/scripts/socket-release/templates/workflows/cargo-publish.yml new file mode 100644 index 00000000..6a55fad7 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/workflows/cargo-publish.yml @@ -0,0 +1,77 @@ +# Managed by socket-release-kit — byte-identical to +# scripts/socket-release/templates/workflows/cargo-publish.yml. Adapted from +# the fleet preset: manual dispatch, DRY-RUN unless `publish: true`; +# publishes via crates.io Trusted Publishing (OIDC — id-token: write, no +# long-lived token). The `cargo-publish` CI environment gates the real, +# PERMANENT publish (crates.io is yank-only). +# +# ORDER RULE: the engine publishes FIRST, then gates the git tag + immutable +# GH release on the version being resolvable in the crates.io index — the +# release is the FINAL marker and never precedes the registry publish. +name: cargo publish + +on: + workflow_dispatch: + inputs: + publish: + description: 'Publish for real (false = dry-run, the default).' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: cargo-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + # Same-named CI environment: the human-approval + OIDC gate for the + # PERMANENT publish; a dry-run is ungated. An expression in + # `environment:` is lawful — it is not a run body. + environment: ${{ inputs.publish == true && 'cargo-publish' || '' }} + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + with: + fetch-depth: 1 + fetch-tags: true + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 (2026-03-11) + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-05-15) + with: + node-version: '24' + - name: Install + run: | + set -euo pipefail + pnpm install --frozen-lockfile + - name: Rust toolchain + uses: dtolnay/rust-toolchain@4fd1da8b0805d2d2e936788875a7d65dbd677dc2 # nightly (2025-01-27) + with: + toolchain: nightly + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.8.1 (2025-07-29) + - name: crates.io auth (OIDC Trusted Publishing) + id: auth + if: ${{ inputs.publish == true }} + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.2 (2025-06-06) + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + GH_TOKEN: ${{ github.token }} + PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + ARGS=(--staged --dry-run) + if [ "$PUBLISH" = "true" ]; then ARGS=(--direct); fi + node scripts/socket-release/cargo-publish.mts "${ARGS[@]}" + - name: Attest build provenance (.crate) + if: ${{ inputs.publish == true }} + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 (2025-06-04) + with: + subject-path: target/package/*.crate diff --git a/release-kit/payload/scripts/socket-release/templates/workflows/github-release.yml b/release-kit/payload/scripts/socket-release/templates/workflows/github-release.yml new file mode 100644 index 00000000..5e187c64 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/workflows/github-release.yml @@ -0,0 +1,76 @@ +# Managed by socket-release-kit — byte-identical to +# scripts/socket-release/templates/workflows/github-release.yml. +# +# ORDER RULE: the immutable GitHub release is the FINAL marker of a release. +# The `gate` job refuses any tag whose version is not already resolvable on +# its registry (registry-liveness-gate runs on the runner's system Node, +# before any install exists); `ensure-release` heals a tag gap manually. +name: github release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to gate/heal (vX.Y.Z), else the pushed tag.' + type: string + default: '' + release: + description: 'Cut the release for real (workflow_dispatch only).' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: github-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + with: + fetch-depth: 1 + persist-credentials: false + - name: Registry liveness gate + env: + TAG: ${{ github.ref_name || inputs.tag }} + run: | + set -euo pipefail + node scripts/socket-release/registry-liveness-gate.mjs + ensure-release: + if: github.event_name == 'workflow_dispatch' && inputs.release + runs-on: ubuntu-latest + environment: github-release + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 (2026-03-11) + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-05-15) + with: + node-version: '24' + - name: Install + run: | + set -euo pipefail + pnpm install --frozen-lockfile + - name: Build + run: | + set -euo pipefail + pnpm run build + - name: Ensure tag + immutable release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -euo pipefail + node scripts/socket-release/github-release.mts --tag "$TAG" --release diff --git a/release-kit/payload/scripts/socket-release/templates/workflows/npm-publish.yml b/release-kit/payload/scripts/socket-release/templates/workflows/npm-publish.yml new file mode 100644 index 00000000..5f078f42 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/templates/workflows/npm-publish.yml @@ -0,0 +1,83 @@ +# Managed by socket-release-kit — byte-identical to +# scripts/socket-release/templates/workflows/npm-publish.yml (the bootstrap's +# staged-config step restores drift; reconcile edits into the template). +# +# ENVIRONMENT PIN: the job pins `environment: npm-publish` because npm's +# trusted-publisher OIDC exchange 404s outside the exact environment the +# trust config names — a publish run from any other environment cannot mint +# a token at all. +# +# ORDER RULE: registry publish FIRST; the git tag + immutable GitHub release +# follow only once the version resolves as live (github-release.yml gates on +# registry liveness — the release is the FINAL marker, never the first). +name: npm publish + +on: + workflow_dispatch: + inputs: + publish: + description: 'Publish for real (false = dry-run, the default).' + type: boolean + default: false + dist-tag: + description: 'npm dist-tag for the staged publish.' + type: string + default: latest + backfill-version: + description: 'Backfill an already-tagged version (X.Y.Z), else empty.' + type: string + default: '' + checkout-ref: + description: 'Ref to check out for a backfill, else empty.' + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (2026-05-15) + with: + ref: ${{ inputs.checkout-ref }} + fetch-depth: 1 + fetch-tags: true + persist-credentials: false + # Reads the consumer's `packageManager` pin — the bootstrap preflight + # requires it, so CI publishes with the same pnpm the operator staged + # with. + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 (2026-03-11) + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-05-15) + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + - name: Install + run: | + set -euo pipefail + pnpm install --frozen-lockfile + - name: Build + run: | + set -euo pipefail + pnpm run build + - name: Stage publish + env: + BACKFILL: ${{ inputs.backfill-version }} + CHECKOUT_REF: ${{ inputs.checkout-ref }} + DIST_TAG: ${{ inputs.dist-tag }} + PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + ARGS=(--staged --tag "$DIST_TAG") + if [ -n "$BACKFILL" ]; then ARGS+=(--backfill "$BACKFILL" --checkout-ref "$CHECKOUT_REF"); fi + if [ "$PUBLISH" != "true" ]; then ARGS+=(--dry-run); fi + node scripts/socket-release/npm-publish.mts "${ARGS[@]}" diff --git a/release-kit/payload/scripts/socket-release/util/napi-targets.mts b/release-kit/payload/scripts/socket-release/util/napi-targets.mts new file mode 100644 index 00000000..4c15efb2 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/util/napi-targets.mts @@ -0,0 +1,234 @@ +/* + * @file Canonical ABI/NAPI target identifiers, matching napi-rs's naming rules + * (`platform-arch[-abi]` derived from the Rust target triple; darwin carries + * no ABI segment, linux carries an EXPLICIT `-gnu` or `-musl`, windows + * carries `-msvc`). Single source of truth for fleet surfaces that enumerate + * `.node` addon targets: tail-package manifest generators, meta-package + * runtime loaders, source-allowlist entries (`kind: 'napi'`), and the + * `platform-tails-match-naming-domain` check. + * + * THE TWO NAMING DOMAINS ARE DISTINCT BY DESIGN (ratified 2026-07-04): + * - BINARIES (kind `cli`) follow pnpm pack-app naming — + * `pack-app-triplets.mts`, 8 targets, glibc unsuffixed, no toolchain + * segment (`linux-x64`, `win32-arm64`). + * - ABI/NAPI (kind `napi`) follows napi-rs naming — THIS file, 5 default + * targets, `-gnu`/`-msvc` explicit (`linux-x64-gnu`, `win32-x64-msvc`), + * the wasm fallback covers platforms outside the native set. + * Never blur the two: the suffix tells a reader which artifact kind a tail + * ships. + * + * @see https://github.com/napi-rs/napi-rs — `parseTriple` derives + * `platformArchABI` exactly this way (oxc's `@oxc-parser/binding-*` packages + * are the reference deployment of the convention). + */ + +/** + * Every ABI/NAPI target the fleet ships or recognizes, in ASCII order. + * + * Linux always carries an explicit libc ABI (`-gnu` or `-musl`) — unlike the + * pack-app binary domain where glibc is unsuffixed. Windows always carries + * `-msvc`. Darwin never carries an ABI segment. `wasm32-wasi` is the universal + * fallback binding target. + */ +export const NAPI_TARGETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-gnu', + 'linux-arm64-musl', + 'linux-x64-gnu', + 'linux-x64-musl', + 'wasm32-wasi', + 'win32-arm64-msvc', + 'win32-x64-msvc', +] as const + +/** + * Literal-union type derived from `NAPI_TARGETS`. Use as a type annotation + * everywhere a napi target appears so a typo at the call site fails compile. + */ +export type NapiTarget = (typeof NAPI_TARGETS)[number] + +/** + * Native (non-wasm) subset — the targets that produce a `.node` payload. + */ +export type NapiNativeTarget = Exclude + +/** + * The fleet-default build matrix for `.node` addons: 5 targets (napi-rs's + * popular-target starter set). Everything else falls back to wasm at load + * time, so musl and win32-arm64 are deliberately absent — an addon family + * opts into extra targets explicitly, it doesn't inherit them. + */ +export const NAPI_TARGETS_DEFAULT = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-gnu', + 'linux-x64-gnu', + 'win32-x64-msvc', +] as const satisfies readonly NapiTarget[] + +/** + * O(1) membership set for hot paths, lint rules, allowlist validators. + * Materialized once at module load. + */ +export const NAPI_TARGET_SET: ReadonlySet = new Set(NAPI_TARGETS) + +/** + * Rust target triple → napi target name, for the targets the fleet builds. + * Mirrors napi-rs `parseTriple` output for these triples exactly. + */ +export const RUST_TRIPLE_TO_NAPI_TARGET: Readonly> = + { + 'aarch64-apple-darwin': 'darwin-arm64', + 'aarch64-pc-windows-msvc': 'win32-arm64-msvc', + 'aarch64-unknown-linux-gnu': 'linux-arm64-gnu', + 'aarch64-unknown-linux-musl': 'linux-arm64-musl', + 'wasm32-wasip1-threads': 'wasm32-wasi', + 'x86_64-apple-darwin': 'darwin-x64', + 'x86_64-pc-windows-msvc': 'win32-x64-msvc', + 'x86_64-unknown-linux-gnu': 'linux-x64-gnu', + 'x86_64-unknown-linux-musl': 'linux-x64-musl', + } + +/** + * Type-guard: is `value` one of the canonical napi targets? + * + * Use at trust boundaries — anywhere an untrusted string (CLI arg, env var, + * release-tag-parsing output) is about to be used as a napi target. + */ +export function isNapiTarget(value: unknown): value is NapiTarget { + return typeof value === 'string' && NAPI_TARGET_SET.has(value as NapiTarget) +} + +/** + * Inputs to `resolveCurrentNapiTarget`. Pure data so the function is + * unit-testable without mocking `process` or filesystem libc detection. + */ +export interface CurrentNapiTargetInputs { + /** + * `process.platform` value. + */ + readonly platform: NodeJS.Platform + /** + * `process.arch` value. + */ + readonly arch: string + /** + * Whether the current Linux runtime uses musl libc. Ignored on non-Linux. + */ + readonly isMusl: boolean +} + +/** + * Pure-function napi-target resolver for runtime loader require-chains. + * Returns the native target for the given runtime inputs, or `undefined` when + * no native target matches, the caller then falls back to the wasm binding. + * + * Examples: - `{ platform: 'linux', arch: 'x64', isMusl: false }` → + * `linux-x64-gnu` - `{ platform: 'linux', arch: 'x64', isMusl: true }` → + * `linux-x64-musl` - `{ platform: 'freebsd', arch: 'x64', isMusl: false }` → + * `undefined` + */ +export function resolveCurrentNapiTarget( + inputs: CurrentNapiTargetInputs, +): NapiNativeTarget | undefined { + const { arch, isMusl, platform } = inputs + + if (platform === 'linux') { + if (arch === 'arm64') { + return isMusl ? 'linux-arm64-musl' : 'linux-arm64-gnu' + } + if (arch === 'x64') { + return isMusl ? 'linux-x64-musl' : 'linux-x64-gnu' + } + return undefined + } + + if (platform === 'darwin') { + if (arch === 'arm64') { + return 'darwin-arm64' + } + if (arch === 'x64') { + return 'darwin-x64' + } + return undefined + } + + if (platform === 'win32') { + if (arch === 'arm64') { + return 'win32-arm64-msvc' + } + if (arch === 'x64') { + return 'win32-x64-msvc' + } + return undefined + } + + return undefined +} + +/** + * Parse a napi-target suffix off the end of a tail-package name. Returns the + * target if the name ends in one, `undefined` otherwise. + * + * Longest-suffix-first so ABI-qualified forms win over any shorter overlap. + * + * Examples: - `parseNapiTargetSegment('acorn-linux-x64-gnu')` → + * `linux-x64-gnu` - `parseNapiTargetSegment('acorn-darwin-arm64')` → + * `darwin-arm64` - `parseNapiTargetSegment('acorn-linux-x64')` → `undefined` + * (bare linux belongs to the pack-app BINARY domain, not this one) + */ +export function parseNapiTargetSegment(name: string): NapiTarget | undefined { + // oxlint-disable-next-line unicorn/no-array-sort -- `NAPI_TARGETS` is a shared module-level const, so the spread copies it first; an in-place sort would mutate the constant list every caller shares. .toSorted() would trip socket/no-runtime-features-below-engine-floor in cascaded Node-18 repos. + const ordered = [...NAPI_TARGETS].sort((a, b) => b.length - a.length) + for (let i = 0, { length } = ordered; i < length; i += 1) { + const target = ordered[i]! + if (name === target || name.endsWith(`-${target}`)) { + return target + } + } + return undefined +} + +/** + * The `os` / `cpu` / `libc` package.json fields for a native napi target. Tail + * manifest generators stamp these directly so a tail can never resolve on the + * wrong platform. (`wasm32-wasi` is excluded by type — the wasm binding is + * platform-unrestricted.) + */ +export interface NapiTargetEngineFields { + readonly os: readonly [NodeJS.Platform] + readonly cpu: readonly [string] + readonly libc?: readonly ['glibc' | 'musl'] | undefined +} + +/** + * Resolve the package.json engine-restriction fields (`os`, `cpu`, optionally + * `libc`) for a native napi target. Used by tail-manifest generators. + */ +export function napiTargetEngineFields( + target: NapiNativeTarget, +): NapiTargetEngineFields { + if (target === 'darwin-arm64') { + return { cpu: ['arm64'], os: ['darwin'] } + } + if (target === 'darwin-x64') { + return { cpu: ['x64'], os: ['darwin'] } + } + if (target === 'linux-arm64-gnu') { + return { cpu: ['arm64'], libc: ['glibc'], os: ['linux'] } + } + if (target === 'linux-arm64-musl') { + return { cpu: ['arm64'], libc: ['musl'], os: ['linux'] } + } + if (target === 'linux-x64-gnu') { + return { cpu: ['x64'], libc: ['glibc'], os: ['linux'] } + } + if (target === 'linux-x64-musl') { + return { cpu: ['x64'], libc: ['musl'], os: ['linux'] } + } + if (target === 'win32-arm64-msvc') { + return { cpu: ['arm64'], os: ['win32'] } + } + return { cpu: ['x64'], os: ['win32'] } +} diff --git a/release-kit/payload/scripts/socket-release/util/pack-app-triplets.mts b/release-kit/payload/scripts/socket-release/util/pack-app-triplets.mts new file mode 100644 index 00000000..f4c63a72 --- /dev/null +++ b/release-kit/payload/scripts/socket-release/util/pack-app-triplets.mts @@ -0,0 +1,236 @@ +/** + * @file Canonical platform-triplet identifiers, matching pnpm pack-app's + * supported targets. Single source of truth for fleet surfaces that enumerate + * platforms: tail-package manifest generators, meta-package runtime loaders + * (resolve current process → triplet → + * `require.resolve('@/-/bin/')`), + * source-allowlist entries, and lint rules that validate tail-name suffixes + * against the known set. Sorted ASCII byte order so the list reads + * identically to `socket/sort-named-imports` / `sort-source-methods` + * enforcement elsewhere — every consumer that wants priority order sorts + * downstream. + * + * @see https://pnpm.io/11.x/cli/pack-app for the upstream triplet spec. + */ + +/** + * Every platform triplet pnpm pack-app supports, in ASCII order. + * + * Linux gets four variants (glibc + musl × arm64 + x64). macOS and Windows get + * two each (arm64 + x64). The `-musl` qualifier is Linux-only. + */ +export const PACK_APP_TRIPLETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-arm64-musl', + 'linux-x64', + 'linux-x64-musl', + 'win32-arm64', + 'win32-x64', +] as const + +/** + * Literal-union type derived from `PACK_APP_TRIPLETS`. Use as a type annotation + * everywhere a triplet appears so a typo at the call site fails compile. + */ +export type PackAppTriplet = (typeof PACK_APP_TRIPLETS)[number] + +/** + * Linux-only subset (glibc + musl × arm64 + x64). For package families that + * ship Linux binaries without macOS / Windows support. + */ +export const PACK_APP_TRIPLETS_LINUX = [ + 'linux-arm64', + 'linux-arm64-musl', + 'linux-x64', + 'linux-x64-musl', +] as const satisfies readonly PackAppTriplet[] + +/** + * MacOS-only subset (arm64 + x64). + */ +export const PACK_APP_TRIPLETS_DARWIN = [ + 'darwin-arm64', + 'darwin-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * Windows-only subset (arm64 + x64). + */ +export const PACK_APP_TRIPLETS_WIN32 = [ + 'win32-arm64', + 'win32-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * Glibc-only subset, excludes musl. For families whose Linux build doesn't + * support musl distros (Alpine, …). + */ +export const PACK_APP_TRIPLETS_GLIBC = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +] as const satisfies readonly PackAppTriplet[] + +/** + * O(1) membership set for hot paths, lint rules, allowlist validators. + * Materialized once at module load. + */ +export const PACK_APP_TRIPLET_SET: ReadonlySet = new Set( + PACK_APP_TRIPLETS, +) + +/** + * Type-guard: is `value` one of the canonical triplets? + * + * Use at trust boundaries — anywhere an untrusted string (CLI arg, env var, + * release-tag-parsing output) is about to be used as a triplet. + */ +export function isPackAppTriplet(value: unknown): value is PackAppTriplet { + return ( + typeof value === 'string' && + PACK_APP_TRIPLET_SET.has(value as PackAppTriplet) + ) +} + +/** + * Inputs to `resolveCurrentTriplet`. Pure data so the function is unit-testable + * without mocking `process` or filesystem libc detection. + */ +export interface CurrentTripletInputs { + /** + * `process.platform` value. + */ + readonly platform: NodeJS.Platform + /** + * `process.arch` value. + */ + readonly arch: string + /** + * Whether the current Linux runtime uses musl libc. Ignored on non-Linux. + * Detection is the caller's job (typically by probing + * `/proc/self/map_files/../maps` or `ldd --version`). + */ + readonly isMusl: boolean +} + +/** + * Pure-function triplet resolver. Returns the canonical triplet for the given + * runtime inputs, or `undefined` if no triplet matches (running on an + * unsupported platform or arch). + * + * Examples: - `{ platform: 'darwin', arch: 'arm64', isMusl: false }` → + * `darwin-arm64` - `{ platform: 'linux', arch: 'x64', isMusl: true }` → + * `linux-x64-musl` - `{ platform: 'sunos', arch: 'sparc', isMusl: false }` → + * `undefined` + */ +export function resolveCurrentTriplet( + inputs: CurrentTripletInputs, +): PackAppTriplet | undefined { + const { platform, arch, isMusl } = inputs + + // Only Linux carries the libc qualifier. + if (platform === 'linux') { + if (arch === 'arm64') { + return isMusl ? 'linux-arm64-musl' : 'linux-arm64' + } + if (arch === 'x64') { + return isMusl ? 'linux-x64-musl' : 'linux-x64' + } + return undefined + } + + if (platform === 'darwin') { + if (arch === 'arm64') { + return 'darwin-arm64' + } + if (arch === 'x64') { + return 'darwin-x64' + } + return undefined + } + + if (platform === 'win32') { + if (arch === 'arm64') { + return 'win32-arm64' + } + if (arch === 'x64') { + return 'win32-x64' + } + return undefined + } + + return undefined +} + +/** + * Parse a triplet suffix off the end of a tail-package name. Returns the + * triplet if the name ends in one, `undefined` otherwise. + * + * Greedy-match against the canonical set so `linux-arm64-musl` wins over + * `linux-arm64` when both could match — the longer triplet always sorts before + * the shorter prefix in the constant list, so the first match wins. + * + * Examples: - `parseTripletSegment('acorn-linux-arm64-musl')` → + * `linux-arm64-musl` - `parseTripletSegment('stuie-yoga-darwin-arm64')` → + * `darwin-arm64` - `parseTripletSegment('acorn-wasm')` → `undefined` + */ +export function parseTripletSegment(name: string): PackAppTriplet | undefined { + // Iterate longest-suffix-first so musl forms win over their glibc + // shortenings. + // oxlint-disable-next-line unicorn/no-array-sort -- `PACK_APP_TRIPLETS` is a shared module-level const, so the spread copies it first; an in-place sort would mutate the constant list every caller shares. .toSorted() would trip socket/no-runtime-features-below-engine-floor in cascaded Node-18 repos. + const ordered = [...PACK_APP_TRIPLETS].sort((a, b) => b.length - a.length) + for (let i = 0, { length } = ordered; i < length; i += 1) { + const triplet = ordered[i]! + if (name === triplet || name.endsWith(`-${triplet}`)) { + return triplet + } + } + return undefined +} + +/** + * The `os` / `cpu` / `libc` package.json fields for a given triplet. Tail + * manifest generators stamp these directly so a tail can never resolve on the + * wrong platform. + */ +export interface TripletEngineFields { + readonly os: readonly [NodeJS.Platform] + readonly cpu: readonly [string] + readonly libc?: readonly ['glibc' | 'musl'] | undefined +} + +/** + * Resolve the package.json engine-restriction fields (`os`, `cpu`, optionally + * `libc`) for a triplet. Used by tail-manifest generators. + */ +export function tripletEngineFields( + triplet: PackAppTriplet, +): TripletEngineFields { + if (triplet === 'darwin-arm64') { + return { os: ['darwin'], cpu: ['arm64'] } + } + if (triplet === 'darwin-x64') { + return { os: ['darwin'], cpu: ['x64'] } + } + if (triplet === 'linux-arm64') { + return { os: ['linux'], cpu: ['arm64'], libc: ['glibc'] } + } + if (triplet === 'linux-arm64-musl') { + return { os: ['linux'], cpu: ['arm64'], libc: ['musl'] } + } + if (triplet === 'linux-x64') { + return { os: ['linux'], cpu: ['x64'], libc: ['glibc'] } + } + if (triplet === 'linux-x64-musl') { + return { os: ['linux'], cpu: ['x64'], libc: ['musl'] } + } + if (triplet === 'win32-arm64') { + return { os: ['win32'], cpu: ['arm64'] } + } + return { os: ['win32'], cpu: ['x64'] } +} diff --git a/scripts/repo/check/release-kit-is-coherent.mts b/scripts/repo/check/release-kit-is-coherent.mts new file mode 100644 index 00000000..850556cb --- /dev/null +++ b/scripts/repo/check/release-kit-is-coherent.mts @@ -0,0 +1,318 @@ +/* + * @file `check --all` gate: the release-kit payload is internally coherent. + * Four assertions, all read-only: + * + * 1. kit-manifest.json matches the payload's current bytes (gen-manifest + * `--check` semantics inline) — R11 pins the POST-FORMAT bytes, so this + * also proves the formatter and the manifest agree. + * 2. No payload source leaks a fleet-internal reference: `scripts/fleet/`, + * `@socketsecurity/lib-stable`, `@socketsecurity/sdk-stable`, or + * `socket-wheelhouse` — except the ONE lawful literal, the shared + * browser-profile dir in playwright-law.mts (and the two modules that + * restate it), which every Socket npm browser tool shares. + * 3. No `*.test.*` file ships under the payload. + * 4. The PURE modules (bootstrap plan/render/gates/config, brew + * formula/shared, install manifest/plan) import no effects modules — + * `node:fs`, `node:child_process`, `node:net`, `node:http` — a + * module-source scan that keeps the pure/effects split honest. + * 5. Naming law 1 (entries): the only code files at the payload ROOT are + * the sanctioned flow entries plus the grandfathered residents — + * nothing new may be added at root. + * 6. Naming law 6 (suffixes): every payload code file is `.mts`, except + * `.mjs` scripts that must run on system Node before any install + * (workflow gate jobs, composite-action scripts); any `.mjs` imported + * from TypeScript carries a `.d.mts` sidecar. + */ + +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +import { + buildManifest, + serializeManifest, +} from '../../../release-kit/gen-manifest.mts' +import { + PAYLOAD_ROOT, + walkPayload, +} from '../../../release-kit/install/seams.mts' + +const logger = getDefaultLogger() +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', +) + +const FORBIDDEN_MARKERS = [ + 'scripts/fleet/', + '@socketsecurity/lib-stable', + '@socketsecurity/sdk-stable', + 'socket-wheelhouse', +] as const + +// The ONE lawful `socket-wheelhouse` literal: the durable Chrome profile +// every Socket npm browser tool shares (~/.config/socket-wheelhouse/…). +// Renaming it would sign every already-authenticated operator out, so the +// modules that name it are allowlisted for that marker ONLY. +const MARKER_ALLOWLIST: ReadonlyArray<{ + marker: string + paths: readonly string[] +}> = [ + { + marker: 'socket-wheelhouse', + paths: [ + '_shared/playwright-law.mts', + 'publish-infra/npm/browser-session.mts', + 'publish-infra/npm/trusted-publisher-browser.mts', + ], + }, +] + +const PURE_MODULES = [ + 'bootstrap/config.mts', + 'bootstrap/gates.mts', + 'bootstrap/plan.mts', + 'bootstrap/render.mts', + 'publish-infra/brew/formula.mts', + 'publish-infra/brew/shared.mts', + 'publish-infra/npm/access-parse.mts', + 'publish-infra/npm/access-plan.mts', +] as const + +const SAUCE_PURE_MODULES = [ + 'release-kit/install/manifest.mts', + 'release-kit/install/plan.mts', +] as const + +const EFFECT_IMPORT = /from\s+['"]node:(?:child_process|fs|http|net)['"]/ + +// Naming law 1: the closed set of code files permitted at the payload root. +// `create-release.mts` is a grandfathered dead github-release entry, kept +// until it is deleted fleet-side. See the README "Known divergence" note. +const ROOT_ENTRY_ALLOWLIST: ReadonlySet = new Set([ + 'bootstrap.mts', + 'brew-publish.mts', + 'cargo-publish.mts', + 'create-release.mts', + 'github-release.mts', + 'npm-publish.mts', + 'npm-web-auth.mts', + 'paths.mts', + 'registry-liveness-gate.d.mts', + 'registry-liveness-gate.mjs', +]) + +// Naming law 6: `.mjs` is allowed only for scripts that run on system Node +// before any install — the two known homes are the payload-root registry +// liveness gate job and the composite-action minter under templates/actions/. +const SYSTEM_NODE_MJS: readonly RegExp[] = [ + /^registry-liveness-gate\.mjs$/, + /^templates\/actions\/[^/]+\/[^/]+\.mjs$/, +] + +const CODE_FILE = /\.(?:cjs|cts|js|mjs|mts|ts)$/ +const DISALLOWED_CODE_EXT = /\.(?:cjs|cts|js|ts)$/ + +function isMarkerAllowlisted(rel: string, marker: string): boolean { + return MARKER_ALLOWLIST.some( + entry => entry.marker === marker && entry.paths.includes(rel), + ) +} + +function main(): void { + const failures: string[] = [] + + // 1. Manifest freshness (gen-manifest --check inline). + const manifestPath = path.join(PAYLOAD_ROOT, 'kit-manifest.json') + let committed: string | undefined + try { + committed = readFileSync(manifestPath, 'utf8') + } catch { + committed = undefined + } + const regenerated = serializeManifest(buildManifest()) + if (committed !== regenerated) { + failures.push( + [ + 'What: kit-manifest.json does not match the payload bytes.', + `Where: ${manifestPath}`, + `Saw: ${committed === undefined ? 'no manifest' : 'stale sha entries'}`, + 'Wanted: the manifest regenerated from the current (post-format) payload', + 'Fix: node release-kit/gen-manifest.mts', + ].join('\n'), + ) + } + + const payloadFiles = walkPayload() + + // 2. Fleet-internal markers. + for (let i = 0, { length } = payloadFiles; i < length; i += 1) { + const rel = payloadFiles[i]! + if (!/\.(?:json|md|mjs|mts|txt|yml)$/.test(rel)) { + continue + } + const text = readFileSync(path.join(PAYLOAD_ROOT, rel), 'utf8') + for (let m = 0, { length: ml } = FORBIDDEN_MARKERS; m < ml; m += 1) { + const marker = FORBIDDEN_MARKERS[m]! + if (!text.includes(marker)) { + continue + } + if (isMarkerAllowlisted(rel, marker)) { + continue + } + const line = text.slice(0, text.indexOf(marker)).split('\n').length + failures.push( + [ + `What: the payload leaks the fleet-internal marker "${marker}".`, + `Where: release-kit/payload/scripts/socket-release/${rel}:${line}`, + `Saw: ${marker}`, + 'Wanted: kit sources reference only scripts/socket-release/ paths and the plain (non -stable) lib/sdk specifiers', + 'Fix: repoint the reference (R1/R5), or add a dated allowlist entry with the reason it is load-bearing.', + ].join('\n'), + ) + } + } + + // 3. No tests ship in the payload. + for (let i = 0, { length } = payloadFiles; i < length; i += 1) { + const rel = payloadFiles[i]! + if (/\.test\./.test(rel)) { + failures.push( + [ + 'What: a test file is shipping inside the payload.', + `Where: release-kit/payload/scripts/socket-release/${rel}`, + `Saw: ${rel}`, + 'Wanted: tests live in test/repo/unit/release-kit/, never in the copy-in payload', + 'Fix: move the file under test/repo/.', + ].join('\n'), + ) + } + } + + // 4. Pure-module import discipline. + const pureTargets = [ + ...PURE_MODULES.map(rel => ({ + abs: path.join(PAYLOAD_ROOT, rel), + label: `release-kit/payload/scripts/socket-release/${rel}`, + })), + ...SAUCE_PURE_MODULES.map(rel => ({ + abs: path.join(REPO_ROOT, rel), + label: rel, + })), + ] + for (let i = 0, { length } = pureTargets; i < length; i += 1) { + const target = pureTargets[i]! + let text: string + try { + text = readFileSync(target.abs, 'utf8') + } catch { + failures.push( + [ + 'What: a pure module named by the coherence check is missing.', + `Where: ${target.label}`, + 'Saw: no such file', + 'Wanted: the module present (the pure/effects split is part of the kit contract)', + 'Fix: restore the module or update the check list in the same commit.', + ].join('\n'), + ) + continue + } + const hit = EFFECT_IMPORT.exec(text) + if (hit) { + failures.push( + [ + 'What: a PURE kit module imports an effects module.', + `Where: ${target.label}`, + `Saw: ${hit[0]}`, + 'Wanted: pure modules import no node:fs / node:child_process / node:net / node:http', + 'Fix: move the effect behind the seams module and pass data in.', + ].join('\n'), + ) + } + } + + // 5. Naming law 1: only sanctioned code files live at the payload root. + for (let i = 0, { length } = payloadFiles; i < length; i += 1) { + const rel = payloadFiles[i]! + if (path.dirname(rel) !== '.' || !CODE_FILE.test(rel)) { + continue + } + if (!ROOT_ENTRY_ALLOWLIST.has(rel)) { + failures.push( + [ + 'What: an unsanctioned code file lives at the payload root.', + `Where: release-kit/payload/scripts/socket-release/${rel}`, + `Saw: ${rel}`, + 'Wanted: root holds only the sanctioned flow entries and grandfathered residents (naming law 1)', + 'Fix: move it under its tier (publish-infra//, bootstrap/, lib/, _shared/) or add it to ROOT_ENTRY_ALLOWLIST with the reason.', + ].join('\n'), + ) + } + } + + // 6. Naming law 6: .mts everywhere; .mjs only for system-Node scripts, each + // TypeScript-importable one carrying a .d.mts sidecar. + for (let i = 0, { length } = payloadFiles; i < length; i += 1) { + const rel = payloadFiles[i]! + if (DISALLOWED_CODE_EXT.test(rel)) { + failures.push( + [ + 'What: a payload code file uses a non-.mts extension.', + `Where: release-kit/payload/scripts/socket-release/${rel}`, + `Saw: ${rel}`, + 'Wanted: .mts everywhere (naming law 6); .mjs only for system-Node scripts', + 'Fix: rename to .mts, or (for a system-Node script) to .mjs with a .d.mts sidecar.', + ].join('\n'), + ) + continue + } + if (!rel.endsWith('.mjs')) { + continue + } + if (!SYSTEM_NODE_MJS.some(re => re.test(rel))) { + failures.push( + [ + 'What: a .mjs script lives outside the sanctioned system-Node homes.', + `Where: release-kit/payload/scripts/socket-release/${rel}`, + `Saw: ${rel}`, + 'Wanted: .mjs only for the registry liveness gate job or a composite-action minter (naming law 6)', + 'Fix: convert it to .mts, or add its home to SYSTEM_NODE_MJS with the reason.', + ].join('\n'), + ) + continue + } + // The system-Node .mjs at the payload root is the registry liveness gate; + // TypeScript imports it, so it needs a .d.mts sidecar. The composite-action + // minter nested under templates/actions/ is never TypeScript-imported. + if (path.dirname(rel) === '.') { + const sidecar = rel.replace(/\.mjs$/, '.d.mts') + if (!payloadFiles.includes(sidecar)) { + failures.push( + [ + 'What: a TypeScript-importable .mjs is missing its .d.mts sidecar.', + `Where: release-kit/payload/scripts/socket-release/${rel}`, + `Saw: no ${sidecar}`, + 'Wanted: every .mjs imported from TypeScript carries a .d.mts sidecar (naming law 6)', + 'Fix: add the .d.mts sidecar next to the .mjs.', + ].join('\n'), + ) + } + } + } + + if (failures.length > 0) { + logger.fail(failures.join('\n\n')) + process.exitCode = 1 + return + } + logger.success( + `release-kit is coherent — ${payloadFiles.length} payload files checked.`, + ) +} + +main() diff --git a/scripts/repo/check/release-kit-launches-are-sanctioned.mts b/scripts/repo/check/release-kit-launches-are-sanctioned.mts new file mode 100644 index 00000000..ac54b1d4 --- /dev/null +++ b/scripts/repo/check/release-kit-launches-are-sanctioned.mts @@ -0,0 +1,215 @@ +/* + * @file `check --all` gate: every playwright launch in the release-kit + * payload goes through the ONE sanctioned session module, with no + * automation flags and no bare `chromium.launch`. The scanner logic is the + * wheelhouse's proven text scan (comment stripping so a docblock quoting + * the launch shape never counts; string literals preserved so an explicit + * `--no-sandbox` is still caught). Also asserts the kit's own law module + * accepts its own lawful launch shape: `lawViolations(lawfulLaunchOptions())` + * must be empty, so the shipped law and the shipped launch can never drift + * apart. + */ + +import { readFileSync } from 'node:fs' +import * as path from 'node:path' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +import { + lawfulLaunchOptions, + lawViolations, +} from '../../../release-kit/payload/scripts/socket-release/_shared/playwright-law.mts' +import { + PAYLOAD_ROOT, + walkPayload, +} from '../../../release-kit/install/seams.mts' + +const logger = getDefaultLogger() + +/** + * The ONE payload module allowed to call `launchPersistentContext`. + */ +export const KIT_LAUNCH_ALLOWLIST: readonly string[] = [ + 'release-kit/payload/scripts/socket-release/publish-infra/npm/browser-session.mts', +] + +const SANCTIONED_IGNORED_DEFAULT_ARGS: readonly string[] = [ + '--enable-automation', + '--use-mock-keychain', +] + +/** + * `text` with comments blanked (string contents preserved). Pure — exported + * for tests. + */ +export function stripComments(text: string): string { + let out = '' + let i = 0 + const { length } = text + while (i < length) { + const two = text.slice(i, i + 2) + if (two === '//') { + const nl = text.indexOf('\n', i) + i = nl === -1 ? length : nl + continue + } + if (two === '/*') { + const close = text.indexOf('*/', i + 2) + i = close === -1 ? length : close + 2 + continue + } + const ch = text[i]! + if (ch === "'" || ch === '"' || ch === '`') { + out += ch + i += 1 + while (i < length) { + const c = text[i]! + out += c + i += 1 + if (c === '\\') { + if (i < length) { + out += text[i]! + i += 1 + } + continue + } + if (c === ch) { + break + } + } + continue + } + out += ch + i += 1 + } + return out +} + +export function importsPlaywright(text: string): boolean { + return /from\s+['"]playwright(?:-core)?['"]/.test(text) +} + +export interface KitLaunchViolation { + detail: string + relPath: string +} + +/** + * Every sanctioned-launch violation in one payload file's text. Pure — + * exported for tests. + */ +export function scanKitPlaywrightUsage(config: { + relPath: string + text: string +}): KitLaunchViolation[] { + const cfg = { __proto__: null, ...config } as typeof config + const { relPath } = cfg + if (!importsPlaywright(cfg.text)) { + return [] + } + const text = stripComments(cfg.text) + const allowed = KIT_LAUNCH_ALLOWLIST.includes(relPath) + const violations: KitLaunchViolation[] = [] + if (/\bchromiumSandbox\s*:(?!\s*true\b)/.test(text)) { + violations.push({ + detail: 'sets `chromiumSandbox` to something other than `true`', + relPath, + }) + } + // A quoted launch flag: an opening quote, `--`, one of the sandbox or + // automation flag names, then the closing quote. + const sandboxArg = + /['"]--(?:disable-(?:blink-features|dev-shm-usage|setuid-sandbox)|no-sandbox)['"]/.exec( + text, + ) + if (sandboxArg) { + violations.push({ + detail: `passes the launch flag ${sandboxArg[0]} explicitly`, + relPath, + }) + } + // The ignoreDefaultArgs option with its value: either the literal `true` + // or a bracketed list, captured for the sanctioned-pair comparison. + const ignoreArgs = /\bignoreDefaultArgs\s*:\s*(true\b|\[[^\]]*\])/.exec(text) + if (ignoreArgs) { + const value = ignoreArgs[1]! + const entries = + value === 'true' + ? undefined + : [...value.matchAll(/['"`]([^'"`]+)['"`]/g)].map(m => m[1]!) + const sanctioned = + entries !== undefined && + entries.length === SANCTIONED_IGNORED_DEFAULT_ARGS.length && + SANCTIONED_IGNORED_DEFAULT_ARGS.every(flag => entries.includes(flag)) + if (!sanctioned) { + violations.push({ + detail: `sets ignoreDefaultArgs to ${value.replaceAll(/\s+/g, ' ')} — only the sanctioned pair is lawful`, + relPath, + }) + } + } + if (/\bchromium\s*\.\s*launch\s*\(/.test(text) && !allowed) { + violations.push({ + detail: 'calls bare `chromium.launch(` — use the sanctioned session', + relPath, + }) + } + if (/\blaunchPersistentContext\s*\(/.test(text) && !allowed) { + violations.push({ + detail: `calls launchPersistentContext outside ${KIT_LAUNCH_ALLOWLIST[0]}`, + relPath, + }) + } + return violations +} + +function main(): void { + const failures: string[] = [] + const files = walkPayload().filter(f => f.endsWith('.mts')) + let scanned = 0 + for (let i = 0, { length } = files; i < length; i += 1) { + const rel = files[i]! + const text = readFileSync(path.join(PAYLOAD_ROOT, rel), 'utf8') + if (!importsPlaywright(text)) { + continue + } + scanned += 1 + const relPath = `release-kit/payload/scripts/socket-release/${rel}` + const violations = scanKitPlaywrightUsage({ relPath, text }) + for (let v = 0, { length: vl } = violations; v < vl; v += 1) { + failures.push( + [ + 'What: an unsanctioned playwright launch in the kit payload.', + `Where: ${violations[v]!.relPath}`, + `Saw: ${violations[v]!.detail}.`, + `Wanted: every launch through ${KIT_LAUNCH_ALLOWLIST[0]}.`, + 'Fix: import openNpmBrowserSession from the sanctioned module instead of launching here.', + ].join('\n'), + ) + } + } + // The kit's own law must accept its own lawful launch shape. + const drift = lawViolations(lawfulLaunchOptions()) + if (drift.length > 0) { + failures.push( + [ + 'What: the kit playwright law refuses its own lawful launch options.', + 'Where: release-kit/payload/scripts/socket-release/_shared/playwright-law.mts', + `Saw: ${drift.join('; ')}`, + 'Wanted: lawViolations(lawfulLaunchOptions()) === []', + 'Fix: reconcile lawfulLaunchOptions with lawViolations in the same commit.', + ].join('\n'), + ) + } + if (failures.length > 0) { + logger.fail(failures.join('\n\n')) + process.exitCode = 1 + return + } + logger.success( + `release-kit launches are sanctioned — ${scanned} playwright-importing payload file(s) checked; law self-check clean.`, + ) +} + +main() diff --git a/scripts/repo/check/release-kit-types-resolve.mts b/scripts/repo/check/release-kit-types-resolve.mts new file mode 100644 index 00000000..0997850b --- /dev/null +++ b/scripts/repo/check/release-kit-types-resolve.mts @@ -0,0 +1,52 @@ +/* + * @file Release-tier gate: the release-kit tree — payload engine, installer, + * gen-manifest — typechecks under the fleet compiler settings. The fleet + * `pnpm run type` scopes to `scripts/**`, so without this the payload's + * types would only ever be checked by hand. Held to the release tier + * (pre-push / CI, where check.mts sets FLEET_CHECK_RELEASE=1) because a + * full tsc program load is an inner-loop long pole. + */ + +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' +import { spawnSync } from '@socketsecurity/lib/process/spawn/child' + +const logger = getDefaultLogger() +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', +) + +function main(): void { + if (process.env['FLEET_CHECK_RELEASE'] !== '1') { + logger.log( + 'release-kit typecheck held to the release tier (pre-push / CI / --release).', + ) + return + } + const result = spawnSync( + process.execPath, + [ + 'node_modules/typescript/bin/tsc', + '--noEmit', + '-p', + '.config/repo/tsconfig.release-kit.json', + ], + { cwd: REPO_ROOT, stdio: 'inherit' }, + ) + if (result.status !== 0) { + logger.fail( + 'release-kit typecheck failed — run `node node_modules/typescript/bin/tsc --noEmit -p .config/repo/tsconfig.release-kit.json`.', + ) + process.exitCode = 1 + return + } + logger.success('release-kit typechecks clean.') +} + +main() diff --git a/scripts/repo/check/release-kit-workflows-are-env-mapped.mts b/scripts/repo/check/release-kit-workflows-are-env-mapped.mts new file mode 100644 index 00000000..d2b09d3a --- /dev/null +++ b/scripts/repo/check/release-kit-workflows-are-env-mapped.mts @@ -0,0 +1,180 @@ +/* + * @file `check --all` gate: the kit's workflow templates hold the fleet + * zizmor posture. Per templates/workflows/*.yml: (1) no `${{` inside any + * `run:` scalar — inputs reach shell through env-mapped variables, never + * expression interpolation, the classic injection shape; (2) every + * third-party `uses:` is SHA-pinned (40 hex) with a trailing + * `# (YYYY-MM-DD)` comment so the pin is auditable; (3) top-level + * `permissions:` and `concurrency:` blocks are present. Pure line-scan + * helpers exported for tests. + */ + +import { readdirSync, readFileSync } from 'node:fs' +import * as path from 'node:path' +import process from 'node:process' + +import { getDefaultLogger } from '@socketsecurity/lib/logger/default' + +import { PAYLOAD_ROOT } from '../../../release-kit/install/seams.mts' + +const logger = getDefaultLogger() + +export interface WorkflowViolation { + detail: string + file: string + line: number +} + +/** + * Every `${{` occurrence inside a `run:` scalar (single-line or block). + * Pure — exported for tests. + */ +export function findRunExpressionViolations( + file: string, + text: string, +): WorkflowViolation[] { + const violations: WorkflowViolation[] = [] + const lines = text.split('\n') + let inRunBlock = false + let runIndent = -1 + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! + // A run: key with its leading indent (group 1) and inline value + // (group 2) — a block indicator or a single-line scalar. + const runStart = /^(\s*)run:\s*(.*)$/.exec(line) + if (runStart) { + const rest = runStart[2] ?? '' + if (rest === '>' || rest === '>-' || rest === '|' || rest === '|-') { + inRunBlock = true + runIndent = runStart[1]!.length + continue + } + if (rest.includes('${{')) { + violations.push({ + detail: 'a `${{ }}` expression inside a run: scalar', + file, + line: i + 1, + }) + } + inRunBlock = false + continue + } + if (inRunBlock) { + const indent = /^(\s*)/.exec(line)![1]!.length + if (line.trim() !== '' && indent <= runIndent) { + inRunBlock = false + } else if (line.includes('${{')) { + violations.push({ + detail: 'a `${{ }}` expression inside a run: block', + file, + line: i + 1, + }) + } + } + } + return violations +} + +const PINNED_USES = + /^\s*(?:-\s+)?uses:\s+\S+@[0-9a-f]{40}\s+#\s+\S+\s+\(\d{4}-\d{2}-\d{2}\)\s*$/ + +/** + * Every `uses:` line that is not a lawfully SHA-pinned, date-commented + * reference. Local composite actions (`./…`) are exempt — there is nothing + * to pin. Pure — exported for tests. + */ +export function findUsesPinViolations( + file: string, + text: string, +): WorkflowViolation[] { + const violations: WorkflowViolation[] = [] + const lines = text.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]! + if (!/^\s*(?:-\s+)?uses:\s+/.test(line)) { + continue + } + if (/^\s*(?:-\s+)?uses:\s+\.\//.test(line)) { + continue + } + if (!PINNED_USES.test(line)) { + violations.push({ + detail: + 'an unpinned or undated `uses:` — wanted `@<40-hex-sha> # (YYYY-MM-DD)`', + file, + line: i + 1, + }) + } + } + return violations +} + +/** + * Missing top-level permissions/concurrency blocks. Pure — exported for + * tests. + */ +export function findMissingBlocks( + file: string, + text: string, +): WorkflowViolation[] { + const violations: WorkflowViolation[] = [] + if (!/^permissions:/m.test(text)) { + violations.push({ + detail: 'no top-level permissions: block', + file, + line: 1, + }) + } + if (!/^concurrency:/m.test(text)) { + violations.push({ + detail: 'no top-level concurrency: block', + file, + line: 1, + }) + } + return violations +} + +export function scanWorkflowTemplate( + file: string, + text: string, +): WorkflowViolation[] { + return [ + ...findRunExpressionViolations(file, text), + ...findUsesPinViolations(file, text), + ...findMissingBlocks(file, text), + ] +} + +function main(): void { + const dir = path.join(PAYLOAD_ROOT, 'templates', 'workflows') + const files = readdirSync(dir).filter(f => f.endsWith('.yml')) + const failures: string[] = [] + for (let i = 0, { length } = files; i < length; i += 1) { + const file = files[i]! + const text = readFileSync(path.join(dir, file), 'utf8') + const violations = scanWorkflowTemplate(file, text) + for (let v = 0, { length: vl } = violations; v < vl; v += 1) { + const violation = violations[v]! + failures.push( + [ + 'What: a kit workflow template breaks the env-mapped posture.', + `Where: release-kit/payload/scripts/socket-release/templates/workflows/${violation.file}:${violation.line}`, + `Saw: ${violation.detail}.`, + 'Wanted: env-mapped inputs (no ${{ in run bodies), SHA-pinned dated uses:, permissions: + concurrency: present.', + 'Fix: map the input into env: and reference it as "$VAR" in the run body; pin and date the action.', + ].join('\n'), + ) + } + } + if (failures.length > 0) { + logger.fail(failures.join('\n\n')) + process.exitCode = 1 + return + } + logger.success( + `release-kit workflow templates are env-mapped — ${files.length} template(s) checked.`, + ) +} + +main() diff --git a/scripts/repo/check/shipped-content-is-consumer-clean.mts b/scripts/repo/check/shipped-content-is-consumer-clean.mts index ad70eb44..da5f7a1a 100644 --- a/scripts/repo/check/shipped-content-is-consumer-clean.mts +++ b/scripts/repo/check/shipped-content-is-consumer-clean.mts @@ -23,6 +23,7 @@ import { FLEET_INTERNAL_MARKERS, SCAFFOLDING_ENTRIES, SHIPPED_DIRS, + SHIPPED_MARKER_ALLOWLIST, SHIPPED_ROOT_FILES, } from '../constants/shipped-surfaces.mts' @@ -52,6 +53,18 @@ export function findUnclassifiedEntries(tracked: string[]): string[] { return [...topLevel].filter(entry => !classified.has(entry)).toSorted() } +/** + * True when `marker` appearing in `rel` is a documented carve-out rather than + * a leak — see SHIPPED_MARKER_ALLOWLIST for why each pair is load-bearing. + */ +export function isAllowlistedMarker(rel: string, marker: string): boolean { + return SHIPPED_MARKER_ALLOWLIST.some( + entry => + entry.marker === marker && + (entry.paths as readonly string[]).includes(rel), + ) +} + export function findFleetLeaks(tracked: string[]): string[] { const leaks: string[] = [] const shipped = tracked.filter(f => @@ -66,6 +79,9 @@ export function findFleetLeaks(tracked: string[]): string[] { m += 1 ) { const marker = FLEET_INTERNAL_MARKERS[m]! + if (isAllowlistedMarker(rel, marker)) { + continue + } const idx = content.indexOf(marker) if (idx !== -1) { const line = content.slice(0, idx).split('\n').length diff --git a/scripts/repo/constants/shipped-surfaces.mts b/scripts/repo/constants/shipped-surfaces.mts index 789a7e23..651ee545 100644 --- a/scripts/repo/constants/shipped-surfaces.mts +++ b/scripts/repo/constants/shipped-surfaces.mts @@ -11,7 +11,7 @@ /** * Trees consumers install or copy — Socket-integration content only. */ -export const SHIPPED_DIRS = ['agents', 'skills'] as const +export const SHIPPED_DIRS = ['agents', 'release-kit', 'skills'] as const /** * Consumer-facing manifests and adapters at the root: generated for the @@ -56,6 +56,23 @@ export const SCAFFOLDING_ENTRIES = [ 'tsconfig.json', ] as const +/** + * Narrow carve-outs from {@link FLEET_INTERNAL_MARKERS}: an exact + * marker/path pair whose appearance in a shipped file is load-bearing, with + * the reason it cannot be renamed. Anything not listed here is a leak. + */ +export const SHIPPED_MARKER_ALLOWLIST = [ + { + marker: 'socket-wheelhouse', + paths: [ + 'release-kit/payload/scripts/socket-release/_shared/playwright-law.mts', + 'release-kit/payload/scripts/socket-release/publish-infra/npm/browser-session.mts', + 'release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-browser.mts', + ], + why: 'the ONE durable Chrome profile every Socket npm browser tool shares lives at ~/.config/socket-wheelhouse/staged-browser-profile; renaming it would sign every already-authenticated operator out.', + }, +] as const + /** * Strings whose appearance in a SHIPPED file leaks fleet internals to * consumers. Kept as plain substrings so the scan stays greppable. diff --git a/skills/socket-release/SKILL.md b/skills/socket-release/SKILL.md new file mode 100644 index 00000000..3d283934 --- /dev/null +++ b/skills/socket-release/SKILL.md @@ -0,0 +1,78 @@ +--- +name: socket-release +description: + Stand up SocketDev publishing (npm, crates.io, GitHub releases, Homebrew + tap) in a repo — copy in the socket-release kit from a sauce checkout and + run its bootstrap through name reservation, GitHub environments, npm + trusted publisher, publishing-access tightening, staged publish config, + and verification. +disable-model-invocation: true +--- + +# Socket Release Setup + +Install the socket-release kit into the current repo and stand publishing +up. Everything destructive is dry-run by default; the bootstrap prints the +exact next command after every run, and every human moment renders as a +🖐 HUMAN GATE — stop and show it, never improvise around it. + +## Steps + +1. **Get the kit source.** Shallow-clone sauce to the canonical clone home: + + ``` + git clone --depth=1 --single-branch https://github.com/SocketDev/sauce.git ~/.socket/_wheelhouse/repo-clones/SocketDev-sauce + ``` + + Done when: `~/.socket/_wheelhouse/repo-clones/SocketDev-sauce/release-kit/install.mts` exists. + +2. **Install the kit.** Plan first, then apply with the channels this repo + publishes on (`npm`, `crates`, `github-release`, `brew`): + + ``` + node ~/.socket/_wheelhouse/repo-clones/SocketDev-sauce/release-kit/install.mts --target . --channels npm,github-release + node ~/.socket/_wheelhouse/repo-clones/SocketDev-sauce/release-kit/install.mts --target . --channels npm,github-release --apply + ``` + + Done when: the same command with `--verify` exits 0. + +3. **Pin the kit dependencies.** The payload imports plain specifiers; add + the exact pins: + + ``` + pnpm add -D @socketsecurity/lib@6.5.2 @socketsecurity/sdk@4.1.3 playwright-core@1.61.1 + ``` + + Done when: `node scripts/socket-release/bootstrap.mts preflight` shows + the `kit-deps-resolvable` check passing. + +4. **Bootstrap.** Run the plan, then follow `nextCommand` and the gates: + + ``` + node scripts/socket-release/bootstrap.mts + node scripts/socket-release/bootstrap.mts --apply + ``` + + The run stops at human gates (reserve-name consent, npm web-2FA, + staged-placeholder promote, GitHub 403 fallback) — render the gate and + wait. Done when: `node scripts/socket-release/bootstrap.mts verify` + exits 0 and reports the stood-up detail (trusted publisher conforming, + environments restricted, publishing access staged-only). + +## Browser law + +Playwright browser law (verbatim, non-negotiable): + +- Launch ONLY via openNpmBrowserSession (scripts/socket-release/publish-infra/npm/browser-session.mts) on the durable profile ~/.config/socket-wheelhouse/staged-browser-profile. +- The launch shape is channel + chromiumSandbox: true + headless + the two sanctioned ignoreDefaultArgs entries, and nothing else — never an args array, never a sandbox-disabling flag. +- Login is NEVER scripted: the operator signs in once in the headed window; no password, OTP, or cookie passes through the process. +- All npm browser tools share the ONE durable profile so a single sign-in covers every tool. +- npm auth is decided by the /-/whoami BODY on the website origin, never the HTTP status. +- A human-verification challenge PAUSES the run for the operator with a visible countdown and is never retried blindly. + +## Operating the channels + +- **npm staged publishing**: see [npm-publish](npm-publish/SKILL.md) +- **GitHub releases + ORDER RULE**: see [gh-release](gh-release/SKILL.md) +- **crates.io staged model**: see [crates-publish](crates-publish/SKILL.md) +- **Homebrew tap bumps**: see [brew-tap](brew-tap/SKILL.md) diff --git a/skills/socket-release/brew-tap/SKILL.md b/skills/socket-release/brew-tap/SKILL.md new file mode 100644 index 00000000..3ce2bee3 --- /dev/null +++ b/skills/socket-release/brew-tap/SKILL.md @@ -0,0 +1,77 @@ +--- +name: brew-tap +description: Operate the socket-release Homebrew tap flow — the binary-download + formula model, tap repo layout, formula bumps tied to published releases, + and sha256 verification against the release's own checksums.txt. Use when + bumping a Homebrew formula or standing up a tap for a Socket CLI. +--- + +# Homebrew tap (binary-download formula) + +Model: no bottles, no source build — the formula downloads the release's +prebuilt per-platform tarballs by EXACT +`releases/download/v/` URL (never `latest`) and pins the +sha256 the release's own `checksums.txt` vouched for. brew-publish NEVER +hashes an asset itself; the manifest is the authority. + +## Tap layout (one-time, manual) + +The tap repo (`SocketDev/homebrew-socket`) carries an unsharded `Formula/` +dir plus a README documenting tap trust: + +``` +export HOMEBREW_REQUIRE_TAP_TRUST=1 +brew trust SocketDev/socket +``` + +The exact layout is modeled by `release-kit/examples/brew-cli/tap-fixture/` +in sauce. Creating the tap repo is a human act (repo creation rights) — +note it plainly; there is no script. + +## Bump cycle + +Prerequisite: the release is CUT — tag on origin, release published (not +draft), all four platform assets uploaded, `checksums.txt` attached (the +gh-release flow produces all of this). + +``` +node scripts/socket-release/brew-publish.mts --tag vX.Y.Z # dry-run plan +node scripts/socket-release/brew-publish.mts --tag vX.Y.Z --apply # commit the bump +``` + +The tool refuses, in order, with exit 1 and zero writes: a tag not on +origin (it never creates tags), a draft/missing release, a missing +templated asset, a missing/incomplete `checksums.txt`. An identical formula +is a no-op ("already reads ", exit 0). `--apply` commits the +formula DIRECT to the tap default branch with a GitHub-signed API commit +(never a PR — the version-bump-PR shape is guard-blocked), then re-reads +the tap: the committed bytes must parse back to the desired formula, or it +exits 1 saved-state-unproven. + +From CI, `brew-publish.yml` runs on `release: published` (or manual +dispatch with `tag` + `publish: true`) and mints a per-run App token via +the `./.github/actions/socket-release-app-token` composite from the +org-wide App credentials — org secrets are enterprise-wide; never treat +them as missing setup or a human task. + +## sha256 verification + +The formula's four sha256s come from `checksums.txt` — verify the chain, +never re-hash locally as authority: + +``` +gh release download vX.Y.Z --pattern checksums.txt --output - +``` + +Both grammars count: plain ` ` (shasum) and +`sha256: ` (the kit release tail). A duplicate filename with +differing hex is a hard refusal. After a bump, spot-check one platform: +the `url` in `Formula/.rb` must name the exact `v` download +path and its `sha256` must equal the manifest line for that asset. + +## Auth moments (gate, never improvise) + +- Local `--apply` uses ambient `gh` auth: if `gh auth status` fails, the + operator runs `gh auth login` (browser) — render the gate. +- Manual audits run on an operator Mac: `brew style` / `brew audit` against + the tap (deferred from CI). diff --git a/skills/socket-release/crates-publish/SKILL.md b/skills/socket-release/crates-publish/SKILL.md new file mode 100644 index 00000000..92e50f50 --- /dev/null +++ b/skills/socket-release/crates-publish/SKILL.md @@ -0,0 +1,71 @@ +--- +name: crates-publish +description: Operate the socket-release crates.io flow — the cargo staged model + (dry-run default), trusted publishing via OIDC under the cargo-publish + environment, index-propagation waits, and yank-as-rollback. Use when + publishing a Rust crate in a repo carrying scripts/socket-release/. +--- + +# crates.io publish (staged model) + +crates.io publishes are PERMANENT (yank-only, no unpublish), so the kit's +cargo flow is dry-run by default everywhere and the real publish runs only +under the `cargo-publish` GitHub environment through crates.io Trusted +Publishing (OIDC — no long-lived token anywhere). + +## Bootstrap + +The same bootstrap stands up the `cargo-publish` environment +(branch-restricted) and installs `cargo-publish.yml`: + +``` +node scripts/socket-release/bootstrap.mts github-env staged-config --apply +``` + +crates.io's trusted-publisher config (crate ↔ repo ↔ workflow ↔ +environment) is set on crates.io's settings page by the crate owner — a +human step; render it as a gate with the crate's settings URL, do not +improvise a browser drive. + +## Release cycle + +1. Bump: `Cargo.toml` version + CHANGELOG, commit + `chore: bump version to `, push. +2. Dry-run locally: + + ``` + node scripts/socket-release/cargo-publish.mts --staged --dry-run + ``` + +3. Publish from CI: the operator dispatches the `cargo publish` workflow + from the Actions UI with `publish: true` (`gh workflow run` is + guard-blocked for agents — the human clicks). The workflow runs + `--direct` under the `cargo-publish` environment with the OIDC-minted + token; a dry-run dispatch runs `--staged --dry-run` ungated. +4. Index propagation: the publish is not "done" until the version appears + in the crates.io index — the engine's registry gate polls + `https://index.crates.io/` and the tag/release cut waits for it + (ORDER RULE: the GitHub release follows index resolvability, never + precedes it). Do not retry a publish that is merely propagating. +5. The tag + immutable release cut follows automatically; a gap heals with + `node scripts/socket-release/github-release.mts --tag vX.Y.Z --release`. + +## Local emergencies + +`node scripts/socket-release/cargo-publish.mts --direct` publishes from the +operator's machine with their own `cargo login` token — 2FA/auth is theirs +to provide (gate, never scripted). `--approve` promotes a staged cargo +entry where the staged lane is available; `--package ` disambiguates +a workspace (multi-crate ordering is deferred — the tool refuses ambiguity +rather than guessing). + +## Rollback = yank + +``` +cargo yank --version X.Y.Z # from the crate root, operator auth +cargo yank --version X.Y.Z --undo +``` + +Yank never deletes bytes — existing lockfiles keep resolving; new +resolutions skip the version. Ship the fixed version immediately after, and +deprecate nothing (crates.io has no deprecate). diff --git a/skills/socket-release/gh-release/SKILL.md b/skills/socket-release/gh-release/SKILL.md new file mode 100644 index 00000000..00242c5d --- /dev/null +++ b/skills/socket-release/gh-release/SKILL.md @@ -0,0 +1,66 @@ +--- +name: gh-release +description: Cut, verify, and reconcile immutable GitHub releases with the + socket-release kit — the registry-resolvability ORDER RULE, the + three-step draft-upload-undraft cut, checksums.txt production, and tag-gap + healing. Use when tagging a release, healing a missing tag/release, or + when the github-release workflow gate refuses a tag. +--- + +# GitHub release (immutable, registry-gated) + +ORDER RULE (non-negotiable): the immutable GitHub release is the FINAL +marker of a release. It can only follow a version that already resolves on +its registry — never precede one. `requireRegistryLive` enforces this in +every path; the `github-release.yml` workflow's `gate` job refuses a pushed +tag whose version is not live (`registry-liveness-gate.mjs`, zero-dep, runs +before any install). + +## Normal path (automatic) + +The npm/cargo promote tail cuts the tag + release itself: after +`node scripts/socket-release/npm-publish.mts --approve` promotes and the +version resolves live, the engine tags `v`, pushes the tag, and +cuts the release. Nothing to run by hand when that succeeds. + +## Cutting a release with assets + +`github-release.mts --release` performs the fleet-canonical three-step immutable +cut — `gh release create --draft --verify-tag` → upload assets → `gh release +edit --draft=false` — and writes a `checksums.txt` manifest (sha1 + sha256 + +sha512) alongside the tarball, so the GitHub-release digest stays directly +comparable to the npm published shasum: + +``` +node scripts/socket-release/github-release.mts --tag vX.Y.Z --release +``` + +Never hand-run `gh release create` without `--draft`: the release goes +immutable the instant it publishes, so assets and checksums must be attached +while drafted. + +## Healing a release gap + +A RELEASE GAP is a version public on its registry with the `v` tag +or GitHub release missing. Re-running `--approve` does NOT heal it (the +approve leg drops already-published versions before the tag step). The +healer is: + +``` +node scripts/socket-release/github-release.mts --tag vX.Y.Z # dry-run: confirms liveness +node scripts/socket-release/github-release.mts --tag vX.Y.Z --release # cuts tag + release +``` + +It refuses (exit 1) when the version is not live — an unreachable registry +is never read as unpublished. From CI, the operator dispatches the +`github release` workflow with `tag` + `release: true` from the Actions UI +(`gh workflow run` is guard-blocked for agents — the human clicks; there is +no agent lane for the dispatch itself). + +## Verifying + +- The tag exists on origin: `git ls-remote --tags origin refs/tags/vX.Y.Z` +- The release exists and is not a draft: + `gh release view vX.Y.Z --json isDraft,assets` +- `checksums.txt` is attached when any binary assets are (the brew channel + hard-requires it). diff --git a/skills/socket-release/npm-publish/SKILL.md b/skills/socket-release/npm-publish/SKILL.md new file mode 100644 index 00000000..08e2fe4b --- /dev/null +++ b/skills/socket-release/npm-publish/SKILL.md @@ -0,0 +1,94 @@ +--- +name: npm-publish +description: + Operate the socket-release npm flow end to end — bootstrap a package + (name reservation, permissive-then-staged-only publishing access, trusted + publishing), dispatch a staged publish, promote with --approve, + backfill an old version, and roll back with deprecate. Use when + publishing an npm package in a repo carrying scripts/socket-release/. +--- + +# npm publish (staged) + +The kit's npm model: CI STAGES the publish through trusted publishing +(OIDC, environment `npm-publish`); a human PROMOTES it locally after +byte-verification. Direct publishing is disabled after bootstrap — staged +is the only path. Dry-run is every command's default. + +## One-time bootstrap + +``` +node scripts/socket-release/bootstrap.mts # plan +node scripts/socket-release/bootstrap.mts --apply # stand up, stops at gates +node scripts/socket-release/bootstrap.mts --status # receipts +``` + +Steps run in canonical order: preflight → placeholder → +npm-access-permissive → github-env → staged-config → trusted-publisher → +npm-access-staged-only → verify. Two of them are npm publishing-access +steps: PERMISSIVE first (direct + staged enabled, only while the 0.0.0 +placeholder is pending, so the one-time direct publish can land), then +STAGED-ONLY (direct publishing unchecked in the npm web UI once trusted +publishing stands). A re-run never re-widens; `verify` FAILS a package left +permissive and names the fix +(`node scripts/socket-release/bootstrap.mts npm-access-staged-only --apply`). + +STOP AND GATE, never improvise, at these moments: + +- **reserve name** — publishing `@0.0.0` is irreversible; only + `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve ` + performs it (the bootstrap renders the gate). +- **npm web-2FA** — the PTY prints `APPROVE HERE (expires in minutes): `; + the operator approves in their browser, the command keeps waiting. +- **placeholder promote** — a staged 0.0.0 needs + `node scripts/socket-release/npm-publish.mts --approve` plus the + operator's 2FA. +- **npm auth dead** — `node scripts/socket-release/npm-web-auth.mts login` + (both lanes run the same router command). + +## Release cycle + +1. Bump: edit `version` + CHANGELOG, commit + `chore: bump version to ` (load-bearing subject), push. +2. Stage from CI: the operator dispatches the `npm publish` workflow from + the Actions UI with `publish: true` (dry-run is the dispatch default; + `gh workflow run` is guard-blocked for agents — the human clicks). +3. Soak: staged entries are maintainer-visible only. Inspect with + `pnpm stage list --json` — an unauthenticated or wrong-account list + reads as EMPTY, not as an error, so identity-check first + (`node scripts/socket-release/npm-web-auth.mts login`). +4. Promote: `node scripts/socket-release/npm-publish.mts --approve` — it + byte-verifies the staged tarball, promotes through the operator's 2FA, + then cuts the git tag + immutable GitHub release once the version + resolves live (ORDER RULE — registry first, release marker last). +5. Verify: the version resolves on the registry and the release exists; a + missing tag/release heals with + `node scripts/socket-release/github-release.mts --tag v --release`. + +## Backfill + +An already-tagged version that never published: a backfill never moves the +`latest` pointer, so it always needs an explicit non-`latest` dist-tag. +Dispatch the workflow with `backfill-version: X.Y.Z` + `checkout-ref: vX.Y.Z` +\+ `dist-tag: backfill` (any non-`latest` tag), or locally + +``` +node scripts/socket-release/npm-publish.mts --staged --backfill X.Y.Z --checkout-ref vX.Y.Z --tag backfill --dry-run +``` + +Drop `--dry-run` only after the plan reads clean; promotion is the same +`--approve` path. + +## Rollback + +npm publishes are permanent (unpublish closes at 72h and burns nothing +back). Roll back by deprecating the bad version and shipping a fixed one: + +``` +npm deprecate @ "broken — use " +``` + +Deprecation needs the operator's npm auth (2FA) — gate, do not improvise. +A never-promoted staged entry needs no rollback: reject it with +`node scripts/socket-release/npm-web-auth.mts stage reject ` and +re-stage. diff --git a/test/repo/integration/release-kit/bootstrap.test.mts b/test/repo/integration/release-kit/bootstrap.test.mts new file mode 100644 index 00000000..7a9715de --- /dev/null +++ b/test/repo/integration/release-kit/bootstrap.test.mts @@ -0,0 +1,241 @@ +/** + * @file Full in-process bootstrap runs over the installed npm-lib scenario + * with fully fake seams: the plan run (golden, exit 0), the + * staged-placeholder block (golden, exit 3, gate lines pass the shape + * assertions), the apply-without-reserve block (golden, exit 3, ZERO + * publish effects), the DAG violation (exit 4), the status run (golden, + * exit 0), stdout purity in --json mode, and plan-mode zero-writes. Every + * emitted document and every committed golden passes validateRunJson. + * Plus the spawn smokes: --help → 0, unknown step → 2, --apply --dry-run + * → 2 (bootstrap) and --help → 0 (installer). + */ + +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { runBootstrap } from '../../../../release-kit/payload/scripts/socket-release/bootstrap.mts' +import { validateRunJson } from '../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import type { RunJson } from '../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import { + ROOT, + buildScenario, + normalizeRunDoc, + runFixture, +} from './scenarios.mts' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.join(HERE, '../../../..') + +interface CapturedRun { + doc: RunJson + exitCode: number + humanLines: string[] + stdout: string +} + +async function capture(config: { + argv: string[] + scenario?: Parameters[0] +}): Promise { + const scenario = buildScenario(config.scenario) + // Every run gets a real temp dir (receipts persist there under --apply; + // plan mode must leave it untouched); the fake file map shadows reads. + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-bootstrap-')) + const realRead = scenario.seams.readFile.bind(scenario.seams) + scenario.seams.readFile = p => + realRead(p.replace(repoRoot, ROOT)) ?? realRead(p) + let stdout = '' + const humanLines: string[] = [] + const exitCode = await runBootstrap({ + argv: config.argv, + log: line => humanLines.push(line), + out: text => { + stdout += text + }, + repoRoot, + seams: scenario.seams, + }) + const doc = JSON.parse(stdout || 'null') as RunJson + if (doc && typeof doc === 'object' && doc.repo) { + // The temp dir is run-unique; goldens pin the canonical scenario root. + doc.repo.root = ROOT + } + return { doc, exitCode, humanLines, stdout } +} + +function normalizeForGolden(doc: RunJson): unknown { + return normalizeRunDoc(doc) +} + +describe('bootstrap integration (fake seams)', () => { + it('plan run matches run-plan.golden.json and exits 0', async () => { + const run = await capture({ argv: ['--json'] }) + expect(run.exitCode).toBe(0) + expect(validateRunJson(run.doc)).toEqual([]) + expect(normalizeForGolden(run.doc)).toEqual( + JSON.parse(runFixture('run/run-plan.golden.json')), + ) + }) + + it('two consecutive plan runs emit identical documents', async () => { + const first = await capture({ argv: ['--json'] }) + const second = await capture({ argv: ['--json'] }) + expect(normalizeRunDoc(first.doc)).toEqual(normalizeRunDoc(second.doc)) + }) + + it('--json stdout carries EXACTLY one JSON document (human logs on stderr lane)', async () => { + const run = await capture({ argv: ['--json'] }) + expect(() => JSON.parse(run.stdout)).not.toThrow() + expect(run.humanLines.length).toBeGreaterThan(0) + expect(run.stdout.trimEnd().startsWith('{')).toBe(true) + expect(run.stdout.trimEnd().endsWith('}')).toBe(true) + }) + + it('staged placeholder blocks (exit 3) matching run-blocked.golden.json with shaped gate lines', async () => { + const run = await capture({ + argv: ['--apply', '--json'], + scenario: { stage: 'staged' }, + }) + expect(run.exitCode).toBe(3) + expect(validateRunJson(run.doc)).toEqual([]) + const blocked = run.doc.steps.find(s => s.status === 'blocked') + expect(blocked?.step).toBe('placeholder') + // SANCTIONED SHAPE EXCEPTION: gate line prefixes are the fleet contract. + const lines = blocked!.gate!.lines + expect(lines[0]).toMatch(/^🖐 {2}HUMAN GATE — placeholder promote \[1\/1\]$/) + expect(lines.some(l => l.startsWith(' A) You: '))).toBe(true) + expect(lines.some(l => l.startsWith(' B) Me: '))).toBe(true) + expect(lines.at(-1)!.startsWith(' Then: ')).toBe(true) + expect(normalizeForGolden(run.doc)).toEqual( + JSON.parse(runFixture('run/run-blocked.golden.json')), + ) + }) + + it('apply without --reserve blocks on the reserve gate (exit 3) with ZERO publish effects', async () => { + const scenario = buildScenario() + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-bootstrap-')) + const realRead = scenario.seams.readFile.bind(scenario.seams) + scenario.seams.readFile = p => + realRead(p.replace(repoRoot, ROOT)) ?? realRead(p) + let stdout = '' + const exitCode = await runBootstrap({ + argv: ['--apply', '--json'], + log: () => {}, + out: t => { + stdout += t + }, + repoRoot, + seams: scenario.seams, + }) + const doc = JSON.parse(stdout) as RunJson + expect(exitCode).toBe(3) + expect(validateRunJson(doc)).toEqual([]) + expect(scenario.placeholderCalls).toEqual([]) + const blocked = doc.steps.find(s => s.status === 'blocked') + expect(blocked?.gate?.name).toBe('reserve name') + const normalized = normalizeForGolden({ + ...doc, + repo: { ...doc.repo, root: ROOT }, + }) + expect(normalized).toEqual( + JSON.parse(runFixture('run/run-reserve-gate.golden.json')), + ) + }) + + it('a DAG violation exits 4 naming the missing steps and the exact command', async () => { + const run = await capture({ argv: ['trusted-publisher', '--json'] }) + expect(run.exitCode).toBe(4) + const text = run.humanLines.join('\n') + expect(text).toContain('placeholder, github-env, staged-config') + expect(text).toContain( + 'node scripts/socket-release/bootstrap.mts placeholder github-env staged-config --apply', + ) + }) + + it('--status prints the eight-step table from receipts only (exit 0) matching its golden', async () => { + const run = await capture({ argv: ['--status', '--json'] }) + expect(run.exitCode).toBe(0) + expect(validateRunJson(run.doc)).toEqual([]) + expect(run.humanLines.filter(l => l.includes('pending'))).toHaveLength(8) + expect(normalizeForGolden(run.doc)).toEqual( + JSON.parse(runFixture('run/run-status.golden.json')), + ) + }) + + it('plan mode performs zero writes — no state file, no seam mutations', async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-plan-')) + const scenario = buildScenario() + const realRead = scenario.seams.readFile.bind(scenario.seams) + scenario.seams.readFile = p => + realRead(p.replace(repoRoot, ROOT)) ?? realRead(p) + const exitCode = await runBootstrap({ + argv: [], + log: () => {}, + out: () => {}, + repoRoot, + seams: scenario.seams, + }) + expect(exitCode).toBe(0) + expect( + fs.existsSync( + path.join(repoRoot, '.cache/socket-release/bootstrap-state.json'), + ), + ).toBe(false) + expect(scenario.placeholderCalls).toEqual([]) + expect(scenario.calls.filter(c => c.kind === 'execPty')).toEqual([]) + }) + + it('every committed run golden passes validateRunJson', () => { + for (const name of [ + 'run-plan', + 'run-blocked', + 'run-reserve-gate', + 'run-status', + ]) { + const doc = JSON.parse(runFixture(`run/${name}.golden.json`)) + expect(validateRunJson(doc), name).toEqual([]) + } + }) +}) + +describe('CLI spawn smokes', () => { + const BOOTSTRAP = path.join( + REPO_ROOT, + 'release-kit/payload/scripts/socket-release/bootstrap.mts', + ) + const INSTALL = path.join(REPO_ROOT, 'release-kit/install.mts') + + it('bootstrap --help exits 0', () => { + const r = spawnSync(process.execPath, [BOOTSTRAP, '--help'], { + encoding: 'utf8', + }) + expect(r.status).toBe(0) + }) + + it('an unknown step exits 2', () => { + const r = spawnSync(process.execPath, [BOOTSTRAP, 'deploy', '--json'], { + encoding: 'utf8', + }) + expect(r.status).toBe(2) + }) + + it('--apply --dry-run conflict exits 2', () => { + const r = spawnSync(process.execPath, [BOOTSTRAP, '--apply', '--dry-run'], { + encoding: 'utf8', + }) + expect(r.status).toBe(2) + }) + + it('install --help exits 0', () => { + const r = spawnSync(process.execPath, [INSTALL, '--help'], { + encoding: 'utf8', + }) + expect(r.status).toBe(0) + expect(r.stdout).toContain('--channels') + }) +}) diff --git a/test/repo/integration/release-kit/install.test.mts b/test/repo/integration/release-kit/install.test.mts new file mode 100644 index 00000000..0924a224 --- /dev/null +++ b/test/repo/integration/release-kit/install.test.mts @@ -0,0 +1,156 @@ +/** + * @file Installer integration: each example copied to a temp dir, the + * in-process installer run with REAL fs against it — the produced file + * list equals the example's expected-install.json, an immediate second + * --apply plans zero copies, --verify exits 0, and the config seed is + * write-only-if-absent. The installer never touches .github/workflows, + * package.json, or .gitignore (that is staged-config's job). + */ + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { runInstall } from '../../../../release-kit/install.mts' +import type { KitChannel } from '../../../../release-kit/install/manifest.mts' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const EXAMPLES = path.join(HERE, '../../../../release-kit/examples') + +const CASES: Array<{ channels: KitChannel[]; name: string }> = [ + { channels: ['npm', 'github-release'], name: 'npm-lib' }, + { channels: ['crates', 'github-release'], name: 'rust-crate' }, + { channels: ['npm', 'github-release', 'brew'], name: 'brew-cli' }, +] + +function tempCopy(example: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `kit-install-${example}-`)) + fs.cpSync(path.join(EXAMPLES, example), dir, { recursive: true }) + return dir +} + +describe('installer integration (real fs, temp dirs)', () => { + for (const { channels, name } of CASES) { + it(`${name}: --apply matches expected-install.json, second apply is empty, --verify exits 0`, () => { + const target = tempCopy(name) + const expected = JSON.parse( + fs.readFileSync( + path.join(EXAMPLES, name, 'expected-install.json'), + 'utf8', + ), + ) as { channels: string[]; files: string[] } + + const apply = runInstall({ + apply: true, + channels, + force: false, + log: () => {}, + target, + verify: false, + }) + expect(apply.exitCode).toBe(0) + const produced = apply.files + .filter(f => f.action === 'copy') + .map(f => `scripts/socket-release/${f.path}`) + .toSorted() + expect(produced).toEqual(expected.files) + expect(apply.channels).toEqual(expected.channels) + // Every produced file actually landed on disk. + for (const rel of produced) { + expect(fs.existsSync(path.join(target, rel)), rel).toBe(true) + } + + // The installer never touches the workflow/manifest surfaces. + expect(fs.existsSync(path.join(target, '.github/workflows'))).toBe(false) + + // The config seed exists (write-only-if-absent). + expect( + fs.existsSync(path.join(target, '.config/socket-release.json')), + ).toBe(true) + + const second = runInstall({ + apply: true, + channels, + force: false, + log: () => {}, + target, + verify: false, + }) + expect(second.exitCode).toBe(0) + expect(second.files.filter(f => f.action === 'copy')).toEqual([]) + + const verify = runInstall({ + apply: false, + channels, + force: false, + log: () => {}, + target, + verify: true, + }) + expect(verify.exitCode).toBe(0) + }) + } + + it('a hand-edited installed file is a per-file conflict refusal; --force restores', () => { + const target = tempCopy('npm-lib') + runInstall({ + apply: true, + channels: ['npm'], + force: false, + log: () => {}, + target, + verify: false, + }) + const victim = path.join(target, 'scripts/socket-release/bootstrap.mts') + fs.appendFileSync(victim, '// hand edit\n') + const conflicted = runInstall({ + apply: true, + channels: ['npm'], + force: false, + log: () => {}, + target, + verify: false, + }) + expect(conflicted.exitCode).toBe(1) + expect(conflicted.files.some(f => f.action === 'conflict')).toBe(true) + const forced = runInstall({ + apply: true, + channels: ['npm'], + force: true, + log: () => {}, + target, + verify: false, + }) + expect(forced.exitCode).toBe(0) + const verify = runInstall({ + apply: false, + channels: ['npm'], + force: false, + log: () => {}, + target, + verify: true, + }) + expect(verify.exitCode).toBe(0) + }) + + it('the config seed never overwrites an existing config', () => { + const target = tempCopy('npm-lib') + const configPath = path.join(target, '.config/socket-release.json') + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + fs.writeFileSync(configPath, '{"schemaVersion":1,"channels":["npm"]}\n') + runInstall({ + apply: true, + channels: ['npm'], + force: false, + log: () => {}, + target, + verify: false, + }) + expect(fs.readFileSync(configPath, 'utf8')).toBe( + '{"schemaVersion":1,"channels":["npm"]}\n', + ) + }) +}) diff --git a/test/repo/integration/release-kit/scenarios.mts b/test/repo/integration/release-kit/scenarios.mts new file mode 100644 index 00000000..44e51f0e --- /dev/null +++ b/test/repo/integration/release-kit/scenarios.mts @@ -0,0 +1,285 @@ +/** + * @file Scenario builders for the in-process bootstrap integration runs: + * an installed npm-lib-shaped consumer served entirely through fake seams + * (files, exec router, canned registry), so a full `runBootstrap` executes + * with no browser, no network, no child process. The SAME builders + * generate the committed run goldens (test/repo/unit/release-kit/fixtures/ + * release-kit/run/*.golden.json) so scenario and golden can never drift. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { + BootstrapSeams, + ExecResult, +} from '../../../../release-kit/payload/scripts/socket-release/bootstrap/seams.mts' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const PAYLOAD = path.join( + HERE, + '../../../../release-kit/payload/scripts/socket-release', +) +const FIXTURES = path.join(HERE, '../../unit/release-kit/fixtures/release-kit') + +export const ROOT = '/tmp/npm-lib' + +function payloadFile(rel: string): string { + return readFileSync(path.join(PAYLOAD, rel), 'utf8') +} + +export function runFixture(rel: string): string { + return readFileSync(path.join(FIXTURES, rel), 'utf8') +} + +export type RegistryState = 'live' | 'unclaimed' | 'unreachable' +export type StageState = 'auth-dead' | 'empty' | 'staged' + +export interface ScenarioConfig { + registry?: RegistryState | undefined + stage?: StageState | undefined + trust?: 'absent' | 'auth-died' | 'conforms' | undefined + workflowsInstalled?: boolean | undefined +} + +export interface Scenario { + calls: Array<{ args: string[]; cmd: string; kind: string }> + placeholderCalls: Array<{ access: string; apply: boolean; names: string[] }> + seams: BootstrapSeams +} + +/** + * A fully fake consumer: the npm-lib example installed with channels + * npm,github-release, everything green on the GitHub side, with the npm + * side's registry/stage/trust state per config. + */ +export function buildScenario(config?: ScenarioConfig | undefined): Scenario { + const cfg = { __proto__: null, ...config } as ScenarioConfig + const registry = cfg.registry ?? 'unclaimed' + const stage = cfg.stage ?? 'empty' + const trust = cfg.trust ?? 'absent' + const workflowsInstalled = cfg.workflowsInstalled ?? true + + const npmTemplate = payloadFile('templates/workflows/npm-publish.yml') + const ghrTemplate = payloadFile('templates/workflows/github-release.yml') + const files: Record = { + [path.join(ROOT, '.config/socket-release.json')]: JSON.stringify({ + channels: ['npm', 'github-release'], + npm: { access: 'restricted', distTag: 'latest' }, + schemaVersion: 1, + }), + [path.join(ROOT, '.gitignore')]: + 'node_modules/\n# socket-release-kit\n.cache/\n', + [path.join(ROOT, 'package.json')]: JSON.stringify({ + files: ['dist'], + name: '@socketsecurity/example-lib', + packageManager: 'pnpm@11.17.0', + publishConfig: { access: 'restricted' }, + scripts: { + build: 'echo build', + prepublishOnly: + "echo 'ERROR: publish via the socket-release kit (scripts/socket-release)' && exit 1", + release: 'node scripts/socket-release/bootstrap.mts', + 'release:npm': 'node scripts/socket-release/npm-publish.mts', + 'release:status': 'node scripts/socket-release/bootstrap.mts --status', + }, + version: '1.0.0', + }), + [path.join( + ROOT, + 'scripts/socket-release/templates/workflows/github-release.yml', + )]: ghrTemplate, + [path.join( + ROOT, + 'scripts/socket-release/templates/workflows/npm-publish.yml', + )]: npmTemplate, + } + if (workflowsInstalled) { + files[path.join(ROOT, '.github/workflows/npm-publish.yml')] = npmTemplate + files[path.join(ROOT, '.github/workflows/github-release.yml')] = ghrTemplate + } + + const envDoc = JSON.parse(runFixture('gh-env/restricted.json')) as { + environments: Array> + } + envDoc.environments.push({ + ...envDoc.environments[0]!, + name: 'github-release', + }) + const envList = JSON.stringify(envDoc) + const policies = runFixture('gh-env/restricted-policies.json') + + const calls: Scenario['calls'] = [] + const placeholderCalls: Scenario['placeholderCalls'] = [] + let tick = 0 + + const execRouter = (cmd: string, args: string[]): ExecResult => { + const joined = `${cmd} ${args.join(' ')}` + if (joined === 'git remote get-url origin') { + return { + code: 0, + stderr: '', + stdout: 'https://github.com/SocketDev/example-lib.git\n', + } + } + if (joined === 'gh api repos/SocketDev/example-lib') { + return { + code: 0, + stderr: '', + stdout: JSON.stringify({ + default_branch: 'main', + private: true, + visibility: 'private', + }), + } + } + if (joined === 'gh auth status') { + return { code: 0, stderr: '', stdout: 'Logged in to github.com\n' } + } + if (joined === 'pnpm help stage') { + return { code: 0, stderr: '', stdout: 'Usage: pnpm stage \n' } + } + if (joined === 'npm trust --help') { + return { code: 0, stderr: '', stdout: 'npm trust\n' } + } + if (joined === 'pnpm stage list --json') { + if (stage === 'auth-dead') { + return { + code: 1, + stderr: '', + stdout: runFixture('stage-list/auth-failed.txt'), + } + } + const list = + stage === 'staged' + ? runFixture('stage-list/two-staged.txt').replaceAll( + '@socketsecurity/example', + '@socketsecurity/example-lib', + ) + : runFixture('stage-list/empty.txt') + return { code: 0, stderr: '', stdout: list } + } + if (cmd === 'npm' && args[0] === 'trust' && args[1] === 'list') { + if (trust === 'auth-died') { + return { + code: 1, + stderr: '', + stdout: runFixture('trust-list/auth-died.txt'), + } + } + if (trust === 'conforms') { + return { + code: 0, + stderr: '', + stdout: runFixture('trust-list/conforms.json').replaceAll( + 'SocketDev/example', + 'SocketDev/example-lib', + ), + } + } + return { + code: 0, + stderr: '', + stdout: 'No trusted publishers configured for this package.\n', + } + } + if ( + joined.includes('/environments') && + joined.includes('deployment-branch-policies') + ) { + return { code: 0, stderr: '', stdout: policies } + } + if (joined.endsWith('/environments')) { + return { code: 0, stderr: '', stdout: envList } + } + if (joined.includes('/contents/.github/workflows/')) { + return workflowsInstalled + ? { code: 0, stderr: '', stdout: '{}' } + : { code: 1, stderr: 'HTTP 404', stdout: '' } + } + return { code: 0, stderr: '', stdout: '' } + } + + const seams: BootstrapSeams = { + ensureNpmIdentity: async () => true, + exec: async (cmd, args) => { + calls.push({ args, cmd, kind: 'exec' }) + return execRouter(cmd, args) + }, + execPty: async (cmd, args) => { + calls.push({ args, cmd, kind: 'execPty' }) + return 0 + }, + listDir: p => + p.endsWith('.github/workflows') && workflowsInstalled + ? ['github-release.yml', 'npm-publish.yml'] + : [], + now: () => { + tick += 7 + return new Date(Date.UTC(2026, 6, 31, 0, 0, 0, tick)) + }, + readFile: p => files[p], + readPublishingAccess: async () => ({ + directEnabled: registry === 'live' ? false : undefined, + stagedEnabled: registry === 'live' ? true : undefined, + state: registry === 'live' ? 'staged-only' : 'unknown', + }), + registryJson: async () => { + if (registry === 'unreachable') { + return { unreachable: 'connect ETIMEDOUT 104.16.0.1:443' } + } + if (registry === 'live') { + return { + body: JSON.parse(runFixture('packument/live.json')), + status: 200, + } + } + return { + body: JSON.parse(runFixture('packument/unpublished-404.json')), + status: 404, + } + }, + resolveKitDep: () => true, + runPlaceholder: async c => { + placeholderCalls.push({ + access: c.access, + apply: c.apply, + names: c.names, + }) + return [{ name: c.names[0]!, status: 'published' as const }] + }, + writeFile: (p, content) => { + files[p] = content + }, + writePublishingAccess: async (_pkg, desired) => ({ + ok: true, + read: { + directEnabled: (desired as { directEnabled: boolean }).directEnabled, + stagedEnabled: (desired as { stagedEnabled: boolean }).stagedEnabled, + state: 'staged-only' as const, + }, + }), + } + return { calls, placeholderCalls, seams } +} + +/** + * Normalize a run document for golden comparison: only the timing field + * varies run-to-run (the receipt `at` timestamps come from the fake clock). + */ +export function normalizeRunDoc(doc: unknown): unknown { + return JSON.parse( + JSON.stringify(doc, (key, value: unknown) => + key === 'durationMs' + ? 0 + : key === 'at' + ? '' + : key === 'saw' && + typeof value === 'string' && + /^v\d+\.\d+\.\d+$/.test(value) + ? '' + : value, + ), + ) +} diff --git a/test/repo/unit/release-kit/_shared/release-subject.test.mts b/test/repo/unit/release-kit/_shared/release-subject.test.mts new file mode 100644 index 00000000..7b0a66c1 --- /dev/null +++ b/test/repo/unit/release-kit/_shared/release-subject.test.mts @@ -0,0 +1,196 @@ +/** + * @file The ONE release-subject resolver, covered end to end: the plain-repo + * shape, the publishConfig.directory redirect shape, and all four safety + * throws that stop a publish from staging the wrong package (empty/non-string + * directory, a directory escaping the repo root, a missing subject manifest, + * and a subject manifest with no name/version). + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { resolveReleaseSubject } from '../../../../../release-kit/payload/scripts/socket-release/_shared/release-subject.mts' + +const roots: string[] = [] + +function makeRoot(manifest: Record): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'release-subject-')) + roots.push(root) + writeFileSync(path.join(root, 'package.json'), JSON.stringify(manifest)) + return root +} + +function writeSubject( + root: string, + dir: string, + manifest: Record, +): void { + const subjectDir = path.join(root, dir) + mkdirSync(subjectDir, { recursive: true }) + writeFileSync(path.join(subjectDir, 'package.json'), JSON.stringify(manifest)) +} + +afterEach(() => { + while (roots.length) { + rmSync(roots.pop()!, { force: true, recursive: true }) + } +}) + +describe('resolveReleaseSubject — plain repo', () => { + it('points every path at the root and marks it un-redirected', () => { + const root = makeRoot({ + name: 'plain-pkg', + private: true, + repository: 'github:SocketDev/plain', + version: '1.2.3', + }) + const subject = resolveReleaseSubject(root) + expect(subject.redirected).toBe(false) + expect(subject.name).toBe('plain-pkg') + expect(subject.version).toBe('1.2.3') + expect(subject.private).toBe(true) + expect(subject.dir).toBe(root) + expect(subject.packDir).toBe(root) + expect(subject.manifestPath).toBe(path.join(root, 'package.json')) + expect(subject.changelogPath).toBe(path.join(root, 'CHANGELOG.md')) + expect(subject.readmePath).toBe(path.join(root, 'README.md')) + expect(subject.repository).toBe('github:SocketDev/plain') + }) + + it('leaves name/version empty strings when the manifest omits them', () => { + const root = makeRoot({}) + const subject = resolveReleaseSubject(root) + expect(subject.name).toBe('') + expect(subject.version).toBe('') + expect(subject.private).toBeUndefined() + }) +}) + +describe('resolveReleaseSubject — publishConfig.directory redirect', () => { + it('resolves the subject manifest and packs INSIDE the directory', () => { + const root = makeRoot({ + name: 'root-private', + private: true, + publishConfig: { directory: 'packages/lib' }, + repository: 'github:SocketDev/mono', + version: '0.0.0', + }) + writeSubject(root, 'packages/lib', { + name: '@scope/lib', + version: '4.5.6', + }) + const subject = resolveReleaseSubject(root) + const dir = path.join(root, 'packages', 'lib') + expect(subject.redirected).toBe(true) + expect(subject.name).toBe('@scope/lib') + expect(subject.version).toBe('4.5.6') + expect(subject.dir).toBe(dir) + expect(subject.packDir).toBe(dir) + expect(subject.rootPath).toBe(root) + expect(subject.manifestPath).toBe(path.join(dir, 'package.json')) + expect(subject.changelogPath).toBe(path.join(dir, 'CHANGELOG.md')) + expect(subject.readmePath).toBe(path.join(dir, 'README.md')) + }) + + it('falls back to the root repository when the subject omits one', () => { + const root = makeRoot({ + name: 'root-private', + publishConfig: { directory: 'sub' }, + repository: 'github:SocketDev/mono', + version: '0.0.0', + }) + writeSubject(root, 'sub', { name: 'child', version: '1.0.0' }) + expect(resolveReleaseSubject(root).repository).toBe('github:SocketDev/mono') + }) + + it('prefers the subject repository over the root fallback', () => { + const root = makeRoot({ + name: 'root-private', + publishConfig: { directory: 'sub' }, + repository: 'github:SocketDev/mono', + version: '0.0.0', + }) + writeSubject(root, 'sub', { + name: 'child', + repository: 'github:SocketDev/child', + version: '1.0.0', + }) + expect(resolveReleaseSubject(root).repository).toBe( + 'github:SocketDev/child', + ) + }) +}) + +describe('resolveReleaseSubject — safety throws', () => { + it('throws on a non-string directory', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: 42 }, + version: '1.0.0', + }) + expect(() => resolveReleaseSubject(root)).toThrow(/non-empty/) + }) + + it('throws on an empty-string directory', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: '' }, + version: '1.0.0', + }) + expect(() => resolveReleaseSubject(root)).toThrow(/non-empty/) + }) + + it('throws when the directory escapes the repo root', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: '../outside' }, + version: '1.0.0', + }) + expect(() => resolveReleaseSubject(root)).toThrow( + /subdirectory of the repo/, + ) + }) + + it('throws when the directory resolves to the root itself', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: '.' }, + version: '1.0.0', + }) + expect(() => resolveReleaseSubject(root)).toThrow( + /subdirectory of the repo/, + ) + }) + + it('throws when the subject directory has no package.json', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: 'missing' }, + version: '1.0.0', + }) + expect(() => resolveReleaseSubject(root)).toThrow(/no package\.json/) + }) + + it('throws when the subject manifest lacks a name', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: 'sub' }, + version: '1.0.0', + }) + writeSubject(root, 'sub', { version: '1.0.0' }) + expect(() => resolveReleaseSubject(root)).toThrow(/must carry a name/) + }) + + it('throws when the subject manifest lacks a version', () => { + const root = makeRoot({ + name: 'root', + publishConfig: { directory: 'sub' }, + version: '1.0.0', + }) + writeSubject(root, 'sub', { name: 'child' }) + expect(() => resolveReleaseSubject(root)).toThrow(/must carry a name/) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/config-refusals.test.mts b/test/repo/unit/release-kit/bootstrap/config-refusals.test.mts new file mode 100644 index 00000000..0b523c1e --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/config-refusals.test.mts @@ -0,0 +1,150 @@ +/** + * @file The parseKitConfig refusal arms not already pinned: a non-object + * document, an invalid dist-tag, and every brew-block validation. Each is a + * loud KitError, never a silent default. + */ + +import { describe, expect, it } from 'vitest' + +import { parseKitConfig } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/config.mts' + +const WHERE = '.config/socket-release.json' + +function reject(raw: string): unknown { + try { + parseKitConfig(raw, WHERE) + } catch (e) { + return e + } + throw new Error('expected parseKitConfig to throw') +} + +describe('parseKitConfig refusals', () => { + it('refuses a non-object JSON document', () => { + expect(() => parseKitConfig('42', WHERE)).toThrow(/not an object/) + expect(() => parseKitConfig('null', WHERE)).toThrow(/not an object/) + expect(() => parseKitConfig('[]', WHERE)).toThrow(/not an object/) + }) + + it('refuses an invalid dist-tag', () => { + expect(() => + parseKitConfig( + JSON.stringify({ + channels: ['npm'], + npm: { distTag: '' }, + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/distTag/) + expect(() => + parseKitConfig( + JSON.stringify({ + channels: ['npm'], + npm: { distTag: 5 }, + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/distTag/) + }) + + it('refuses the brew channel with no brew block', () => { + expect(() => + parseKitConfig( + JSON.stringify({ channels: ['brew'], schemaVersion: 1 }), + WHERE, + ), + ).toThrow(/without a brew block/) + }) + + it('refuses a missing brew.tap', () => { + expect(() => + parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: 'a-.tgz', + formula: 'a', + triplets: ['darwin-arm64'], + }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/brew\.tap/) + }) + + it('refuses a non-string brew.formula', () => { + expect(() => + parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: 'a-.tgz', + formula: 5, + tap: 'o/r', + triplets: ['darwin-arm64'], + }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/brew\.formula/) + }) + + it('refuses a missing brew.assetTemplate', () => { + expect(() => + parseKitConfig( + JSON.stringify({ + brew: { formula: 'a', tap: 'o/r', triplets: ['darwin-arm64'] }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/brew\.assetTemplate/) + }) + + it('refuses non-array brew.triplets', () => { + expect(() => + parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: 'a-.tgz', + formula: 'a', + tap: 'o/r', + triplets: 'x', + }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ), + ).toThrow(/brew\.triplets/) + }) + + it('accepts a fully-specified brew config', () => { + const config = parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: '-.tar.gz', + formula: 'examplecli', + tap: 'SocketDev/socket', + triplets: ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'], + }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ) + expect(config.brew?.tap).toBe('SocketDev/socket') + expect(config.brew?.triplets).toHaveLength(4) + }) + + it('reports the refusal as a KitError-shaped multi-line message', () => { + const err = reject('not json at all') as Error + expect(err.message).toMatch(/Where:/) + expect(err.message).toMatch(/Fix:/) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/config.test.mts b/test/repo/unit/release-kit/bootstrap/config.test.mts new file mode 100644 index 00000000..eb2750c9 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/config.test.mts @@ -0,0 +1,115 @@ +/** + * @file ParseKitConfig accept/reject matrix — machine fields only (KitError + * fields + exit codes), never prose sentences. + */ + +import { describe, expect, it } from 'vitest' + +import { parseKitConfig } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/config.mts' +import { KitError } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' + +const WHERE = '/x/.config/socket-release.json' + +function refusal(raw: string): KitError { + try { + parseKitConfig(raw, WHERE) + } catch (e) { + expect(e).toBeInstanceOf(KitError) + return e as KitError + } + return expect.unreachable() as never +} + +describe('parseKitConfig accepts', () => { + it('the template shape', () => { + const config = parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: '-.tar.gz', + formula: '', + tap: 'SocketDev/socket', + triplets: ['darwin-arm64'], + }, + channels: ['npm', 'github-release'], + npm: { access: 'restricted', distTag: 'latest' }, + schemaVersion: 1, + }), + WHERE, + ) + expect(config.channels).toEqual(['npm', 'github-release']) + expect(config.npm.access).toBe('restricted') + expect(config.brew).toBeUndefined() + }) + + it('a brew channel with its block', () => { + const config = parseKitConfig( + JSON.stringify({ + brew: { + assetTemplate: '-.tar.gz', + formula: 'examplecli', + tap: 'SocketDev/socket', + triplets: ['darwin-arm64', 'linux-x64'], + }, + channels: ['brew'], + schemaVersion: 1, + }), + WHERE, + ) + expect(config.brew?.tap).toBe('SocketDev/socket') + }) + + it('an absent npm.access stays undefined (access-resolved is a later check)', () => { + const config = parseKitConfig( + JSON.stringify({ channels: ['npm'], schemaVersion: 1 }), + WHERE, + ) + expect(config.npm.access).toBeUndefined() + expect(config.npm.distTag).toBe('latest') + }) +}) + +describe('parseKitConfig rejects', () => { + it('an unknown channel with the exact valid set in the fix field', () => { + const e = refusal( + JSON.stringify({ channels: ['npm', 'docker'], schemaVersion: 1 }), + ) + expect(e.exitCode).toBe(2) + expect(e.fields.saw).toBe('docker') + expect(e.fields.fix).toContain('npm, crates, github-release, brew') + }) + + it('a missing/foreign schemaVersion', () => { + const e = refusal(JSON.stringify({ channels: ['npm'] })) + expect(e.exitCode).toBe(2) + expect(e.fields.wanted).toBe('1') + }) + + it('empty channels', () => { + const e = refusal(JSON.stringify({ channels: [], schemaVersion: 1 })) + expect(e.fields.wanted).toContain('non-empty') + }) + + it('the brew channel without a brew block', () => { + const e = refusal(JSON.stringify({ channels: ['brew'], schemaVersion: 1 })) + expect(e.exitCode).toBe(2) + expect(e.fields.fix).toContain('"brew"') + }) + + it('a bad access level', () => { + const e = refusal( + JSON.stringify({ + channels: ['npm'], + npm: { access: 'open' }, + schemaVersion: 1, + }), + ) + expect(e.fields.saw).toBe('open') + expect(e.fields.wanted).toBe('public | restricted') + }) + + it('unparseable JSON', () => { + const e = refusal('{ nope') + expect(e.exitCode).toBe(2) + expect(e.fields.saw).toBe('unparseable JSON') + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/gates.test.mts b/test/repo/unit/release-kit/bootstrap/gates.test.mts new file mode 100644 index 00000000..231893b2 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/gates.test.mts @@ -0,0 +1,63 @@ +/** + * @file Mirror test over CANONICAL_GATES: every gate any kit flow can render + * comes from a factory and holds the 6-line fleet shape. SANCTIONED SHAPE + * EXCEPTION: these assertions pin gate line PREFIXES (the fleet gate + * format is itself the contract) — one of the two allowed prose-shaped + * assertions. + */ + +import { describe, expect, it } from 'vitest' + +import { + formatHumanGate, + formatHumanGateQueue, +} from '../../../../../release-kit/payload/scripts/socket-release/_shared/human-gate.mts' +import { CANONICAL_GATES } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/gates.mts' + +describe('CANONICAL_GATES mirror', () => { + it('carries all eight factories exactly once', () => { + expect(CANONICAL_GATES.map(g => g.id).toSorted()).toEqual([ + 'browser-session', + 'gh-env', + 'npm-auth', + 'placeholder-promote', + 'publish-approve', + 'push-grant', + 'reserve-name', + 'web-auth-approve', + ]) + }) + + for (const { gate, id } of CANONICAL_GATES) { + it(`${id} renders the 6-line fleet shape`, () => { + const lines = formatHumanGate(gate, { index: 1, total: 1 }) + expect(lines[0]).toMatch(/^🖐 {2}HUMAN GATE — .+ \[1\/1\]$/) + expect(lines[1]!.startsWith(' Need: ')).toBe(true) + // Mind is optional; when present it sits between Need and A) You. + const aIdx = lines.findIndex(l => l.startsWith(' A) You: ')) + expect(aIdx).toBeGreaterThanOrEqual(2) + if (aIdx === 3) { + expect(lines[2]!.startsWith(' Mind: ')).toBe(true) + } + expect(lines[aIdx + 1]!.startsWith(' B) Me: ')).toBe(true) + expect(lines.at(-1)!.startsWith(' Then: ')).toBe(true) + }) + } + + it('queues number every gate [i/N] in clearing order', () => { + const lines = formatHumanGateQueue(CANONICAL_GATES.map(g => g.gate)) + const headers = lines.filter(l => l.startsWith('🖐')) + expect(headers).toHaveLength(CANONICAL_GATES.length) + for (let i = 0; i < headers.length; i += 1) { + expect(headers[i]).toContain(`[${i + 1}/${CANONICAL_GATES.length}]`) + } + }) + + it('never names an org secret as missing human work', () => { + for (const { gate } of CANONICAL_GATES) { + const text = formatHumanGate(gate).join('\n') + expect(text).not.toContain('SOCKET_RELEASE_APP_PRIVATE_KEY') + expect(text).not.toContain('SOCKET_RELEASE_CLIENT_ID') + } + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/plan.test.mts b/test/repo/unit/release-kit/bootstrap/plan.test.mts new file mode 100644 index 00000000..96d96bb7 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/plan.test.mts @@ -0,0 +1,141 @@ +/** + * @file Pure planning core: canonical ordering + dedupe, the precondition + * DAG, resume selection, receipt currency, and next-command rendering. + */ + +import { describe, expect, it } from 'vitest' + +import { + PRECONDITIONS, + STEP_IDS, + canonicalizeSteps, + isReceiptCurrent, + nextCommandFor, + nextPendingStep, + planRun, + preconditionGaps, +} from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/plan.mts' +import type { StepReceipt } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/plan.mts' + +const passed: StepReceipt = { + at: '2026-07-31T00:00:00.000Z', + dryRun: false, + status: 'passed', +} + +describe('canonicalizeSteps', () => { + it('orders positional steps canonically and dedupes', () => { + expect( + canonicalizeSteps(['verify', 'preflight', 'verify', 'placeholder']), + ).toEqual(['preflight', 'placeholder', 'verify']) + }) + + it('throws on an unknown step naming the valid set', () => { + expect(() => canonicalizeSteps(['deploy'])).toThrowError( + new RegExp(STEP_IDS.join(', ').replaceAll('-', '\\-')), + ) + }) + + it('accepts the amendment access steps', () => { + expect( + canonicalizeSteps(['npm-access-staged-only', 'npm-access-permissive']), + ).toEqual(['npm-access-permissive', 'npm-access-staged-only']) + }) +}) + +describe('preconditionGaps', () => { + it('names the missing steps for a fresh trusted-publisher run', () => { + const gaps = preconditionGaps(['trusted-publisher'], {}) + expect(gaps).toEqual([ + { + missing: ['placeholder', 'github-env', 'staged-config'], + step: 'trusted-publisher', + }, + ]) + }) + + it('is satisfied by passed receipts', () => { + const gaps = preconditionGaps(['trusted-publisher'], { + 'github-env': passed, + placeholder: passed, + 'staged-config': passed, + }) + expect(gaps).toEqual([]) + }) + + it('is satisfied by steps scheduled earlier in the same run', () => { + const gaps = preconditionGaps( + [ + 'preflight', + 'placeholder', + 'github-env', + 'staged-config', + 'trusted-publisher', + ], + {}, + ) + expect(gaps).toEqual([]) + }) + + it('blocked and failed receipts never satisfy a precondition', () => { + for (const status of ['blocked', 'failed', 'planned'] as const) { + const gaps = preconditionGaps(['placeholder'], { + preflight: { ...passed, status }, + }) + expect(gaps).toHaveLength(1) + expect(gaps[0]!.missing).toEqual(['preflight']) + } + }) + + it('gates the tighten step on placeholder + trusted-publisher', () => { + expect(PRECONDITIONS['npm-access-staged-only']).toEqual([ + 'placeholder', + 'trusted-publisher', + ]) + const gaps = preconditionGaps(['npm-access-staged-only'], { + placeholder: passed, + }) + expect(gaps[0]!.missing).toEqual(['trusted-publisher']) + }) +}) + +describe('planRun (resume)', () => { + it('with no positionals runs every step lacking a passed receipt', () => { + expect(planRun([], { preflight: passed })).toEqual( + STEP_IDS.filter(s => s !== 'preflight'), + ) + }) + + it('all-passed resumes to verify only', () => { + const receipts = Object.fromEntries(STEP_IDS.map(s => [s, passed])) + expect(planRun([], receipts)).toEqual(['verify']) + }) + + it('explicit positionals run exactly those steps', () => { + expect(planRun(['verify'], {})).toEqual(['verify']) + }) +}) + +describe('isReceiptCurrent / nextPendingStep / nextCommandFor', () => { + it('only passed receipts are current', () => { + expect(isReceiptCurrent(passed)).toBe(true) + expect(isReceiptCurrent(undefined)).toBe(false) + expect(isReceiptCurrent({ ...passed, status: 'blocked' })).toBe(false) + }) + + it('nextPendingStep walks canonical order', () => { + expect(nextPendingStep({ preflight: passed })).toBe('placeholder') + expect( + nextPendingStep(Object.fromEntries(STEP_IDS.map(s => [s, passed]))), + ).toBeUndefined() + }) + + it('nextCommandFor carries --reserve only for placeholder', () => { + expect(nextCommandFor('placeholder', { packageName: '@x/y' })).toBe( + 'node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @x/y', + ) + expect(nextCommandFor('github-env', { packageName: '@x/y' })).toBe( + 'node scripts/socket-release/bootstrap.mts github-env --apply', + ) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/render-arms.test.mts b/test/repo/unit/release-kit/bootstrap/render-arms.test.mts new file mode 100644 index 00000000..f1b517da --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/render-arms.test.mts @@ -0,0 +1,133 @@ +/** + * @file The validateRunJson error arms and the rich render paths not already + * pinned: an empty document tripping every top-level field, a malformed step + * tripping every per-step field, and renderStepHuman/renderStatusTable over + * failing checks, applied/would effects, gate lines, and dry-run receipts. + */ + +import { describe, expect, it } from 'vitest' + +import { + renderStatusTable, + renderStepHuman, + validateRunJson, +} from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import type { StepOutcomeJson } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import type { StepReceipt } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/plan.mts' + +describe('validateRunJson top-level arms', () => { + it('an empty object trips every top-level field', () => { + const errors = validateRunJson({ schemaVersion: 1 }).join(' | ') + expect(errors).toContain('kit must be') + expect(errors).toContain('mode must be') + expect(errors).toContain('repo must carry') + expect(errors).toContain('package must carry') + expect(errors).toContain('requestedSteps must be') + expect(errors).toContain('steps must be an array') + expect(errors).toContain('state must carry') + expect(errors).toContain('nextStep must be null') + expect(errors).toContain('nextCommand must be null') + expect(errors).toContain('exitCode must be an integer') + }) + + it('flags a non-object step entry', () => { + expect( + validateRunJson({ schemaVersion: 1, steps: ['x'] }).join(' '), + ).toContain('steps[0] is not an object') + }) + + it('a malformed step object trips every per-step field', () => { + const errors = validateRunJson({ schemaVersion: 1, steps: [{}] }).join( + ' | ', + ) + expect(errors).toContain('steps[0].step is not a step id') + expect(errors).toContain('steps[0].status must be') + expect(errors).toContain('steps[0].already must be a boolean') + expect(errors).toContain('steps[0].detail must be a string') + expect(errors).toContain('steps[0].durationMs must be an integer') + expect(errors).toContain('steps[0].checks must be an array') + expect(errors).toContain('steps[0].effects must be an array') + expect(errors).toContain('steps[0].gate must be null or') + }) + + it('flags malformed check and effect entries', () => { + const errors = validateRunJson({ + schemaVersion: 1, + steps: [{ checks: [{}], effects: [{}], gate: null }], + }).join(' | ') + expect(errors).toContain('steps[0].checks[0] must be') + expect(errors).toContain('steps[0].effects[0] must be') + }) + + it('flags a malformed nextStep and nextCommand of the wrong type', () => { + const errors = validateRunJson({ + nextCommand: 5, + nextStep: 'not-a-step', + schemaVersion: 1, + }).join(' | ') + expect(errors).toContain('nextStep must be null') + expect(errors).toContain('nextCommand must be null') + }) +}) + +describe('renderStepHuman rich output', () => { + it('renders the failed mark, failing check with fix, effects, and gate lines', () => { + const outcome: StepOutcomeJson = { + already: true, + checks: [ + { fix: 'do z', id: 'c1', ok: false, saw: 'x', wanted: 'y' }, + { fix: null, id: 'c2', ok: true, saw: '', wanted: '' }, + ], + detail: 'boom', + effects: [ + { applied: true, description: 'published', kind: 'registry-publish' }, + { applied: false, description: 'would tag', kind: 'git-tag' }, + ], + gate: { lines: ['GATE line 1', 'GATE line 2'], name: 'human-gate' }, + status: 'failed', + step: 'preflight', + } + const lines = renderStepHuman(outcome) + expect(lines[0]).toContain('× preflight: failed (already) — boom') + expect(lines.some(l => l.includes('× c1: saw x; wanted y'))).toBe(true) + expect(lines.some(l => l.includes('Fix: do z'))).toBe(true) + expect( + lines.some(l => l.includes('did [registry-publish] published')), + ).toBe(true) + expect(lines.some(l => l.includes('would [git-tag] would tag'))).toBe(true) + expect(lines).toContain('GATE line 1') + expect(lines.some(l => l.includes('c2'))).toBe(false) + }) + + it('renders the neutral mark for a skipped step', () => { + const outcome: StepOutcomeJson = { + already: false, + checks: [], + detail: 'nothing to do', + effects: [], + gate: null, + status: 'skipped', + step: 'placeholder', + } + expect(renderStepHuman(outcome)[0]!.startsWith('·')).toBe(true) + }) +}) + +describe('renderStatusTable', () => { + it('renders a dry-run receipt with its timestamp and pending for the rest', () => { + const receipts: Partial> = { + preflight: { + at: '2026-07-31T00:00:00.000Z', + dryRun: true, + status: 'passed', + } as StepReceipt, + } + const table = renderStatusTable(receipts as Record) + expect( + table.some( + l => l.includes('passed (dry-run)') && l.includes('at 2026-07-31'), + ), + ).toBe(true) + expect(table.some(l => l.includes('pending'))).toBe(true) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/render.test.mts b/test/repo/unit/release-kit/bootstrap/render.test.mts new file mode 100644 index 00000000..ad526cd3 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/render.test.mts @@ -0,0 +1,131 @@ +/** + * @file Rendering + the hand-rolled document validator: every committed run + * golden validates clean; six mutated documents are rejected each naming + * its violation; formatKitError carries the four ingredients in order; + * the status table renders all eight steps. + */ + +import { describe, expect, it } from 'vitest' + +import { + KitError, + formatKitError, + gateToJson, + renderStatusTable, + renderStepHuman, + validateRunJson, +} from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import type { RunJson } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import { STEP_IDS } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/plan.mts' +import { reserveNameGate } from '../../../../../release-kit/payload/scripts/socket-release/_shared/human-gate.mts' +import { fixture } from '../helpers.mts' + +const GOLDENS = [ + 'run-plan', + 'run-blocked', + 'run-reserve-gate', + 'run-status', +] as const + +function golden(name: string): RunJson { + return JSON.parse(fixture(`run/${name}.golden.json`)) as RunJson +} + +describe('validateRunJson', () => { + it('accepts every committed golden', () => { + for (const name of GOLDENS) { + expect(validateRunJson(golden(name)), name).toEqual([]) + } + }) + + it('rejects six mutated documents, each naming its violation', () => { + const base = () => golden('run-plan') + + const noSteps = base() as unknown as Record + delete noSteps['steps'] + expect(validateRunJson(noSteps).join(' ')).toContain( + 'steps must be an array', + ) + + const badStatus = base() + ;(badStatus.steps[0] as { status: string }).status = 'maybe' + expect(validateRunJson(badStatus).join(' ')).toContain( + 'status must be one of', + ) + + const badExit = base() as { exitCode: unknown } + badExit.exitCode = 1.5 + expect(validateRunJson(badExit).join(' ')).toContain( + 'exitCode must be an integer', + ) + + const badSchema = base() as { schemaVersion: unknown } + badSchema.schemaVersion = 2 + expect(validateRunJson(badSchema).join(' ')).toContain( + 'schemaVersion must be 1', + ) + + const badGate = base() + ;(badGate.steps[1] as { gate: unknown }).gate = { name: 42 } + expect(validateRunJson(badGate).join(' ')).toContain('gate must be null or') + + const badCheck = base() + ;(badCheck.steps[0]!.checks[0] as { ok: unknown }).ok = 'yes' + expect(validateRunJson(badCheck).join(' ')).toContain('checks[0]') + }) + + it('rejects a non-object outright', () => { + expect(validateRunJson('nope')).toEqual(['document is not an object']) + }) +}) + +describe('formatKitError', () => { + it('carries the four ingredients in order as machine fields', () => { + const err = new KitError( + { + fix: 'do the one thing.', + saw: 'the wrong thing', + wanted: 'the right thing', + what: 'Something failed.', + where: '/x/y', + }, + 1, + ) + expect(err.fields).toEqual({ + fix: 'do the one thing.', + saw: 'the wrong thing', + wanted: 'the right thing', + what: 'Something failed.', + where: '/x/y', + }) + expect(err.exitCode).toBe(1) + const lines = formatKitError(err.fields).split('\n') + expect(lines[0]).toBe('Something failed.') + expect(lines[1]!.startsWith(' Where: ')).toBe(true) + expect(lines[2]!.startsWith(' Saw: ')).toBe(true) + expect(lines[3]!.startsWith(' Wanted: ')).toBe(true) + expect(lines[4]!.startsWith(' Fix: ')).toBe(true) + }) +}) + +describe('renderStatusTable / renderStepHuman / gateToJson', () => { + it('the status table renders all eight steps', () => { + const table = renderStatusTable({}) + expect(table).toHaveLength(STEP_IDS.length) + for (const id of STEP_IDS) { + expect(table.some(l => l.startsWith(id))).toBe(true) + } + }) + + it('gateToJson carries the factory-rendered lines', () => { + const gate = gateToJson(reserveNameGate('@x/y', 'restricted', 'resumes.')) + expect(gate.name).toBe('reserve name') + expect(gate.lines[0]).toContain('HUMAN GATE — reserve name') + }) + + it('renderStepHuman marks passed/planned/failed distinctly', () => { + const outcome = golden('run-plan').steps[0]! + const lines = renderStepHuman(outcome) + expect(lines[0]).toContain('preflight: passed (already)') + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/state.test.mts b/test/repo/unit/release-kit/bootstrap/state.test.mts new file mode 100644 index 00000000..9a964b10 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/state.test.mts @@ -0,0 +1,92 @@ +/** + * @file State-file receipts: round-trip, schemaVersion refusal, contextKey + * invalidation, and the corrupted-JSON refusal (never silently fresh). + * Assertions target the machine fields (KitError.fields / exitCode). + */ + +import { describe, expect, it } from 'vitest' + +import { + contextKey, + freshState, + parseState, + serializeState, + withReceipt, +} from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/state.mts' +import { KitError } from '../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' + +const KEY = contextKey('SocketDev/example', '@socketsecurity/example') + +function fresh() { + return freshState({ + expectedKey: KEY, + packageName: '@socketsecurity/example', + packageVersion: '1.0.0', + root: '/tmp/example-repo', + slug: 'SocketDev/example', + }) +} + +describe('state round-trip', () => { + it('parse(serialize(state)) preserves receipts and context', () => { + const state = withReceipt(fresh(), 'preflight', { + at: '2026-07-31T00:00:00.000Z', + dryRun: false, + status: 'passed', + }) + const back = parseState(serializeState(state), KEY, '/x/state.json') + expect(back).toEqual(state) + }) + + it('withReceipt is pure — the original state is untouched', () => { + const state = fresh() + withReceipt(state, 'verify', { + at: 'x', + dryRun: false, + status: 'failed', + }) + expect(state.receipts).toEqual({}) + }) + + it('contextKey is deterministic and slug+name sensitive', () => { + expect(contextKey('a/b', 'p')).toBe(contextKey('a/b', 'p')) + expect(contextKey('a/b', 'p')).not.toBe(contextKey('a/b', 'q')) + }) +}) + +describe('state refusals', () => { + it('foreign schemaVersion refuses with usage exit code', () => { + const doc = { ...fresh(), schemaVersion: 2 } + try { + parseState(JSON.stringify(doc), KEY, '/x/state.json') + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(KitError) + expect((e as KitError).exitCode).toBe(2) + expect((e as KitError).fields.saw).toContain('2') + } + }) + + it('a changed context invalidates every receipt with Fix: --reset', () => { + const otherKey = contextKey('SocketDev/other', '@socketsecurity/example') + try { + parseState(serializeState(fresh()), otherKey, '/x/state.json') + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(KitError) + expect((e as KitError).exitCode).toBe(2) + expect((e as KitError).fields.fix).toContain('--reset') + } + }) + + it('corrupted JSON refuses — never silently reads as fresh state', () => { + try { + parseState('{ definitely not json', KEY, '/x/state.json') + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(KitError) + expect((e as KitError).exitCode).toBe(2) + expect((e as KitError).fields.fix).toContain('--reset') + } + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/access.test.mts b/test/repo/unit/release-kit/bootstrap/steps/access.test.mts new file mode 100644 index 00000000..217f5185 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/access.test.mts @@ -0,0 +1,237 @@ +/** + * @file The publishing-access machinery (owner directive): the pure parser + * over the three golden HTML states + the unknown-shape refusal, the diff + * planner's refuse-on-unknown, and the two bootstrap steps' classify — + * permissive never re-widens a live package; staged-only is the terminal + * done-predicate. + */ + +import { describe, expect, it } from 'vitest' + +import { + classifyPublishingAccess, + parsePublishingAccess, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import { + PERMISSIVE_ACCESS, + STAGED_ONLY_ACCESS, + accessMatchesDesired, + diffPublishingAccess, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts' +import * as permissive from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-permissive.mts' +import * as stagedOnly from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/npm-access-staged-only.mts' +import { + fakeSeams, + fixture, + livePackument, + makeCtx, + unpublishedPackument, +} from '../../helpers.mts' + +describe('parsePublishingAccess over the golden pages', () => { + it('both-enabled.html → both-enabled', () => { + const read = parsePublishingAccess( + fixture('access-pages/both-enabled.html'), + ) + expect(read).toEqual({ + directEnabled: true, + stagedEnabled: true, + state: 'both-enabled', + }) + }) + + it('staged-only.html → staged-only', () => { + const read = parsePublishingAccess(fixture('access-pages/staged-only.html')) + expect(read.state).toBe('staged-only') + expect(read.directEnabled).toBe(false) + }) + + it('direct-only.html (escaped JSON fallback) → direct-only', () => { + const read = parsePublishingAccess(fixture('access-pages/direct-only.html')) + expect(read).toEqual({ + directEnabled: true, + stagedEnabled: false, + state: 'direct-only', + }) + }) + + it('an unknown page shape REFUSES with state unknown — never a default', () => { + const read = parsePublishingAccess( + fixture('access-pages/unknown-shape.html'), + ) + expect(read.state).toBe('unknown') + expect(read.directEnabled).toBeUndefined() + }) + + it('classifyPublishingAccess treats a half-read as unknown', () => { + expect(classifyPublishingAccess(true, undefined)).toBe('unknown') + expect(classifyPublishingAccess(undefined, true)).toBe('unknown') + expect(classifyPublishingAccess(false, false)).toBe('unknown') + }) +}) + +describe('diffPublishingAccess', () => { + it('plans the exact checkbox edits to the terminal shape', () => { + const read = parsePublishingAccess( + fixture('access-pages/both-enabled.html'), + ) + expect(diffPublishingAccess(read, STAGED_ONLY_ACCESS)).toEqual([ + { checkbox: 'allowDirectPublish', to: false }, + ]) + const direct = parsePublishingAccess( + fixture('access-pages/direct-only.html'), + ) + expect(diffPublishingAccess(direct, STAGED_ONLY_ACCESS)).toEqual([ + { checkbox: 'allowDirectPublish', to: false }, + { checkbox: 'allowStagedPublish', to: true }, + ]) + }) + + it('refuses to plan against an unknown read', () => { + const read = parsePublishingAccess( + fixture('access-pages/unknown-shape.html'), + ) + expect(() => diffPublishingAccess(read, PERMISSIVE_ACCESS)).toThrowError( + /Refusing to plan/, + ) + }) + + it('accessMatchesDesired never matches an unknown read', () => { + const read = parsePublishingAccess( + fixture('access-pages/unknown-shape.html'), + ) + expect(accessMatchesDesired(read, STAGED_ONLY_ACCESS)).toBe(false) + }) +}) + +describe('npm-access-permissive step', () => { + it('a live name is already-done — a re-run NEVER re-widens', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ registry: () => livePackument() }) + const inputs = await permissive.read(ctx, fake.seams) + const detection = permissive.classifyAccessPermissive(inputs, ctx) + expect(detection.done).toBe(true) + expect(detection.state).toBe('live') + // The browser read lane was never opened for a live name. + expect(fake.accessWrites).toEqual([]) + const plan = permissive.plan(detection, ctx) + expect(plan.effects).toEqual([]) + }) + + it('plan mode defers the browser read and reports planned work', async () => { + const ctx = makeCtx({ apply: false }) + const fake = fakeSeams({ registry: () => unpublishedPackument() }) + const inputs = await permissive.read(ctx, fake.seams) + expect(inputs.access).toBeUndefined() + const detection = permissive.classifyAccessPermissive(inputs, ctx) + expect(detection.done).toBe(false) + expect(detection.state).toBe('pending-unread') + const plan = permissive.plan(detection, ctx) + expect(plan.effects.map(e => e.kind)).toEqual(['npm-access']) + expect(plan.effects[0]!.applied).toBe(false) + }) + + it('a pending placeholder with direct disabled applies PERMISSIVE', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + accessReads: [ + { directEnabled: false, stagedEnabled: true, state: 'staged-only' }, + ], + registry: () => unpublishedPackument(), + }) + const inputs = await permissive.read(ctx, fake.seams) + const detection = permissive.classifyAccessPermissive(inputs, ctx) + expect(detection.done).toBe(false) + const plan = permissive.plan(detection, ctx) + const result = await permissive.apply(plan, ctx, fake.seams) + expect(fake.accessWrites).toEqual([ + { desired: PERMISSIVE_ACCESS, pkg: '@socketsecurity/example' }, + ]) + expect(result.gate).toBeUndefined() + }) + + it('a not-yet-created package (unreadable page) is done with the defaults note', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + accessReads: [ + { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + }, + ], + registry: () => unpublishedPackument(), + }) + const inputs = await permissive.read(ctx, fake.seams) + const detection = permissive.classifyAccessPermissive(inputs, ctx) + expect(detection.done).toBe(true) + expect(detection.state).toBe('not-created') + }) +}) + +describe('npm-access-staged-only step', () => { + it('staged-only reads as done (idempotent no-op)', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + accessReads: [ + { directEnabled: false, stagedEnabled: true, state: 'staged-only' }, + ], + registry: () => livePackument(), + }) + const inputs = await stagedOnly.read(ctx, fake.seams) + const detection = stagedOnly.classifyAccessStagedOnly(inputs, ctx) + expect(detection.done).toBe(true) + const plan = stagedOnly.plan(detection, ctx) + const result = await stagedOnly.apply(plan, ctx, fake.seams) + expect(result.effects).toEqual([]) + expect(fake.accessWrites).toEqual([]) + }) + + it('both-enabled plans + applies the STAGED_ONLY tighten', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + accessReads: [ + { directEnabled: true, stagedEnabled: true, state: 'both-enabled' }, + ], + registry: () => livePackument(), + }) + const inputs = await stagedOnly.read(ctx, fake.seams) + const detection = stagedOnly.classifyAccessStagedOnly(inputs, ctx) + expect(detection.done).toBe(false) + const plan = stagedOnly.plan(detection, ctx) + expect(plan.effects.map(e => e.kind)).toEqual(['npm-access']) + await stagedOnly.apply(plan, ctx, fake.seams) + expect(fake.accessWrites).toEqual([ + { desired: STAGED_ONLY_ACCESS, pkg: '@socketsecurity/example' }, + ]) + }) + + it('a not-live package fails with the --reserve fix (tighten never precedes the publish)', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ registry: () => unpublishedPackument() }) + const inputs = await stagedOnly.read(ctx, fake.seams) + const detection = stagedOnly.classifyAccessStagedOnly(inputs, ctx) + expect(detection.failed).toBe(true) + expect( + detection.checks.find(c => c.id === 'registry-name-live')?.fix, + ).toContain('--reserve @socketsecurity/example') + }) + + it('an unreadable page blocks on the browser-session gate (refuse, never classify)', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + accessReads: [ + { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + }, + ], + registry: () => livePackument(), + }) + const inputs = await stagedOnly.read(ctx, fake.seams) + const detection = stagedOnly.classifyAccessStagedOnly(inputs, ctx) + expect(detection.gate?.name).toBe('browser session') + expect(detection.done).toBe(false) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/github-env.test.mts b/test/repo/unit/release-kit/bootstrap/steps/github-env.test.mts new file mode 100644 index 00000000..b16bbbfa --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/github-env.test.mts @@ -0,0 +1,209 @@ +/** + * @file Environment probes: the six probe states over the gh-env fixtures, + * the exact `gh api` argv fixes (PUT flags, list-before-POST, one POST per + * missing branch), the 403 gate, idempotent second apply, and the + * garbled-response refusal (never restricted-ok by default). + */ + +import { describe, expect, it } from 'vitest' + +import * as githubEnv from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/github-env.mts' +import { OK, fakeSeams, fixture, makeCtx } from '../../helpers.mts' + +const envList = (name: string) => ({ + code: 0, + stderr: '', + stdout: fixture(`gh-env/${name}.json`), +}) +const forbidden = { + code: 1, + stderr: fixture('gh-env/forbidden-403.txt'), + stdout: '', +} + +describe('desiredEnvironments', () => { + it('maps channels to their environments, deduped', () => { + expect( + githubEnv.desiredEnvironments(['npm', 'github-release', 'npm']), + ).toEqual(['npm-publish', 'github-release']) + expect(githubEnv.desiredEnvironments(['brew', 'crates'])).toEqual([ + 'brew-publish', + 'cargo-publish', + ]) + }) +}) + +describe('classifyEnvProbe', () => { + const base = { branch: 'main', env: 'npm-publish' } + + it('missing / unrestricted / wrong-branch / restricted-ok over fixtures', () => { + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: envList('missing'), + policy: OK, + }), + ).toBe('missing') + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: envList('unrestricted'), + policy: OK, + }), + ).toBe('unrestricted') + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: envList('wrong-branch'), + policy: envList('wrong-branch-policies'), + }), + ).toBe('wrong-branch') + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: envList('restricted'), + policy: envList('restricted-policies'), + }), + ).toBe('restricted-ok') + }) + + it('403 → forbidden', () => { + expect( + githubEnv.classifyEnvProbe({ ...base, envList: forbidden, policy: OK }), + ).toBe('forbidden') + }) + + it('garbled env JSON refuses — never restricted-ok', () => { + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: { code: 0, stderr: '', stdout: 'not json at all' }, + policy: OK, + }), + ).toBe('garbled') + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: { code: 0, stderr: '', stdout: '{"unexpected": true}' }, + policy: OK, + }), + ).toBe('garbled') + // Restricted env but a garbled policies list is still not restricted-ok. + expect( + githubEnv.classifyEnvProbe({ + ...base, + envList: envList('restricted'), + policy: { code: 0, stderr: '', stdout: 'rate limited' }, + }), + ).toBe('garbled') + }) +}) + +describe('classifyEnvProbes (step level)', () => { + it('403 blocks on the gh-env gate', () => { + const detection = githubEnv.classifyEnvProbes( + { envList: forbidden, policies: {} }, + makeCtx(), + ) + expect(detection.gate?.name).toBe('github environment') + expect(detection.done).toBe(false) + }) + + it('garbled fails, never passes', () => { + const detection = githubEnv.classifyEnvProbes( + { + envList: { code: 0, stderr: '', stdout: 'nonsense' }, + policies: {}, + }, + makeCtx(), + ) + expect(detection.failed).toBe(true) + }) +}) + +describe('planEnvFixes', () => { + it('emits the EXACT gh api argv: idempotent PUT then list-before-POST', () => { + const fixes = githubEnv.planEnvFixes({ + branch: 'main', + envs: ['npm-publish'], + slug: 'SocketDev/example', + }) + expect(fixes).toHaveLength(2) + expect(fixes[0]!.argv).toEqual([ + 'api', + '-X', + 'PUT', + 'repos/SocketDev/example/environments/npm-publish', + '-F', + 'deployment_branch_policy[protected_branches]=false', + '-F', + 'deployment_branch_policy[custom_branch_policies]=true', + ]) + expect(fixes[1]!.argv).toEqual([ + 'api', + '-X', + 'POST', + 'repos/SocketDev/example/environments/npm-publish/deployment-branch-policies', + '-f', + 'name=main', + '-f', + 'type=branch', + ]) + expect(fixes[1]!.listBeforePost).toEqual([ + 'api', + 'repos/SocketDev/example/environments/npm-publish/deployment-branch-policies', + '--jq', + '[.branch_policies[].name]', + ]) + }) + + it('one POST per env, none for an empty env list', () => { + expect( + githubEnv.planEnvFixes({ branch: 'main', envs: [], slug: 'a/b' }), + ).toEqual([]) + }) +}) + +describe('apply (idempotency via exec recorder)', () => { + it('a restricted-ok state plans zero mutating calls', async () => { + const ctx = makeCtx({ apply: true, channels: ['npm'] }) + const detection = githubEnv.classifyEnvProbes( + { + envList: envList('restricted'), + policies: { 'npm-publish': envList('restricted-policies') }, + }, + ctx, + ) + expect(detection.done).toBe(true) + const plan = githubEnv.plan(detection, ctx) + expect(plan.effects).toEqual([]) + const fake = fakeSeams() + const result = await githubEnv.apply(plan, ctx, fake.seams) + expect(result.effects).toEqual([]) + expect(fake.calls).toEqual([]) + }) + + it('the POST is skipped when the branch policy already exists', async () => { + const ctx = makeCtx({ apply: true, channels: ['npm'] }) + const detection = githubEnv.classifyEnvProbes( + { + envList: envList('unrestricted'), + policies: { 'npm-publish': OK }, + }, + ctx, + ) + const plan = githubEnv.plan(detection, ctx) + const fake = fakeSeams({ + exec: (_cmd, args) => + args.includes('--jq') + ? { code: 0, stderr: '', stdout: '["main"]' } + : { code: 0, stderr: '', stdout: '{}' }, + }) + const result = await githubEnv.apply(plan, ctx, fake.seams) + const posts = fake.calls.filter(c => c.args.includes('POST')) + expect(posts).toHaveLength(0) + const puts = fake.calls.filter(c => c.args.includes('PUT')) + expect(puts).toHaveLength(1) + expect(result.effects.filter(e => e.applied)).toHaveLength(1) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/placeholder.test.mts b/test/repo/unit/release-kit/bootstrap/steps/placeholder.test.mts new file mode 100644 index 00000000..4727c0df --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/placeholder.test.mts @@ -0,0 +1,247 @@ +/** + * @file Placeholder detection + consent policy: live short-circuit, staged + * promote gate, plan-vs-apply auth semantics, fail-closed registry, the + * reserve gate with ZERO publish effects, --reserve mismatch as usage, + * and the apply path invoking runPlaceholder exactly once — plus the + * amendment's permissive-after-publish ordering. + */ + +import { describe, expect, it } from 'vitest' + +import * as placeholder from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/placeholder.mts' +import { PERMISSIVE_ACCESS } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts' +import { + fakeSeams, + fixture, + livePackument, + makeCtx, + unpublishedPackument, + unreachableRegistry, +} from '../../helpers.mts' + +const emptyStage = { + code: 0, + stderr: '', + stdout: fixture('stage-list/empty.txt'), +} +const twoStaged = { + code: 0, + stderr: '', + stdout: fixture('stage-list/two-staged.txt'), +} +const authFailed = { + code: 1, + stderr: '', + stdout: fixture('stage-list/auth-failed.txt'), +} +const spinnerNoise = { + code: 0, + stderr: '', + stdout: fixture('stage-list/spinner-noise.txt'), +} + +describe('classifyPlaceholderState', () => { + it('live → done (double-publish structurally unreachable)', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: livePackument(), stageList: undefined }, + makeCtx(), + ) + expect(detection.done).toBe(true) + expect(detection.state).toBe('live') + }) + + it('staged entry → blocked with the promote gate carrying the stageId', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: twoStaged }, + makeCtx(), + ) + expect(detection.state).toBe('staged-pending') + expect(detection.gate?.name).toBe('placeholder promote') + expect(detection.detail).toContain('stage-0001') + }) + + it('spinner noise before the JSON still parses to the staged entry', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: spinnerNoise }, + makeCtx(), + ) + expect(detection.state).toBe('staged-pending') + expect(detection.detail).toContain('stage-0003') + }) + + it('auth-dead stage list → authUnknown with the auth-unavailable check', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: authFailed }, + makeCtx(), + ) + expect(detection.authUnknown).toBe(true) + const check = detection.checks.find(c => c.id === 'stage-list-unknown') + expect(check?.saw).toBe('auth-unavailable') + expect(check?.fix).toContain('npm-web-auth.mts login') + }) + + it('unreachable registry → failed with the §6 fields (never unclaimed)', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: unreachableRegistry(), stageList: emptyStage }, + makeCtx(), + ) + expect(detection.failed).toBe(true) + expect(detection.detail).toContain('Refusing to classify') + const check = detection.checks.find(c => c.id === 'registry-read') + expect(check?.fix).toContain('never read as an unclaimed name') + }) + + it('a scoped package with no resolved access refuses before planning', () => { + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + makeCtx({ access: undefined }), + ) + expect(detection.failed).toBe(true) + const check = detection.checks.find(c => c.id === 'access-resolved') + expect(check?.saw).toBe('none of them is set') + expect(check?.wanted).toBe('public or restricted') + }) +}) + +describe('plan (consent policy)', () => { + const unclaimed = () => + placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + makeCtx(), + ) + + it('apply without --reserve → the reserve gate; --yes never substitutes', () => { + const plan = placeholder.plan( + unclaimed(), + makeCtx({ apply: true, yes: true }), + ) + expect(plan.gate?.name).toBe('reserve name') + expect(plan.usage).toBeUndefined() + }) + + it('a mismatched --reserve is a usage refusal naming saw/wanted', () => { + const plan = placeholder.plan( + unclaimed(), + makeCtx({ apply: true, reserve: '@socketsecurity/wrong' }), + ) + expect(plan.usage).toEqual({ + saw: '@socketsecurity/wrong', + wanted: '@socketsecurity/example', + }) + }) + + it('a byte-equal --reserve plans the publish with no gate', () => { + const plan = placeholder.plan( + unclaimed(), + makeCtx({ apply: true, reserve: '@socketsecurity/example' }), + ) + expect(plan.gate).toBeUndefined() + expect(plan.effects.some(e => e.kind === 'registry-publish')).toBe(true) + expect(plan.effects.some(e => e.kind === 'npm-access')).toBe(true) + }) + + it('plan mode carries the same effects, nothing performed', () => { + const plan = placeholder.plan(unclaimed(), makeCtx()) + expect(plan.effects[0]!.applied).toBe(false) + }) +}) + +describe('apply', () => { + it('a blocked plan (no --reserve) performs ZERO publish effects', async () => { + const { placeholderCalls, seams } = fakeSeams() + const ctx = makeCtx({ apply: true }) + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + ctx, + ) + const plan = placeholder.plan(detection, ctx) + expect(plan.gate).toBeDefined() + // The runner never calls apply when plan carries a gate; the invariant + // here is that planning alone drove no seam at all. + expect(placeholderCalls).toHaveLength(0) + expect(seams).toBeDefined() + }) + + it('with --reserve invokes runPlaceholder once with the expected access', async () => { + const fake = fakeSeams({ + registry: () => livePackument(), + }) + const ctx = makeCtx({ apply: true, reserve: '@socketsecurity/example' }) + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + ctx, + ) + const plan = placeholder.plan(detection, ctx) + const result = await placeholder.apply(plan, ctx, fake.seams) + expect(fake.placeholderCalls).toEqual([ + { + access: 'restricted', + apply: true, + names: ['@socketsecurity/example'], + }, + ]) + expect(result.gate).toBeUndefined() + // Post-publish re-read was live → no access write needed (never re-widen). + expect(fake.accessWrites).toHaveLength(0) + }) + + it('dead npm identity blocks on the npm-auth gate with zero publishes', async () => { + const fake = fakeSeams({ identity: false }) + const ctx = makeCtx({ apply: true, reserve: '@socketsecurity/example' }) + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + ctx, + ) + const plan = placeholder.plan(detection, ctx) + const result = await placeholder.apply(plan, ctx, fake.seams) + expect(result.gate?.name).toBe('npm auth') + expect(fake.placeholderCalls).toHaveLength(0) + }) + + it('AMENDMENT ordering: publish first, then permissive while still pending', async () => { + // The post-publish re-read stays 404 (staged-pending on a staging + // account); the access read shows direct disabled → the apply widens to + // PERMISSIVE right after the publish, never before. + const fake = fakeSeams({ + accessReads: [ + { directEnabled: false, stagedEnabled: true, state: 'staged-only' }, + ], + registry: () => unpublishedPackument(), + }) + const ctx = makeCtx({ apply: true, reserve: '@socketsecurity/example' }) + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + ctx, + ) + const plan = placeholder.plan(detection, ctx) + const result = await placeholder.apply(plan, ctx, fake.seams) + expect(fake.placeholderCalls).toHaveLength(1) + expect(fake.accessWrites).toEqual([ + { desired: PERMISSIVE_ACCESS, pkg: '@socketsecurity/example' }, + ]) + expect(result.effects.some(e => e.kind === 'npm-access' && e.applied)).toBe( + true, + ) + }) + + it('an unreadable access page after publish skips the widen (refuse, not guess)', async () => { + const fake = fakeSeams({ + accessReads: [ + { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + }, + ], + registry: () => unpublishedPackument(), + }) + const ctx = makeCtx({ apply: true, reserve: '@socketsecurity/example' }) + const detection = placeholder.classifyPlaceholderState( + { packument: unpublishedPackument(), stageList: emptyStage }, + ctx, + ) + const plan = placeholder.plan(detection, ctx) + await placeholder.apply(plan, ctx, fake.seams) + expect(fake.accessWrites).toHaveLength(0) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/preflight.test.mts b/test/repo/unit/release-kit/bootstrap/steps/preflight.test.mts new file mode 100644 index 00000000..b22b6515 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/preflight.test.mts @@ -0,0 +1,189 @@ +/** + * @file Preflight classification over inline inputs: every check's + * pass/fail arm, the fail-closed registry rule (unreachable is NEVER + * `unpublished`), and the informational private-repo note. + */ + +import { describe, expect, it } from 'vitest' + +import { + classifyPackument, + classifyPreflightInputs, + nodeVersionOk, +} from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/preflight.mts' +import type { PreflightInputs } from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/preflight.mts' +import { + OK, + fixture, + livePackument, + makeCtx, + unreachableRegistry, +} from '../../helpers.mts' + +function goodInputs(): PreflightInputs { + return { + deps: { lib: true, playwright: true, sdk: true }, + ghAuth: OK, + ghRepo: { + code: 0, + stderr: '', + stdout: JSON.stringify({ default_branch: 'main', visibility: 'private' }), + }, + gitOrigin: { + code: 0, + stderr: '', + stdout: 'https://github.com/SocketDev/example.git\n', + }, + npmTrustHelp: OK, + packageJsonRaw: JSON.stringify({ + files: ['dist'], + name: '@socketsecurity/example', + packageManager: 'pnpm@11.17.0', + version: '1.0.0', + }), + packument: livePackument(), + pnpmStageHelp: OK, + } +} + +function checkById(inputs: PreflightInputs, id: string, ctx = makeCtx()) { + const detection = classifyPreflightInputs(inputs, ctx) + return detection.checks.find(c => c.id === id) +} + +describe('nodeVersionOk', () => { + it('accepts the floor and above, rejects below', () => { + expect(nodeVersionOk('v22.18.0')).toBe(true) + expect(nodeVersionOk('v24.1.0')).toBe(true) + expect(nodeVersionOk('v22.17.9')).toBe(false) + expect(nodeVersionOk('v20.11.0')).toBe(false) + expect(nodeVersionOk('garbage')).toBe(false) + }) +}) + +describe('classifyPackument (fail closed)', () => { + it('live / unpublished / unreachable are the only answers', () => { + expect(classifyPackument(livePackument())).toBe('live') + expect(classifyPackument({ body: {}, status: 404 })).toBe('unpublished') + expect(classifyPackument(unreachableRegistry())).toBe('unreachable') + // A 5xx is unreachable, NEVER unpublished. + expect(classifyPackument({ body: undefined, status: 503 })).toBe( + 'unreachable', + ) + }) + + it('a garbled 200 body never reads as live', () => { + const garbled = { + body: JSON.parse(fixture('packument/garbled.json')), + status: 200, + } + expect(classifyPackument(garbled)).toBe('unpublished') + }) +}) + +describe('classifyPreflightInputs', () => { + it('all-green inputs pass and are done', () => { + const detection = classifyPreflightInputs(goodInputs(), makeCtx()) + expect(detection.done).toBe(true) + expect(detection.checks.filter(c => !c.ok)).toEqual([]) + }) + + it('a private repo adds an informational provenance note (ok: true)', () => { + const check = checkById(goodInputs(), 'provenance-expectation') + expect(check?.ok).toBe(true) + expect(check?.saw).toContain('provenance disabled') + }) + + it('node below the floor fails node-version', () => { + const check = checkById( + goodInputs(), + 'node-version', + makeCtx({ nodeVersion: 'v20.0.0' }), + ) + expect(check?.ok).toBe(false) + }) + + it('a non-GitHub origin fails git-origin-github with the set-url fix', () => { + const inputs = goodInputs() + inputs.gitOrigin = { + code: 0, + stderr: '', + stdout: 'https://gitlab.com/x/y.git\n', + } + const check = checkById(inputs, 'git-origin-github') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain('git remote set-url origin') + }) + + it('a pinned pnpm without stage support fails with the exact bump fix', () => { + const inputs = goodInputs() + inputs.pnpmStageHelp = { code: 1, stderr: '', stdout: '' } + const check = checkById(inputs, 'pnpm-stage-support') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain('pnpm@11.17.0') + expect(check?.fix).toContain('pnpm/action-setup reads packageManager') + }) + + it('npm without trust support fails with the upgrade fix', () => { + const inputs = goodInputs() + inputs.npmTrustHelp = { code: 1, stderr: '', stdout: '' } + const check = checkById(inputs, 'npm-trust-support') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain('npm trust') + }) + + it('gh auth failure fails gh-auth', () => { + const inputs = goodInputs() + inputs.ghAuth = { code: 1, stderr: 'not logged in', stdout: '' } + expect(checkById(inputs, 'gh-auth')?.ok).toBe(false) + }) + + it('a missing kit dep fails with the exact pnpm add -D line', () => { + const inputs = goodInputs() + inputs.deps = { lib: true, playwright: false, sdk: true } + const check = checkById(inputs, 'kit-deps-resolvable') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain( + 'pnpm add -D @socketsecurity/lib@6.5.2 @socketsecurity/sdk@4.1.3 playwright-core@1.61.1', + ) + }) + + it('an unreachable registry FAILS registry-reachable (never unpublished)', () => { + const inputs = goodInputs() + inputs.packument = unreachableRegistry() + const check = checkById(inputs, 'registry-reachable') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain('never read as an unclaimed name') + const detection = classifyPreflightInputs(inputs, makeCtx()) + expect(detection.failed).toBe(true) + }) + + it('a definitive 404 PASSES registry-reachable', () => { + const inputs = goodInputs() + inputs.packument = { body: {}, status: 404 } + expect(checkById(inputs, 'registry-reachable')?.ok).toBe(true) + }) + + it('a scoped package without access fails access-resolved (§6 fields)', () => { + const check = checkById( + goodInputs(), + 'access-resolved', + makeCtx({ access: undefined }), + ) + expect(check?.ok).toBe(false) + expect(check?.saw).toContain('none of') + expect(check?.wanted).toBe('public or restricted') + expect(check?.fix).toContain('--access restricted') + }) + + it('a bad packageManager pin fails package-manifest', () => { + const inputs = goodInputs() + inputs.packageJsonRaw = JSON.stringify({ + files: ['dist'], + name: 'x', + packageManager: 'pnpm@11', + version: '1.0.0', + }) + expect(checkById(inputs, 'package-manifest')?.ok).toBe(false) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/staged-config.test.mts b/test/repo/unit/release-kit/bootstrap/steps/staged-config.test.mts new file mode 100644 index 00000000..7fd65ab1 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/staged-config.test.mts @@ -0,0 +1,201 @@ +/** + * @file Staged-config detection + writes: byte-parity against the REAL + * payload templates, the divergent-workflow conflict refusal with zero + * writes, --force restore to byte-identity, the surgical package.json + * edit (key order / indent / trailing newline), and append-only-if-absent + * gitignore. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import * as stagedConfig from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/staged-config.mts' +import { KitError } from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/render.mts' +import { fakeSeams, makeCtx } from '../../helpers.mts' + +const PAYLOAD = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../../../release-kit/payload/scripts/socket-release', +) +const NPM_TEMPLATE = readFileSync( + path.join(PAYLOAD, 'templates/workflows/npm-publish.yml'), + 'utf8', +) +const GHR_TEMPLATE = readFileSync( + path.join(PAYLOAD, 'templates/workflows/github-release.yml'), + 'utf8', +) + +const ROOT = '/tmp/example-repo' +const T = (rel: string) => path.join(ROOT, rel) + +const CONFORMING_PKG = `${JSON.stringify( + { + name: '@socketsecurity/example', + version: '1.0.0', + scripts: { + build: 'echo build', + ...stagedConfig.KIT_SCRIPTS, + }, + publishConfig: { access: 'restricted' }, + }, + null, + 2, +)}\n` + +function conformingFiles(): Record { + return { + [T('.github/workflows/github-release.yml')]: GHR_TEMPLATE, + [T('.github/workflows/npm-publish.yml')]: NPM_TEMPLATE, + [T('.gitignore')]: `node_modules/\n${stagedConfig.GITIGNORE_BLOCK}`, + [T('package.json')]: CONFORMING_PKG, + [T('scripts/socket-release/templates/workflows/github-release.yml')]: + GHR_TEMPLATE, + [T('scripts/socket-release/templates/workflows/npm-publish.yml')]: + NPM_TEMPLATE, + } +} + +async function classifyWith(files: Record, ctx = makeCtx()) { + const fake = fakeSeams({ files }) + const inputs = await stagedConfig.read(ctx, fake.seams) + return { detection: stagedConfig.classifyStagedConfig(inputs, ctx), fake } +} + +describe('classifyStagedConfig', () => { + it('byte-identical surface is done with every check ok', async () => { + const { detection } = await classifyWith(conformingFiles()) + expect(detection.done).toBe(true) + expect(detection.checks.filter(c => !c.ok)).toEqual([]) + }) + + it('a missing workflow is pending, not a conflict', async () => { + const files = conformingFiles() + delete files[T('.github/workflows/npm-publish.yml')] + const { detection } = await classifyWith(files) + expect(detection.state).toBe('pending') + expect( + detection.checks.find(c => c.id === 'workflow-npm-publish.yml')?.saw, + ).toBe('workflow not installed') + }) + + it('divergent bytes classify as conflict', async () => { + const files = conformingFiles() + files[T('.github/workflows/npm-publish.yml')] = `${NPM_TEMPLATE}# edited\n` + const { detection } = await classifyWith(files) + expect(detection.state).toBe('conflict') + }) +}) + +describe('apply', () => { + it('divergent workflow without --force refuses with the §6 fields and ZERO writes', async () => { + const files = conformingFiles() + files[T('.github/workflows/npm-publish.yml')] = `${NPM_TEMPLATE}# edited\n` + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ files }) + const inputs = await stagedConfig.read(ctx, fake.seams) + const plan = stagedConfig.plan( + stagedConfig.classifyStagedConfig(inputs, ctx), + ctx, + ) + try { + await stagedConfig.apply(plan, ctx, fake.seams) + expect.unreachable() + } catch (e) { + expect(e).toBeInstanceOf(KitError) + const err = e as KitError + expect(err.exitCode).toBe(1) + expect(err.fields.what).toBe( + 'Refusing to overwrite a hand-edited workflow.', + ) + expect(err.fields.where).toBe('.github/workflows/npm-publish.yml') + expect(err.fields.fix).toContain('--force') + } + expect(Object.keys(fake.written)).toEqual([]) + }) + + it('--force restores byte-identity to the template', async () => { + const files = conformingFiles() + files[T('.github/workflows/npm-publish.yml')] = `${NPM_TEMPLATE}# edited\n` + const ctx = makeCtx({ apply: true, force: true }) + const fake = fakeSeams({ files }) + const inputs = await stagedConfig.read(ctx, fake.seams) + const plan = stagedConfig.plan( + stagedConfig.classifyStagedConfig(inputs, ctx), + ctx, + ) + await stagedConfig.apply(plan, ctx, fake.seams) + expect(fake.written[T('.github/workflows/npm-publish.yml')]).toBe( + NPM_TEMPLATE, + ) + }) + + it('writes missing workflows byte-identical and appends the gitignore block once', async () => { + const files = conformingFiles() + delete files[T('.github/workflows/npm-publish.yml')] + files[T('.gitignore')] = 'node_modules/\n' + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ files }) + const inputs = await stagedConfig.read(ctx, fake.seams) + const plan = stagedConfig.plan( + stagedConfig.classifyStagedConfig(inputs, ctx), + ctx, + ) + await stagedConfig.apply(plan, ctx, fake.seams) + expect(fake.written[T('.github/workflows/npm-publish.yml')]).toBe( + NPM_TEMPLATE, + ) + expect(fake.written[T('.gitignore')]).toBe( + `node_modules/\n${stagedConfig.GITIGNORE_BLOCK}`, + ) + // Re-run: detection over the written state plans nothing. + const again = await stagedConfig.read(ctx, fake.seams) + expect(stagedConfig.classifyStagedConfig(again, ctx).done).toBe(true) + }) +}) + +describe('editPackageJsonRaw (surgical)', () => { + it('preserves key order and indent, appends the kit entries, trailing newline', () => { + const raw = `${JSON.stringify( + { + name: 'x', + version: '1.0.0', + zeta: true, + scripts: { build: 'echo', test: 'vitest run' }, + alpha: 1, + }, + null, + 2, + )}\n` + const { changed, next } = stagedConfig.editPackageJsonRaw(raw, 'restricted') + expect(changed).toBe(true) + expect(next.endsWith('\n')).toBe(true) + const keys = Object.keys(JSON.parse(next) as Record) + // Existing top-level order preserved; publishConfig appended. + expect(keys.slice(0, 5)).toEqual([ + 'name', + 'version', + 'zeta', + 'scripts', + 'alpha', + ]) + expect(keys.at(-1)).toBe('publishConfig') + const scripts = (JSON.parse(next) as { scripts: Record }) + .scripts + expect(Object.keys(scripts).slice(0, 2)).toEqual(['build', 'test']) + expect(scripts['release']).toBe('node scripts/socket-release/bootstrap.mts') + }) + + it('is idempotent — a conforming manifest changes nothing', () => { + const first = stagedConfig.editPackageJsonRaw( + '{"name":"x","version":"1.0.0"}\n', + 'restricted', + ) + const second = stagedConfig.editPackageJsonRaw(first.next, 'restricted') + expect(second.changed).toBe(false) + expect(second.next).toBe(first.next) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/trusted-publisher.test.mts b/test/repo/unit/release-kit/bootstrap/steps/trusted-publisher.test.mts new file mode 100644 index 00000000..04fa4417 --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/trusted-publisher.test.mts @@ -0,0 +1,225 @@ +/** + * @file Trust classification (FAIL CLOSED), plan argv order + * (revoke-then-create), the exact npm-web-auth argv, the missing-workflow + * derive-don't-assume refusal, and the apply lane through a fake execPty. + */ + +import { describe, expect, it } from 'vitest' + +import * as trustedPublisher from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/trusted-publisher.mts' +import { trustedPublisherLaw } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts' +import { fakeSeams, fixture, makeCtx } from '../../helpers.mts' + +const LAW = trustedPublisherLaw('SocketDev/example') + +function trustExec(stdout: string, code = 0) { + return { code, stderr: '', stdout } +} + +describe('classifyTrustList (fail closed)', () => { + it('conforms fixture → conforms', () => { + expect( + trustedPublisher.classifyTrustList( + trustExec(fixture('trust-list/conforms.json')), + LAW, + ), + ).toEqual({ kind: 'conforms' }) + }) + + it('stale-repo fixture → stale naming the repository field', () => { + const result = trustedPublisher.classifyTrustList( + trustExec(fixture('trust-list/stale-repo.json')), + LAW, + ) + expect(result.kind).toBe('stale') + if (result.kind === 'stale') { + expect(result.staleFields).toEqual(['repository']) + expect(result.config.id).toBe('tp-002') + } + }) + + it('clean exit without JSON → absent', () => { + const stdout = ( + JSON.parse(fixture('trust-list/absent.json')) as { stdout: string } + ).stdout + expect(trustedPublisher.classifyTrustList(trustExec(stdout), LAW)).toEqual({ + kind: 'absent', + }) + }) + + it('auth-died fixture → auth-died, never "(no config)"', () => { + const result = trustedPublisher.classifyTrustList( + trustExec(fixture('trust-list/auth-died.txt'), 1), + LAW, + ) + expect(result.kind).toBe('auth-died') + }) + + it('an UNKNOWN JSON shape refuses (auth-died), never absent', () => { + const result = trustedPublisher.classifyTrustList( + trustExec(fixture('trust-list/unknown-shape.json')), + LAW, + ) + expect(result.kind).toBe('auth-died') + }) + + it('an error envelope on a clean exit still refuses', () => { + const result = trustedPublisher.classifyTrustList( + trustExec( + '{"error":{"code":"EOTP","summary":"requires a one-time password"}}', + ), + LAW, + ) + expect(result.kind).toBe('auth-died') + }) +}) + +describe('classify (step level)', () => { + it('missing local npm-publish.yml → failed with the staged-config fix', () => { + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec(fixture('trust-list/conforms.json')), + workflows: ['ci.yml'], + }, + makeCtx(), + ) + expect(detection.failed).toBe(true) + const check = detection.checks.find(c => c.id === 'workflow-exists-locally') + expect(check?.fix).toBe( + 'run: node scripts/socket-release/bootstrap.mts staged-config --apply', + ) + }) + + it('auth-died → authUnknown (plan planned / apply blocked handled by the runner)', () => { + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec(fixture('trust-list/auth-died.txt'), 1), + workflows: ['npm-publish.yml'], + }, + makeCtx(), + ) + expect(detection.authUnknown).toBe(true) + expect(detection.checks.find(c => c.id === 'trust-list-unknown')?.saw).toBe( + 'auth-unavailable', + ) + }) +}) + +describe('plan argv', () => { + const workflows = ['npm-publish.yml'] + + it('absent → one create with the exact npm-web-auth argv', () => { + const ctx = makeCtx() + const detection = trustedPublisher.classifyTrustedPublisher( + { trustList: trustExec('No trusted publishers configured.'), workflows }, + ctx, + ) + const plan = trustedPublisher.plan(detection, ctx) + expect(plan.effects).toHaveLength(1) + expect(plan.effects[0]!.description).toBe( + 'node scripts/socket-release/npm-web-auth.mts trust github @socketsecurity/example --file npm-publish.yml --repo SocketDev/example --env npm-publish --allow-publish --allow-stage-publish --yes', + ) + }) + + it('stale → revoke THEN create, in that order', () => { + const ctx = makeCtx() + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec(fixture('trust-list/stale-repo.json')), + workflows, + }, + ctx, + ) + const plan = trustedPublisher.plan(detection, ctx) + expect(plan.effects).toHaveLength(2) + expect(plan.effects[0]!.description).toContain('trust revoke') + expect(plan.effects[1]!.description).toContain('trust github') + }) +}) + +describe('apply through fake execPty', () => { + it('stale drives revoke (by live id) then create, both via execPty', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + exec: (cmd, args) => + cmd === 'npm' && args[0] === 'trust' + ? trustExec(fixture('trust-list/stale-repo.json')) + : undefined, + }) + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec(fixture('trust-list/stale-repo.json')), + workflows: ['npm-publish.yml'], + }, + ctx, + ) + const plan = trustedPublisher.plan(detection, ctx) + await trustedPublisher.apply(plan, ctx, fake.seams) + const ptys = fake.calls.filter(c => c.kind === 'execPty') + expect(ptys).toHaveLength(2) + expect(ptys[0]!.args).toEqual([ + 'scripts/socket-release/npm-web-auth.mts', + 'trust', + 'revoke', + '@socketsecurity/example', + '--id=tp-002', + ]) + expect(ptys[1]!.args).toEqual([ + 'scripts/socket-release/npm-web-auth.mts', + 'trust', + 'github', + '@socketsecurity/example', + '--file', + 'npm-publish.yml', + '--repo', + 'SocketDev/example', + '--env', + 'npm-publish', + '--allow-publish', + '--allow-stage-publish', + '--yes', + ]) + }) + + it('a live conforming re-read at apply time performs zero writes', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + exec: (cmd, args) => + cmd === 'npm' && args[0] === 'trust' + ? trustExec(fixture('trust-list/conforms.json')) + : undefined, + }) + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec('No trusted publishers configured.'), + workflows: ['npm-publish.yml'], + }, + ctx, + ) + const plan = trustedPublisher.plan(detection, ctx) + const result = await trustedPublisher.apply(plan, ctx, fake.seams) + expect(result.effects).toEqual([]) + expect(fake.calls.filter(c => c.kind === 'execPty')).toEqual([]) + }) + + it('auth-death at apply time blocks on the npm-auth gate', async () => { + const ctx = makeCtx({ apply: true }) + const fake = fakeSeams({ + exec: (cmd, args) => + cmd === 'npm' && args[0] === 'trust' + ? trustExec(fixture('trust-list/auth-died.txt'), 1) + : undefined, + }) + const detection = trustedPublisher.classifyTrustedPublisher( + { + trustList: trustExec('No trusted publishers configured.'), + workflows: ['npm-publish.yml'], + }, + ctx, + ) + const plan = trustedPublisher.plan(detection, ctx) + const result = await trustedPublisher.apply(plan, ctx, fake.seams) + expect(result.gate?.name).toBe('npm auth') + expect(fake.calls.filter(c => c.kind === 'execPty')).toEqual([]) + }) +}) diff --git a/test/repo/unit/release-kit/bootstrap/steps/verify.test.mts b/test/repo/unit/release-kit/bootstrap/steps/verify.test.mts new file mode 100644 index 00000000..1210ec1f --- /dev/null +++ b/test/repo/unit/release-kit/bootstrap/steps/verify.test.mts @@ -0,0 +1,211 @@ +/** + * @file Verify aggregation over seam fixtures: all-green passed detail, + * staged-pending block, the unclaimed --reserve fix, workflows-on-origin, + * the amendment's terminal staged-only assertion (permissive = FAIL with + * the remediation command), and the negative org-secret test. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import * as verify from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/verify.mts' +import type { VerifyInputs } from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/verify.mts' +import { KIT_SCRIPTS } from '../../../../../../release-kit/payload/scripts/socket-release/bootstrap/steps/staged-config.mts' +import { + fixture, + livePackument, + makeCtx, + unpublishedPackument, +} from '../../helpers.mts' + +const PAYLOAD = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../../../release-kit/payload/scripts/socket-release', +) +const NPM_TEMPLATE = readFileSync( + path.join(PAYLOAD, 'templates/workflows/npm-publish.yml'), + 'utf8', +) +const GHR_TEMPLATE = readFileSync( + path.join(PAYLOAD, 'templates/workflows/github-release.yml'), + 'utf8', +) + +const OK = { code: 0, stderr: '', stdout: '' } + +function greenInputs(): VerifyInputs { + const pkg = `${JSON.stringify({ + name: '@socketsecurity/example', + publishConfig: { access: 'restricted' }, + scripts: { ...KIT_SCRIPTS }, + version: '1.0.0', + })}\n` + return { + access: { directEnabled: false, stagedEnabled: true, state: 'staged-only' }, + envList: { code: 0, stderr: '', stdout: fixture('gh-env/restricted.json') }, + packument: livePackument(), + pnpmStageHelp: OK, + policies: { + 'github-release': { + code: 0, + stderr: '', + stdout: fixture('gh-env/restricted-policies.json').replaceAll( + 'npm-publish', + 'github-release', + ), + }, + 'npm-publish': { + code: 0, + stderr: '', + stdout: fixture('gh-env/restricted-policies.json'), + }, + }, + stagedConfig: { + gitignore: '# socket-release-kit\n.cache/\n', + packageJsonRaw: pkg, + targets: { + 'github-release.yml': GHR_TEMPLATE, + 'npm-publish.yml': NPM_TEMPLATE, + }, + templates: { + 'github-release.yml': GHR_TEMPLATE, + 'npm-publish.yml': NPM_TEMPLATE, + }, + }, + stageList: undefined, + trustList: { + code: 0, + stderr: '', + stdout: fixture('trust-list/conforms.json'), + }, + workflowsOnOrigin: { + 'github-release.yml': OK, + 'npm-publish.yml': OK, + }, + } +} + +// The green fixture's env list only carries npm-publish; give github-release +// its own restricted entry by widening the envList JSON. +function greenInputsBothEnvs(): VerifyInputs { + const inputs = greenInputs() + const envDoc = JSON.parse(inputs.envList.stdout) as { + environments: Array> + } + envDoc.environments.push({ + ...envDoc.environments[0]!, + name: 'github-release', + }) + inputs.envList = { code: 0, stderr: '', stdout: JSON.stringify(envDoc) } + return inputs +} + +describe('classifyVerify', () => { + it('all green → passed with the stood-up next-release detail', () => { + const ctx = makeCtx({ apply: true }) + const detection = verify.classifyVerify(greenInputsBothEnvs(), ctx) + expect(detection.checks.filter(c => !c.ok)).toEqual([]) + expect(detection.done).toBe(true) + expect(detection.detail).toContain('publishing is stood up') + expect(detection.detail).toContain('chore: bump version to') + }) + + it('staged-pending → blocked on the promote gate', () => { + const inputs = greenInputsBothEnvs() + inputs.packument = unpublishedPackument() + inputs.stageList = { + code: 0, + stderr: '', + stdout: fixture('stage-list/two-staged.txt'), + } + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + expect(detection.gate?.name).toBe('placeholder promote') + }) + + it('unclaimed → failed with the exact --reserve fix', () => { + const inputs = greenInputsBothEnvs() + inputs.packument = unpublishedPackument() + inputs.stageList = { + code: 0, + stderr: '', + stdout: fixture('stage-list/empty.txt'), + } + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + expect(detection.failed).toBe(true) + expect(detection.checks.find(c => c.id === 'registry-name-live')?.fix).toBe( + 'node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example', + ) + }) + + it('a workflow absent from origin fails workflows-on-origin', () => { + const inputs = greenInputsBothEnvs() + inputs.workflowsOnOrigin['npm-publish.yml'] = { + code: 1, + stderr: 'HTTP 404', + stdout: '', + } + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + const check = detection.checks.find(c => c.id === 'workflows-on-origin') + expect(check?.ok).toBe(false) + expect(check?.fix).toContain('commit and push') + }) + + it('AMENDMENT: a package left permissive FAILS with the tighten command', () => { + const inputs = greenInputsBothEnvs() + inputs.access = { + directEnabled: true, + stagedEnabled: true, + state: 'both-enabled', + } + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + expect(detection.failed).toBe(true) + const check = detection.checks.find(c => c.id === 'npm-access-staged-only') + expect(check?.ok).toBe(false) + expect(check?.fix).toBe( + 'run: node scripts/socket-release/bootstrap.mts npm-access-staged-only --apply', + ) + }) + + it('trust auth-death renders auth-unavailable (plan-mode planned semantics)', () => { + const inputs = greenInputsBothEnvs() + inputs.trustList = { + code: 1, + stderr: '', + stdout: fixture('trust-list/auth-died.txt'), + } + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + expect(detection.authUnknown).toBe(true) + expect( + detection.checks.find(c => c.id === 'trusted-publisher-conforms')?.saw, + ).toBe('auth-unavailable') + }) + + it('NEGATIVE: no verify output ever names an org secret as human work', () => { + const scenarios = [ + greenInputsBothEnvs(), + (() => { + const inputs = greenInputsBothEnvs() + inputs.envList = { code: 1, stderr: 'HTTP 403', stdout: '' } + return inputs + })(), + (() => { + const inputs = greenInputsBothEnvs() + inputs.access = { + directEnabled: true, + stagedEnabled: true, + state: 'both-enabled', + } + return inputs + })(), + ] + for (const inputs of scenarios) { + const detection = verify.classifyVerify(inputs, makeCtx({ apply: true })) + const text = JSON.stringify(detection) + expect(text).not.toContain('SOCKET_RELEASE_APP_PRIVATE_KEY') + expect(text).not.toContain('SOCKET_RELEASE_CLIENT_ID') + } + }) +}) diff --git a/test/repo/unit/release-kit/fixtures/release-kit/access-pages/both-enabled.html b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/both-enabled.html new file mode 100644 index 00000000..9139cead --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/both-enabled.html @@ -0,0 +1,15 @@ + + + +

Publishing access

+
+ + + + + +
+ + diff --git a/test/repo/unit/release-kit/fixtures/release-kit/access-pages/direct-only.html b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/direct-only.html new file mode 100644 index 00000000..3488bcb3 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/direct-only.html @@ -0,0 +1,10 @@ + + + +

Publishing access

+ + + diff --git a/test/repo/unit/release-kit/fixtures/release-kit/access-pages/staged-only.html b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/staged-only.html new file mode 100644 index 00000000..22bfa51f --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/staged-only.html @@ -0,0 +1,13 @@ + + + +

Publishing access

+
+ + + + + +
+ + diff --git a/test/repo/unit/release-kit/fixtures/release-kit/access-pages/unknown-shape.html b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/unknown-shape.html new file mode 100644 index 00000000..5b613d6f --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/access-pages/unknown-shape.html @@ -0,0 +1,7 @@ + + + +

npm

+

Something entirely different rendered here.

+ + diff --git a/test/repo/unit/release-kit/fixtures/release-kit/checksums/duplicate-conflict.txt b/test/repo/unit/release-kit/fixtures/release-kit/checksums/duplicate-conflict.txt new file mode 100644 index 00000000..33420ea4 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/checksums/duplicate-conflict.txt @@ -0,0 +1,2 @@ +1111111111111111111111111111111111111111111111111111111111111111 examplecli-darwin-arm64.tar.gz +sha256: 9999999999999999999999999999999999999999999999999999999999999999 examplecli-darwin-arm64.tar.gz diff --git a/test/repo/unit/release-kit/fixtures/release-kit/checksums/kit-format.txt b/test/repo/unit/release-kit/fixtures/release-kit/checksums/kit-format.txt new file mode 100644 index 00000000..915aec88 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/checksums/kit-format.txt @@ -0,0 +1,6 @@ +sha1: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa examplecli-darwin-arm64.tar.gz +sha256: 1111111111111111111111111111111111111111111111111111111111111111 examplecli-darwin-arm64.tar.gz +sha512-base64: c29tZS1iYXNlNjQtZGlnZXN0 examplecli-darwin-arm64.tar.gz +sha1: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb examplecli-darwin-x64.tar.gz +sha256: 2222222222222222222222222222222222222222222222222222222222222222 examplecli-darwin-x64.tar.gz +sha512-base64: b3RoZXItYmFzZTY0LWRpZ2VzdA== examplecli-darwin-x64.tar.gz diff --git a/test/repo/unit/release-kit/fixtures/release-kit/checksums/shasum-format.txt b/test/repo/unit/release-kit/fixtures/release-kit/checksums/shasum-format.txt new file mode 100644 index 00000000..d5102703 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/checksums/shasum-format.txt @@ -0,0 +1,4 @@ +1111111111111111111111111111111111111111111111111111111111111111 examplecli-darwin-arm64.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 examplecli-darwin-x64.tar.gz +3333333333333333333333333333333333333333333333333333333333333333 examplecli-linux-arm64.tar.gz +4444444444444444444444444444444444444444444444444444444444444444 examplecli-linux-x64.tar.gz diff --git a/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-existing.rb b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-existing.rb new file mode 100644 index 00000000..f9e58f66 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-existing.rb @@ -0,0 +1,39 @@ +# Managed by socket-release-kit (scripts/socket-release/brew-publish.mts). +# Do not hand-edit: the next formula bump rewrites this file from the +# release's own checksums.txt. +class Examplecli < Formula + desc "examplecli (Socket release)" + homepage "https://github.com/SocketDev/example-cli" + version "1.2.2" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.2/examplecli-darwin-arm64.tar.gz" + sha256 "9999999999999999999999999999999999999999999999999999999999999999" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.2/examplecli-darwin-x64.tar.gz" + sha256 "9999999999999999999999999999999999999999999999999999999999999999" + end + end + + on_linux do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.2/examplecli-linux-arm64.tar.gz" + sha256 "9999999999999999999999999999999999999999999999999999999999999999" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.2/examplecli-linux-x64.tar.gz" + sha256 "9999999999999999999999999999999999999999999999999999999999999999" + end + end + + def install + bin.install "examplecli" + end + + test do + assert_match version.to_s, shell_output("#{bin}/examplecli --version") + end +end diff --git a/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-fresh.golden.rb b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-fresh.golden.rb new file mode 100644 index 00000000..3822cfc2 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-fresh.golden.rb @@ -0,0 +1,39 @@ +# Managed by socket-release-kit (scripts/socket-release/brew-publish.mts). +# Do not hand-edit: the next formula bump rewrites this file from the +# release's own checksums.txt. +class Examplecli < Formula + desc "examplecli (Socket release)" + homepage "https://github.com/SocketDev/example-cli" + version "1.2.3" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-arm64.tar.gz" + sha256 "1111111111111111111111111111111111111111111111111111111111111111" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-x64.tar.gz" + sha256 "2222222222222222222222222222222222222222222222222222222222222222" + end + end + + on_linux do + on_arm do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-arm64.tar.gz" + sha256 "3333333333333333333333333333333333333333333333333333333333333333" + end + on_intel do + url "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-x64.tar.gz" + sha256 "4444444444444444444444444444444444444444444444444444444444444444" + end + end + + def install + bin.install "examplecli" + end + + test do + assert_match version.to_s, shell_output("#{bin}/examplecli --version") + end +end diff --git a/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-parsed.golden.json b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-parsed.golden.json new file mode 100644 index 00000000..f550ba16 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-parsed.golden.json @@ -0,0 +1,23 @@ +{ + "className": "Examplecli", + "name": "examplecli", + "platforms": { + "darwin-arm64": { + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "url": "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-arm64.tar.gz" + }, + "darwin-x64": { + "sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "url": "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-x64.tar.gz" + }, + "linux-arm64": { + "sha256": "3333333333333333333333333333333333333333333333333333333333333333", + "url": "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-arm64.tar.gz" + }, + "linux-x64": { + "sha256": "4444444444444444444444444444444444444444444444444444444444444444", + "url": "https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-x64.tar.gz" + } + }, + "version": "1.2.3" +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-unparseable.rb b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-unparseable.rb new file mode 100644 index 00000000..a6c15075 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/formula/examplecli-unparseable.rb @@ -0,0 +1,4 @@ +# synthetic — a hand-mangled formula parseFormula must return undefined for (never throw) +module NotAFormula + VERSION = "?" +end diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/forbidden-403.txt b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/forbidden-403.txt new file mode 100644 index 00000000..dac7eac3 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/forbidden-403.txt @@ -0,0 +1,2 @@ +gh: Resource not accessible by integration (HTTP 403) +{"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest","status":"403"} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/missing.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/missing.json new file mode 100644 index 00000000..7ce72f5e --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/missing.json @@ -0,0 +1,7 @@ +{ + "_note": "synthetic — gh api repos//environments with the desired env absent (GitHub REST wire contract)", + "total_count": 1, + "environments": [ + { "name": "some-other-env", "deployment_branch_policy": null } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted-policies.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted-policies.json new file mode 100644 index 00000000..41fb2874 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted-policies.json @@ -0,0 +1,5 @@ +{ + "_note": "synthetic — the deployment-branch-policies list for restricted.json", + "total_count": 1, + "branch_policies": [{ "id": 2, "name": "main", "type": "branch" }] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted.json new file mode 100644 index 00000000..1a07dae7 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/restricted.json @@ -0,0 +1,13 @@ +{ + "_note": "synthetic — the desired terminal state: custom policies, exactly the default branch", + "total_count": 1, + "environments": [ + { + "name": "npm-publish", + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } + } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/unrestricted.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/unrestricted.json new file mode 100644 index 00000000..2ac6196c --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/unrestricted.json @@ -0,0 +1,5 @@ +{ + "_note": "synthetic — the env exists with no branch restriction", + "total_count": 1, + "environments": [{ "name": "npm-publish", "deployment_branch_policy": null }] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch-policies.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch-policies.json new file mode 100644 index 00000000..9df17d28 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch-policies.json @@ -0,0 +1,5 @@ +{ + "_note": "synthetic — the deployment-branch-policies list for wrong-branch.json", + "total_count": 1, + "branch_policies": [{ "id": 1, "name": "develop", "type": "branch" }] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch.json b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch.json new file mode 100644 index 00000000..1c032fb6 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/gh-env/wrong-branch.json @@ -0,0 +1,13 @@ +{ + "_note": "synthetic — custom policies restrict to a non-default branch", + "total_count": 1, + "environments": [ + { + "name": "npm-publish", + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } + } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/packument/garbled.json b/test/repo/unit/release-kit/fixtures/release-kit/packument/garbled.json new file mode 100644 index 00000000..5c2cbd84 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/packument/garbled.json @@ -0,0 +1,4 @@ +{ + "_note": "synthetic — a 200 body that is not a packument; classifyPackument must not read it as live", + "message": "upstream cache error" +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/packument/live.json b/test/repo/unit/release-kit/fixtures/release-kit/packument/live.json new file mode 100644 index 00000000..e26c1251 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/packument/live.json @@ -0,0 +1,5 @@ +{ + "_note": "synthetic packument subset — install-v1 projection (registry.npmjs.org wire contract)", + "dist-tags": { "latest": "1.0.0" }, + "versions": { "0.0.0": {}, "1.0.0": {} } +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/packument/unpublished-404.json b/test/repo/unit/release-kit/fixtures/release-kit/packument/unpublished-404.json new file mode 100644 index 00000000..e18f31c4 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/packument/unpublished-404.json @@ -0,0 +1 @@ +{ "error": "Not found" } diff --git a/test/repo/unit/release-kit/fixtures/release-kit/run/run-blocked.golden.json b/test/repo/unit/release-kit/fixtures/release-kit/run/run-blocked.golden.json new file mode 100644 index 00000000..a4a2e12f --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/run/run-blocked.golden.json @@ -0,0 +1,167 @@ +{ + "exitCode": 3, + "kit": { + "name": "socket-release-kit", + "version": "0.1.0" + }, + "mode": "apply", + "nextCommand": "node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib", + "nextStep": "placeholder", + "package": { + "access": "restricted", + "name": "@socketsecurity/example-lib", + "version": "1.0.0" + }, + "repo": { + "defaultBranch": "main", + "root": "/tmp/npm-lib", + "slug": "SocketDev/example-lib", + "visibility": "private" + }, + "requestedSteps": [ + "preflight", + "placeholder", + "npm-access-permissive", + "github-env", + "staged-config", + "trusted-publisher", + "npm-access-staged-only", + "verify" + ], + "schemaVersion": 1, + "state": { + "path": ".cache/socket-release/bootstrap-state.json", + "receipts": { + "preflight": { + "at": "", + "detail": "all preflight checks pass", + "dryRun": false, + "status": "passed" + }, + "placeholder": { + "at": "", + "detail": "@socketsecurity/example-lib@0.0.0 is staged (stage-0001) and waiting on promotion.", + "dryRun": false, + "status": "blocked" + } + } + }, + "steps": [ + { + "already": true, + "checks": [ + { + "fix": null, + "id": "node-version", + "ok": true, + "saw": "", + "wanted": "node >= 22.18 (native .mts execution)" + }, + { + "fix": null, + "id": "git-origin-github", + "ok": true, + "saw": "https://github.com/SocketDev/example-lib.git", + "wanted": "https://github.com//(.git) or git@github.com:/(.git)" + }, + { + "fix": null, + "id": "default-branch", + "ok": true, + "saw": "default branch main, visibility private", + "wanted": "a readable repo with a default branch" + }, + { + "fix": null, + "id": "provenance-expectation", + "ok": true, + "saw": "private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works", + "wanted": "informational" + }, + { + "fix": null, + "id": "package-manifest", + "ok": true, + "saw": "name @socketsecurity/example-lib, version 1.0.0, files present, packageManager pnpm@11.17.0", + "wanted": "name + version + non-empty files + packageManager matching ^pnpm@X.Y.Z$" + }, + { + "fix": null, + "id": "pnpm-stage-support", + "ok": true, + "saw": "pnpm help stage exited 0", + "wanted": "exit 0 (staged publishing supported)" + }, + { + "fix": null, + "id": "npm-trust-support", + "ok": true, + "saw": "npm trust --help exited 0", + "wanted": "exit 0 (npm trust available)" + }, + { + "fix": null, + "id": "gh-auth", + "ok": true, + "saw": "gh auth status exited 0", + "wanted": "exit 0 (gh authenticated)" + }, + { + "fix": null, + "id": "kit-deps-resolvable", + "ok": true, + "saw": "all three kit dependencies resolve", + "wanted": "@socketsecurity/lib + @socketsecurity/sdk + playwright-core resolvable from the repo root" + }, + { + "fix": null, + "id": "registry-reachable", + "ok": true, + "saw": "unpublished", + "wanted": "a 200 packument or a definitive 404" + }, + { + "fix": null, + "id": "access-resolved", + "ok": true, + "saw": "restricted", + "wanted": "public or restricted" + } + ], + "detail": "all preflight checks pass", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "passed", + "step": "preflight" + }, + { + "already": false, + "checks": [ + { + "fix": null, + "id": "staged-placeholder-pending", + "ok": false, + "saw": "staged entry stage-0001 awaiting promotion", + "wanted": "the name live on the registry" + } + ], + "detail": "@socketsecurity/example-lib@0.0.0 is staged (stage-0001) and waiting on promotion.", + "durationMs": 0, + "effects": [], + "gate": { + "lines": [ + "🖐 HUMAN GATE — placeholder promote [1/1]", + " Need: @socketsecurity/example-lib@0.0.0 is staged (stage-0001) and waiting on promotion before the name resolves as live.", + " Mind: staged entries are maintainer-visible only — an unauthenticated or wrong-account stage list reads as EMPTY, not as an error; the approve pipeline identity-checks first.", + " A) You: run `node scripts/socket-release/npm-publish.mts --approve` — it promotes staged entry stage-0001 and prompts your 2FA.", + " B) Me: say \"promote the placeholder\" and I run `node scripts/socket-release/npm-publish.mts --approve` through its PTY — the 2FA challenge opens in your browser, I wait.", + " Then: the bootstrap resumes at placeholder once the name resolves as live." + ], + "name": "placeholder promote" + }, + "status": "blocked", + "step": "placeholder" + } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/run/run-plan.golden.json b/test/repo/unit/release-kit/fixtures/release-kit/run/run-plan.golden.json new file mode 100644 index 00000000..febf7229 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/run/run-plan.golden.json @@ -0,0 +1,366 @@ +{ + "exitCode": 0, + "kit": { + "name": "socket-release-kit", + "version": "0.1.0" + }, + "mode": "plan", + "nextCommand": "node scripts/socket-release/bootstrap.mts preflight --apply", + "nextStep": "preflight", + "package": { + "access": "restricted", + "name": "@socketsecurity/example-lib", + "version": "1.0.0" + }, + "repo": { + "defaultBranch": "main", + "root": "/tmp/npm-lib", + "slug": "SocketDev/example-lib", + "visibility": "private" + }, + "requestedSteps": [ + "preflight", + "placeholder", + "npm-access-permissive", + "github-env", + "staged-config", + "trusted-publisher", + "npm-access-staged-only", + "verify" + ], + "schemaVersion": 1, + "state": { + "path": ".cache/socket-release/bootstrap-state.json", + "receipts": {} + }, + "steps": [ + { + "already": true, + "checks": [ + { + "fix": null, + "id": "node-version", + "ok": true, + "saw": "", + "wanted": "node >= 22.18 (native .mts execution)" + }, + { + "fix": null, + "id": "git-origin-github", + "ok": true, + "saw": "https://github.com/SocketDev/example-lib.git", + "wanted": "https://github.com//(.git) or git@github.com:/(.git)" + }, + { + "fix": null, + "id": "default-branch", + "ok": true, + "saw": "default branch main, visibility private", + "wanted": "a readable repo with a default branch" + }, + { + "fix": null, + "id": "provenance-expectation", + "ok": true, + "saw": "private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works", + "wanted": "informational" + }, + { + "fix": null, + "id": "package-manifest", + "ok": true, + "saw": "name @socketsecurity/example-lib, version 1.0.0, files present, packageManager pnpm@11.17.0", + "wanted": "name + version + non-empty files + packageManager matching ^pnpm@X.Y.Z$" + }, + { + "fix": null, + "id": "pnpm-stage-support", + "ok": true, + "saw": "pnpm help stage exited 0", + "wanted": "exit 0 (staged publishing supported)" + }, + { + "fix": null, + "id": "npm-trust-support", + "ok": true, + "saw": "npm trust --help exited 0", + "wanted": "exit 0 (npm trust available)" + }, + { + "fix": null, + "id": "gh-auth", + "ok": true, + "saw": "gh auth status exited 0", + "wanted": "exit 0 (gh authenticated)" + }, + { + "fix": null, + "id": "kit-deps-resolvable", + "ok": true, + "saw": "all three kit dependencies resolve", + "wanted": "@socketsecurity/lib + @socketsecurity/sdk + playwright-core resolvable from the repo root" + }, + { + "fix": null, + "id": "registry-reachable", + "ok": true, + "saw": "unpublished", + "wanted": "a 200 packument or a definitive 404" + }, + { + "fix": null, + "id": "access-resolved", + "ok": true, + "saw": "restricted", + "wanted": "public or restricted" + } + ], + "detail": "all preflight checks pass", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "passed", + "step": "preflight" + }, + { + "already": false, + "checks": [ + { + "fix": null, + "id": "registry-name-unclaimed", + "ok": true, + "saw": "definitive 404 — the name is unclaimed", + "wanted": "a definitive registry answer" + } + ], + "detail": "@socketsecurity/example-lib is unclaimed on npm — reserving it publishes a real 0.0.0 placeholder.", + "durationMs": 0, + "effects": [ + { + "applied": false, + "description": "publish @socketsecurity/example-lib@0.0.0 --access restricted via npm-web-auth PTY (placeholder package: package.json + one-line README, files: [])", + "kind": "registry-publish" + }, + { + "applied": false, + "description": "ensure publishing access PERMISSIVE (direct + staged) on @socketsecurity/example-lib while the placeholder is pending", + "kind": "npm-access" + } + ], + "gate": null, + "status": "planned", + "step": "placeholder" + }, + { + "already": false, + "checks": [ + { + "fix": null, + "id": "access-read-deferred", + "ok": true, + "saw": "browser read deferred (plan mode opens no browser)", + "wanted": "a publishing-access read under --apply" + } + ], + "detail": "@socketsecurity/example-lib is not yet live; the permissive ensure runs under --apply (npm defaults a brand-new package to permissive).", + "durationMs": 0, + "effects": [ + { + "applied": false, + "description": "enable direct + staged publishing (permissive) on @socketsecurity/example-lib via the sanctioned browser session", + "kind": "npm-access" + } + ], + "gate": null, + "status": "planned", + "step": "npm-access-permissive" + }, + { + "already": true, + "checks": [ + { + "fix": null, + "id": "env-npm-publish", + "ok": true, + "saw": "restricted-ok", + "wanted": "environment npm-publish restricted to exactly [main]" + }, + { + "fix": null, + "id": "env-github-release", + "ok": true, + "saw": "restricted-ok", + "wanted": "environment github-release restricted to exactly [main]" + } + ], + "detail": "every desired environment is restricted to [main]", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "passed", + "step": "github-env" + }, + { + "already": true, + "checks": [ + { + "fix": null, + "id": "workflow-npm-publish.yml", + "ok": true, + "saw": "byte-identical to the local template", + "wanted": ".github/workflows/npm-publish.yml byte-identical to scripts/socket-release/templates/workflows/npm-publish.yml" + }, + { + "fix": null, + "id": "workflow-github-release.yml", + "ok": true, + "saw": "byte-identical to the local template", + "wanted": ".github/workflows/github-release.yml byte-identical to scripts/socket-release/templates/workflows/github-release.yml" + }, + { + "fix": null, + "id": "package-json-scripts", + "ok": true, + "saw": "all four kit scripts + publishConfig.access present", + "wanted": "release, release:status, release:npm, prepublishOnly scripts + publishConfig.access restricted" + }, + { + "fix": null, + "id": "gitignore-block", + "ok": true, + "saw": "kit block present", + "wanted": "a `# socket-release-kit` + `.cache/` block in .gitignore" + } + ], + "detail": "staged-config surface is byte-complete", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "passed", + "step": "staged-config" + }, + { + "already": false, + "checks": [ + { + "fix": null, + "id": "workflow-exists-locally", + "ok": true, + "saw": "npm-publish.yml present", + "wanted": "the trust-bound workflow exists locally" + }, + { + "fix": "run: node scripts/socket-release/bootstrap.mts trusted-publisher --apply", + "id": "trusted-publisher-absent", + "ok": false, + "saw": "(no config)", + "wanted": "the law bound to SocketDev/example-lib" + } + ], + "detail": "no trusted publisher configured for @socketsecurity/example-lib.", + "durationMs": 0, + "effects": [ + { + "applied": false, + "description": "node scripts/socket-release/npm-web-auth.mts trust github @socketsecurity/example-lib --file npm-publish.yml --repo SocketDev/example-lib --env npm-publish --allow-publish --allow-stage-publish --yes", + "kind": "npm-trust" + } + ], + "gate": null, + "status": "planned", + "step": "trusted-publisher" + }, + { + "already": false, + "checks": [ + { + "fix": "run: node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib", + "id": "registry-name-live", + "ok": false, + "saw": "unpublished", + "wanted": "the package live on the registry before its access is tightened" + } + ], + "detail": "@socketsecurity/example-lib is not live yet — the tighten step runs after the placeholder resolves.", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "planned", + "step": "npm-access-staged-only" + }, + { + "already": false, + "checks": [ + { + "fix": "node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib", + "id": "registry-name-live", + "ok": false, + "saw": "unpublished", + "wanted": "at least one version live on the registry" + }, + { + "fix": "run: node scripts/socket-release/bootstrap.mts trusted-publisher --apply", + "id": "trusted-publisher-conforms", + "ok": false, + "saw": "absent", + "wanted": "type github, repository SocketDev/example-lib, workflow npm-publish.yml, environment npm-publish, permissions createPackage + createStagedPackage" + }, + { + "fix": null, + "id": "environments-restricted", + "ok": true, + "saw": "npm-publish: restricted-ok, github-release: restricted-ok", + "wanted": "every desired environment restricted to [main]" + }, + { + "fix": null, + "id": "workflows-on-origin", + "ok": true, + "saw": "npm-publish.yml: on origin, github-release.yml: on origin", + "wanted": "every channel workflow present on origin main" + }, + { + "fix": null, + "id": "staged-config-parity", + "ok": true, + "saw": "staged-config surface is byte-complete", + "wanted": "local workflows byte-identical to templates; scripts + gitignore present" + }, + { + "fix": null, + "id": "pnpm-stage-support", + "ok": true, + "saw": "pnpm help stage exited 0", + "wanted": "exit 0 — CI publishes with the pinned pnpm" + }, + { + "fix": null, + "id": "provenance-expectation", + "ok": true, + "saw": "private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works", + "wanted": "informational" + }, + { + "fix": null, + "id": "npm-access-staged-only", + "ok": true, + "saw": "browser read deferred (plan mode opens no browser)", + "wanted": "staged-only (direct publishing disabled)" + }, + { + "fix": null, + "id": "state-coherent", + "ok": true, + "saw": "contextKey matches the resolved repo/package", + "wanted": "receipts keyed to this context" + } + ], + "detail": "2 verification(s) failing: registry-name-live, trusted-publisher-conforms", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "planned", + "step": "verify" + } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/run/run-reserve-gate.golden.json b/test/repo/unit/release-kit/fixtures/release-kit/run/run-reserve-gate.golden.json new file mode 100644 index 00000000..c592fc0a --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/run/run-reserve-gate.golden.json @@ -0,0 +1,178 @@ +{ + "exitCode": 3, + "kit": { + "name": "socket-release-kit", + "version": "0.1.0" + }, + "mode": "apply", + "nextCommand": "node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib", + "nextStep": "placeholder", + "package": { + "access": "restricted", + "name": "@socketsecurity/example-lib", + "version": "1.0.0" + }, + "repo": { + "defaultBranch": "main", + "root": "/tmp/npm-lib", + "slug": "SocketDev/example-lib", + "visibility": "private" + }, + "requestedSteps": [ + "preflight", + "placeholder", + "npm-access-permissive", + "github-env", + "staged-config", + "trusted-publisher", + "npm-access-staged-only", + "verify" + ], + "schemaVersion": 1, + "state": { + "path": ".cache/socket-release/bootstrap-state.json", + "receipts": { + "preflight": { + "at": "", + "detail": "all preflight checks pass", + "dryRun": false, + "status": "passed" + }, + "placeholder": { + "at": "", + "detail": "@socketsecurity/example-lib is unclaimed on npm — reserving it publishes a real 0.0.0 placeholder.", + "dryRun": false, + "status": "blocked" + } + } + }, + "steps": [ + { + "already": true, + "checks": [ + { + "fix": null, + "id": "node-version", + "ok": true, + "saw": "", + "wanted": "node >= 22.18 (native .mts execution)" + }, + { + "fix": null, + "id": "git-origin-github", + "ok": true, + "saw": "https://github.com/SocketDev/example-lib.git", + "wanted": "https://github.com//(.git) or git@github.com:/(.git)" + }, + { + "fix": null, + "id": "default-branch", + "ok": true, + "saw": "default branch main, visibility private", + "wanted": "a readable repo with a default branch" + }, + { + "fix": null, + "id": "provenance-expectation", + "ok": true, + "saw": "private repo — provenance disabled (npm rejects private-repo attestations); staged publishing still works", + "wanted": "informational" + }, + { + "fix": null, + "id": "package-manifest", + "ok": true, + "saw": "name @socketsecurity/example-lib, version 1.0.0, files present, packageManager pnpm@11.17.0", + "wanted": "name + version + non-empty files + packageManager matching ^pnpm@X.Y.Z$" + }, + { + "fix": null, + "id": "pnpm-stage-support", + "ok": true, + "saw": "pnpm help stage exited 0", + "wanted": "exit 0 (staged publishing supported)" + }, + { + "fix": null, + "id": "npm-trust-support", + "ok": true, + "saw": "npm trust --help exited 0", + "wanted": "exit 0 (npm trust available)" + }, + { + "fix": null, + "id": "gh-auth", + "ok": true, + "saw": "gh auth status exited 0", + "wanted": "exit 0 (gh authenticated)" + }, + { + "fix": null, + "id": "kit-deps-resolvable", + "ok": true, + "saw": "all three kit dependencies resolve", + "wanted": "@socketsecurity/lib + @socketsecurity/sdk + playwright-core resolvable from the repo root" + }, + { + "fix": null, + "id": "registry-reachable", + "ok": true, + "saw": "unpublished", + "wanted": "a 200 packument or a definitive 404" + }, + { + "fix": null, + "id": "access-resolved", + "ok": true, + "saw": "restricted", + "wanted": "public or restricted" + } + ], + "detail": "all preflight checks pass", + "durationMs": 0, + "effects": [], + "gate": null, + "status": "passed", + "step": "preflight" + }, + { + "already": false, + "checks": [ + { + "fix": null, + "id": "registry-name-unclaimed", + "ok": true, + "saw": "definitive 404 — the name is unclaimed", + "wanted": "a definitive registry answer" + } + ], + "detail": "@socketsecurity/example-lib is unclaimed on npm — reserving it publishes a real 0.0.0 placeholder.", + "durationMs": 0, + "effects": [ + { + "applied": false, + "description": "publish @socketsecurity/example-lib@0.0.0 --access restricted via npm-web-auth PTY (placeholder package: package.json + one-line README, files: [])", + "kind": "registry-publish" + }, + { + "applied": false, + "description": "ensure publishing access PERMISSIVE (direct + staged) on @socketsecurity/example-lib while the placeholder is pending", + "kind": "npm-access" + } + ], + "gate": { + "lines": [ + "🖐 HUMAN GATE — reserve name [1/1]", + " Need: @socketsecurity/example-lib is unclaimed on npm; reserving it publishes a real 0.0.0 placeholder (access restricted).", + " Mind: publishing @socketsecurity/example-lib@0.0.0 is irreversible — the version is burned forever and unpublish closes after 72h — so no default run performs it; --reserve must name the exact package.", + " A) You: run `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib` yourself.", + " B) Me: say \"reserve the name\" and I run `node scripts/socket-release/bootstrap.mts placeholder --apply --reserve @socketsecurity/example-lib` through its PTY — npm's web-2FA opens in your browser, I wait.", + " Then: the bootstrap resumes at placeholder and continues to the remaining steps." + ], + "name": "reserve name" + }, + "status": "blocked", + "step": "placeholder" + } + ] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/run/run-status.golden.json b/test/repo/unit/release-kit/fixtures/release-kit/run/run-status.golden.json new file mode 100644 index 00000000..25d684db --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/run/run-status.golden.json @@ -0,0 +1,28 @@ +{ + "exitCode": 0, + "kit": { + "name": "socket-release-kit", + "version": "0.1.0" + }, + "mode": "status", + "nextCommand": "node scripts/socket-release/bootstrap.mts preflight --apply", + "nextStep": "preflight", + "package": { + "access": "restricted", + "name": "@socketsecurity/example-lib", + "version": "1.0.0" + }, + "repo": { + "defaultBranch": "main", + "root": "/tmp/npm-lib", + "slug": "SocketDev/example-lib", + "visibility": "private" + }, + "requestedSteps": [], + "schemaVersion": 1, + "state": { + "path": ".cache/socket-release/bootstrap-state.json", + "receipts": {} + }, + "steps": [] +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/stage-list/auth-failed.txt b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/auth-failed.txt new file mode 100644 index 00000000..e74fcb60 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/auth-failed.txt @@ -0,0 +1 @@ + ERR_PNPM_STAGE_AUTH Authentication required: run pnpm login and retry. diff --git a/test/repo/unit/release-kit/fixtures/release-kit/stage-list/empty.txt b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/empty.txt new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/empty.txt @@ -0,0 +1 @@ +[] diff --git a/test/repo/unit/release-kit/fixtures/release-kit/stage-list/spinner-noise.txt b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/spinner-noise.txt new file mode 100644 index 00000000..67c177e8 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/spinner-noise.txt @@ -0,0 +1,10 @@ +⠋ contacting registry... +⠙ contacting registry... +{ + "@socketsecurity/example@0.0.0": { + "name": "@socketsecurity/example", + "version": "0.0.0", + "stageId": "stage-0003", + "shasum": "cccccccccccccccccccccccccccccccccccccccc" + } +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/stage-list/two-staged.txt b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/two-staged.txt new file mode 100644 index 00000000..f5bac706 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/stage-list/two-staged.txt @@ -0,0 +1,4 @@ +[ + {"name": "@socketsecurity/example", "version": "0.0.0", "stageId": "stage-0001", "shasum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"name": "@socketsecurity/other", "version": "2.0.0", "stageId": "stage-0002", "shasum": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} +] diff --git a/test/repo/unit/release-kit/fixtures/release-kit/trust-list/absent.json b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/absent.json new file mode 100644 index 00000000..89e316c2 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/absent.json @@ -0,0 +1,4 @@ +{ + "_note": "synthetic — clean exit without config is plain text, not JSON; this file records the stdout shape", + "stdout": "No trusted publishers configured for this package.\n" +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/trust-list/auth-died.txt b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/auth-died.txt new file mode 100644 index 00000000..79d2414d --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/auth-died.txt @@ -0,0 +1,3 @@ +npm error code E401 +npm error Incorrect or missing password. +{"error":{"code":"E401","summary":"must be logged in to view trusted publishers","authUrl":"https://www.npmjs.com/auth/cli/00000000-0000-0000-0000-000000000000"}} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/trust-list/conforms.json b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/conforms.json new file mode 100644 index 00000000..545a2a0d --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/conforms.json @@ -0,0 +1,9 @@ +{ + "_note": "synthetic — hand-authored from the npm trust list --json wire contract (fleet trust-sweep observations, 2026-07-31)", + "type": "github", + "file": "npm-publish.yml", + "repository": "SocketDev/example", + "environment": "npm-publish", + "permissions": ["createPackage", "createStagedPackage"], + "id": "tp-001" +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/trust-list/stale-repo.json b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/stale-repo.json new file mode 100644 index 00000000..00dc6cf0 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/stale-repo.json @@ -0,0 +1,9 @@ +{ + "_note": "synthetic — a config bound to the wrong repository (fleet trust-sweep observations, 2026-07-31)", + "type": "github", + "file": "npm-publish.yml", + "repository": "SocketDev/other-repo", + "environment": "npm-publish", + "permissions": ["createPackage", "createStagedPackage"], + "id": "tp-002" +} diff --git a/test/repo/unit/release-kit/fixtures/release-kit/trust-list/unknown-shape.json b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/unknown-shape.json new file mode 100644 index 00000000..b6a7cc41 --- /dev/null +++ b/test/repo/unit/release-kit/fixtures/release-kit/trust-list/unknown-shape.json @@ -0,0 +1,4 @@ +{ + "_note": "synthetic — a parseable JSON document that is NOT a trust config; must classify auth-died (refuse), never absent", + "unexpected": { "shape": true } +} diff --git a/test/repo/unit/release-kit/fuzz/brew-formula.fuzz.test.mts b/test/repo/unit/release-kit/fuzz/brew-formula.fuzz.test.mts new file mode 100644 index 00000000..f3a1ae96 Binary files /dev/null and b/test/repo/unit/release-kit/fuzz/brew-formula.fuzz.test.mts differ diff --git a/test/repo/unit/release-kit/fuzz/installer.fuzz.test.mts b/test/repo/unit/release-kit/fuzz/installer.fuzz.test.mts new file mode 100644 index 00000000..fa1435da --- /dev/null +++ b/test/repo/unit/release-kit/fuzz/installer.fuzz.test.mts @@ -0,0 +1,210 @@ +/** + * @file Property fuzzing for the installer's path safety and the drift / + * byte-parity checker. A manifest path that could escape the install prefix + * is refused loudly; the sha256 comparison catches any single-byte mutation + * of a payload copy; and the generated manifest round-trips through the + * parser it feeds. + */ + +import crypto from 'node:crypto' +import path from 'node:path' + +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + isSafePayloadPath, + KIT_VERSION, + MANIFEST_FILENAME, + parseKitManifest, +} from '../../../../../release-kit/install/manifest.mts' +import { planInstall } from '../../../../../release-kit/install/plan.mts' +import { + INSTALL_PREFIX, + sha256Hex, +} from '../../../../../release-kit/install/seams.mts' +import { + buildManifest, + serializeManifest, +} from '../../../../../release-kit/gen-manifest.mts' + +function manifestJson( + files: Array<{ channels?: string[]; path: string; sha256: string }>, +): string { + return JSON.stringify({ + files: files.map(f => ({ + channels: f.channels ?? ['common'], + path: f.path, + sha256: f.sha256, + })), + kitVersion: KIT_VERSION, + schemaVersion: 1, + }) +} + +const VALID_SHA = 'a'.repeat(64) + +describe('isSafePayloadPath', () => { + it('rejects absolute, drive-letter, and dot-dot traversal paths', () => { + for (const bad of [ + '', + '/etc/passwd', + '../escape', + '../../etc/passwd', + 'a/../../b', + 'a/../b/../../c', + 'C:\\windows', + '\\\\server\\share', + 'nested/../../..', + 'foo/..', + ]) { + expect(isSafePayloadPath(bad)).toBe(false) + } + }) + + it('accepts clean payload-relative paths', () => { + for (const ok of [ + 'bootstrap.mts', + 'publish-infra/npm/staged.mts', + 'templates/workflows/npm-publish.yml', + 'a/b/c/d.mts', + ]) { + expect(isSafePayloadPath(ok)).toBe(true) + } + }) + + it('accepted paths never resolve outside the install prefix', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 120 }), + fc.string({ maxLength: 60 }), + (rel, targetRaw) => { + const target = path.resolve( + '/tmp/target-root', + targetRaw.replace(/[^a-zA-Z0-9/_-]/g, '') || 'x', + ) + if (!isSafePayloadPath(rel)) { + return + } + const base = path.resolve(target, INSTALL_PREFIX) + const dest = path.resolve(target, INSTALL_PREFIX, rel) + expect(dest === base || dest.startsWith(`${base}${path.sep}`)).toBe( + true, + ) + }, + ), + { numRuns: 600 }, + ) + }) +}) + +describe('parseKitManifest path safety', () => { + it('refuses any manifest whose entry path is unsafe', () => { + fc.assert( + fc.property(fc.string({ maxLength: 120 }), rel => { + const raw = manifestJson([{ path: rel, sha256: VALID_SHA }]) + if (isSafePayloadPath(rel)) { + const parsed = parseKitManifest(raw, 'test') + expect(parsed.files[0]!.path).toBe(rel) + } else { + expect(() => parseKitManifest(raw, 'test')).toThrow() + } + }), + { numRuns: 500 }, + ) + }) + + it('refuses classic traversal payloads with a loud error', () => { + for (const bad of [ + '../../../etc/cron.d/x', + '/etc/passwd', + '..\\..\\win.ini', + ]) { + expect(() => + parseKitManifest( + manifestJson([{ path: bad, sha256: VALID_SHA }]), + 'test', + ), + ).toThrow(/unsafe path|malformed/) + } + }) +}) + +describe('drift / byte-parity checker', () => { + it('any single-byte mutation of a payload copy is classified as a conflict', () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 512, minLength: 1 }), + fc.nat(), + (bytes, seed) => { + const original = Buffer.from(bytes) + const idx = seed % original.length + const mutated = Buffer.from(original) + mutated[idx] = (mutated[idx]! + 1 + (seed % 254)) % 256 + fc.pre(!mutated.equals(original)) + + const originalSha = sha256Hex(original) + const mutatedSha = sha256Hex(mutated) + expect(mutatedSha).not.toBe(originalSha) + + const plan = planInstall({ + entries: [ + { channels: ['common'], path: 'f.mts', sha256: originalSha }, + ], + targetReads: new Map([['f.mts', mutatedSha]]), + }) + expect(plan.conflicts).toHaveLength(1) + expect(plan.identical).toHaveLength(0) + }, + ), + { numRuns: 500 }, + ) + }) + + it('an identical copy is skip-identical and an absent file is a copy', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 256 }), bytes => { + const sha = sha256Hex(Buffer.from(bytes)) + const identical = planInstall({ + entries: [{ channels: ['common'], path: 'f.mts', sha256: sha }], + targetReads: new Map([['f.mts', sha]]), + }) + expect(identical.identical).toHaveLength(1) + const absent = planInstall({ + entries: [{ channels: ['common'], path: 'f.mts', sha256: sha }], + targetReads: new Map([['f.mts', undefined]]), + }) + expect(absent.copies).toHaveLength(1) + }), + { numRuns: 200 }, + ) + }) + + it('sha256Hex agrees between Buffer and its utf8 string form', () => { + fc.assert( + fc.property(fc.string(), text => { + expect(sha256Hex(text)).toBe(sha256Hex(Buffer.from(text, 'utf8'))) + expect(sha256Hex(text)).toBe( + crypto.createHash('sha256').update(text).digest('hex'), + ) + }), + { numRuns: 300 }, + ) + }) +}) + +describe('generated manifest round-trips through its parser', () => { + it('buildManifest → serializeManifest → parseKitManifest preserves every entry', () => { + const manifest = buildManifest() + const serialized = serializeManifest(manifest) + const parsed = parseKitManifest(serialized, MANIFEST_FILENAME) + expect(parsed.files.length).toBe(manifest.files.length) + for (let i = 0; i < manifest.files.length; i += 1) { + expect(parsed.files[i]!.path).toBe(manifest.files[i]!.path) + expect(parsed.files[i]!.sha256).toBe(manifest.files[i]!.sha256) + } + for (const entry of parsed.files) { + expect(isSafePayloadPath(entry.path)).toBe(true) + } + }) +}) diff --git a/test/repo/unit/release-kit/fuzz/npm-parsers.fuzz.test.mts b/test/repo/unit/release-kit/fuzz/npm-parsers.fuzz.test.mts new file mode 100644 index 00000000..a2d0798d --- /dev/null +++ b/test/repo/unit/release-kit/fuzz/npm-parsers.fuzz.test.mts @@ -0,0 +1,302 @@ +/** + * @file Property fuzzing for the npm access-state, trusted-publisher, and + * staged-tarball page parsers. Arbitrary HTML/JSON and single-byte mutations + * of the golden fixtures must never crash a parser and never let it invent a + * classification the page did not carry — an unreadable page reads as a + * refusal (`unknown` / `error`), never a silent default. + */ + +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + classifyPublishingAccess, + parsePublishingAccess, +} from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import type { PublishingAccessState } from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import { + allowsAction, + classifyAccessPage, + extractAllowedActions, + parseTrustedPublisherForm, +} from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts' +import { + classifyStagedFetch, + isCloudflareChallenge, + looksLikeHtmlBody, + mapStagedTarball, + parseStagedPayload, +} from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-parse.mts' +import { fixture, FIXTURES } from '../helpers.mts' + +import { readdirSync } from 'node:fs' +import path from 'node:path' + +const ACCESS_STATES: PublishingAccessState[] = [ + 'both-enabled', + 'direct-only', + 'staged-only', + 'unknown', +] + +function mutateOneByte(text: string, seed: number): string { + if (text.length === 0) { + return text + } + const at = seed % text.length + const code = text.charCodeAt(at) + const next = String.fromCharCode(((code + 1) % 126) + 1) + return `${text.slice(0, at)}${next}${text.slice(at + 1)}` +} + +describe('classifyPublishingAccess', () => { + it('maps every toggle pair deterministically and refuses on any unreadable toggle', () => { + const tri = [true, false, undefined] as const + for (const d of tri) { + for (const s of tri) { + const state = classifyPublishingAccess(d, s) + expect(ACCESS_STATES).toContain(state) + if (d === undefined || s === undefined) { + expect(state).toBe('unknown') + } + } + } + expect(classifyPublishingAccess(true, true)).toBe('both-enabled') + expect(classifyPublishingAccess(true, false)).toBe('direct-only') + expect(classifyPublishingAccess(false, true)).toBe('staged-only') + expect(classifyPublishingAccess(false, false)).toBe('unknown') + }) +}) + +describe('parsePublishingAccess', () => { + it('never throws and stays internally consistent on arbitrary input', () => { + fc.assert( + fc.property(fc.string({ maxLength: 4000 }), html => { + const read = parsePublishingAccess(html) + expect(ACCESS_STATES).toContain(read.state) + expect(read.state).toBe( + classifyPublishingAccess(read.directEnabled, read.stagedEnabled), + ) + }), + { numRuns: 500 }, + ) + }) + + it('reads a synthesized page for any toggle rendering', () => { + const renderToggle = fc.oneof( + fc.constant({ + enabled: true, + html: (n: string) => ``, + }), + fc.constant({ + enabled: false, + html: (n: string) => ``, + }), + fc + .record({ + enabled: fc.boolean(), + }) + .map(({ enabled }) => ({ + enabled, + html: (_n: string) => '', + json: (k: string) => `"${k}": ${enabled}`, + })), + fc.constant({ enabled: undefined, html: (_n: string) => '' }), + ) + fc.assert( + fc.property( + renderToggle, + renderToggle, + fc.string({ maxLength: 40 }), + (dir, stg, noise) => { + const parts = [ + '', + noise, + dir.html('allowDirectPublish'), + 'json' in dir && typeof dir.json === 'function' + ? dir.json('directPublishEnabled') + : '', + stg.html('allowStagedPublish'), + 'json' in stg && typeof stg.json === 'function' + ? stg.json('stagedPublishEnabled') + : '', + '', + ] + const read = parsePublishingAccess(parts.join('\n')) + expect(read.directEnabled).toBe(dir.enabled) + expect(read.stagedEnabled).toBe(stg.enabled) + }, + ), + { numRuns: 400 }, + ) + }) + + it('single-byte mutations of the golden access pages never crash the parser', () => { + const files = readdirSync(path.join(FIXTURES, 'access-pages')) + fc.assert( + fc.property(fc.constantFrom(...files), fc.nat(), (file, seed) => { + const mutated = mutateOneByte(fixture(`access-pages/${file}`), seed) + const read = parsePublishingAccess(mutated) + expect(ACCESS_STATES).toContain(read.state) + }), + { numRuns: 300 }, + ) + }) +}) + +describe('classifyAccessPage', () => { + it('never throws and returns a known state for any body/status', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 3000 }), + fc.integer({ max: 700, min: 0 }), + (body, status) => { + const state = classifyAccessPage({ body, status }) + expect([ + 'auth', + 'challenge', + 'configured', + 'error', + 'unconfigured', + ]).toContain(state) + }, + ), + { numRuns: 500 }, + ) + }) + + it('a Cloudflare challenge body wins over any status', () => { + fc.assert( + fc.property(fc.integer({ max: 599, min: 100 }), status => { + expect( + classifyAccessPage({ body: 'Just a moment... cf-challenge', status }), + ).toBe('challenge') + }), + { numRuns: 200 }, + ) + }) +}) + +describe('parseTrustedPublisherForm', () => { + const seg = fc + .array( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-._'.split('')), + { + maxLength: 20, + minLength: 1, + }, + ) + .map(cs => cs.join('')) + + it('round-trips a configured page on owner/name/workflow/environment', () => { + fc.assert( + fc.property(seg, seg, seg, seg, (owner, name, wf, env) => { + const html = [ + '
', + `${owner}/${name}
`, + `
${wf}.yml
`, + `
${env}
`, + ].join('\n') + const parsed = parseTrustedPublisherForm(html) + expect(parsed).toBeDefined() + expect(parsed!.repositoryOwner).toBe(owner) + expect(parsed!.repositoryName).toBe(name) + expect(parsed!.workflowFilename).toBe(`${wf}.yml`) + expect(parsed!.environmentName).toBe(env) + }), + { numRuns: 300 }, + ) + }) + + it('never throws and returns undefined when no marker is present', () => { + fc.assert( + fc.property(fc.string({ maxLength: 3000 }), html => { + expect(() => parseTrustedPublisherForm(html)).not.toThrow() + }), + { numRuns: 400 }, + ) + }) +}) + +describe('extractAllowedActions / allowsAction', () => { + it('only ever returns the two known publish actions', () => { + fc.assert( + fc.property(fc.string({ maxLength: 3000 }), html => { + const actions = extractAllowedActions(html) + for (const a of actions) { + expect(['npm publish', 'npm stage publish']).toContain(a) + } + }), + { numRuns: 400 }, + ) + }) + + it('reads checked publish checkboxes and honors the plain-vs-stage distinction', () => { + fc.assert( + fc.property(fc.boolean(), fc.boolean(), (plain, stage) => { + const html = [ + plain + ? '' + : '', + stage + ? '' + : '', + ].join('\n') + const actions = extractAllowedActions(html) + expect(allowsAction(actions, 'publish')).toBe(plain) + expect(allowsAction(actions, 'stage-publish')).toBe(stage) + }), + { numRuns: 200 }, + ) + }) +}) + +describe('staged-browser-parse', () => { + it('challenge/html/classify helpers never throw on arbitrary input', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 3000 }), + fc.integer({ max: 700, min: 0 }), + (body, status) => { + expect(typeof isCloudflareChallenge(body)).toBe('boolean') + expect(typeof looksLikeHtmlBody(body)).toBe('boolean') + expect(['auth', 'challenge', 'error', 'ok']).toContain( + classifyStagedFetch({ body, status }), + ) + }, + ), + { numRuns: 500 }, + ) + }) + + it('parseStagedPayload never throws on any JSON value and returns a well-formed envelope', () => { + fc.assert( + fc.property(fc.jsonValue(), value => { + const body = JSON.stringify(value) + const payload = parseStagedPayload(body) + expect(typeof payload.approveUrl).toBe('string') + expect(typeof payload.csrfToken).toBe('string') + expect(typeof payload.rejectUrl).toBe('string') + expect(Array.isArray(payload.tarballs)).toBe(true) + expect(typeof payload.total).toBe('number') + }), + { numRuns: 400 }, + ) + }) + + it('parseStagedPayload throws loudly on non-JSON (never a silent empty result)', () => { + expect(() => parseStagedPayload('Just a moment... ')).toThrow() + }) + + it('mapStagedTarball produces string identity fields for any record', () => { + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.jsonValue()), raw => { + const t = mapStagedTarball(raw as Record) + expect(typeof t.id).toBe('string') + expect(typeof t.packageName).toBe('string') + expect(typeof t.version).toBe('string') + }), + { numRuns: 400 }, + ) + }) +}) diff --git a/test/repo/unit/release-kit/fuzz/workspace-yaml.fuzz.test.mts b/test/repo/unit/release-kit/fuzz/workspace-yaml.fuzz.test.mts new file mode 100644 index 00000000..82fdf346 --- /dev/null +++ b/test/repo/unit/release-kit/fuzz/workspace-yaml.fuzz.test.mts @@ -0,0 +1,157 @@ +/** + * @file Property fuzzing for the pnpm-workspace.yaml catalog string helpers. + * Splice then parse must round-trip the entry; remove then parse must drop + * it; splice must be idempotent for an unchanged version. Every parser must + * survive arbitrary text without throwing. + */ + +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' + +import { + parseCatalogBlock, + parseListBlock, + parseNamedCatalogs, + removeCatalogEntry, + spliceCatalogEntry, +} from '../../../../../release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts' + +const pkgName = fc + .array( + fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-._'.split('')), + { + maxLength: 24, + minLength: 1, + }, + ) + .map(cs => cs.join('')) + .filter(n => n !== 'common' && !n.startsWith('.')) + +const scopedName = fc + .tuple(pkgName, pkgName) + .map(([scope, name]) => `@${scope}/${name}`) + +const anyName = fc.oneof(pkgName, scopedName) + +const versionSpec = fc.oneof( + fc + .tuple(fc.nat({ max: 99 }), fc.nat({ max: 99 }), fc.nat({ max: 99 })) + .map(([a, b, c]) => `${a}.${b}.${c}`), + fc.tuple(pkgName, pkgName).map(([a, b]) => `npm:@${a}/${b}@1.0.0`), +) + +const BASE = [ + 'catalog:', + " '@types/node': 26.1.1", + ' micromark: 4.0.2', + " '@vitest/ui': 4.1.10", + '', + 'minimumReleaseAge: 10080', +].join('\n') + +describe('spliceCatalogEntry', () => { + it('an inserted entry is readable back with its exact version', () => { + fc.assert( + fc.property(anyName, versionSpec, (name, version) => { + const next = spliceCatalogEntry(BASE, name, version) + const parsed = parseCatalogBlock(next) + expect(parsed[name]).toBe(version) + }), + { numRuns: 400 }, + ) + }) + + it('is idempotent for the same name and version', () => { + fc.assert( + fc.property(anyName, versionSpec, (name, version) => { + const once = spliceCatalogEntry(BASE, name, version) + const twice = spliceCatalogEntry(once, name, version) + expect(twice).toBe(once) + }), + { numRuns: 300 }, + ) + }) + + it('a version bump rewrites in place and preserves every other entry', () => { + fc.assert( + fc.property(anyName, versionSpec, versionSpec, (name, v1, v2) => { + const first = spliceCatalogEntry(BASE, name, v1) + const bumped = spliceCatalogEntry(first, name, v2) + expect(parseCatalogBlock(bumped)[name]).toBe(v2) + const base = parseCatalogBlock(BASE) + for (const key of Object.keys(base)) { + if (key !== name) { + expect(parseCatalogBlock(bumped)[key]).toBe(base[key]) + } + } + }), + { numRuns: 300 }, + ) + }) + + it('creates the block when none exists', () => { + fc.assert( + fc.property(anyName, versionSpec, (name, version) => { + const next = spliceCatalogEntry( + 'overrides:\n glob: 13.0.6\n', + name, + version, + ) + expect(parseCatalogBlock(next)[name]).toBe(version) + }), + { numRuns: 200 }, + ) + }) +}) + +describe('removeCatalogEntry', () => { + it('splice then remove leaves the name absent (round-trip)', () => { + fc.assert( + fc.property(anyName, versionSpec, (name, version) => { + const added = spliceCatalogEntry(BASE, name, version) + const removed = removeCatalogEntry(added, name) + expect(parseCatalogBlock(removed)[name]).toBeUndefined() + }), + { numRuns: 400 }, + ) + }) + + it('is a no-op when the entry is absent', () => { + fc.assert( + fc.property(anyName, name => { + expect(removeCatalogEntry(BASE, name)).toBe(BASE) + }), + { numRuns: 200 }, + ) + }) +}) + +describe('parsers never throw on arbitrary input', () => { + it('parseCatalogBlock / parseListBlock / parseNamedCatalogs tolerate any text', () => { + fc.assert( + fc.property(fc.string({ maxLength: 4000 }), text => { + expect(() => parseCatalogBlock(text)).not.toThrow() + expect(() => + parseListBlock(text, { blockKey: 'packages' }), + ).not.toThrow() + expect(() => parseNamedCatalogs(text)).not.toThrow() + }), + { numRuns: 500 }, + ) + }) + + it('splice and remove tolerate arbitrary existing content', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 2000 }), + anyName, + versionSpec, + (text, name, version) => { + expect(() => spliceCatalogEntry(text, name, version)).not.toThrow() + expect(() => removeCatalogEntry(text, name)).not.toThrow() + }, + ), + { numRuns: 400 }, + ) + }) +}) diff --git a/test/repo/unit/release-kit/helpers.mts b/test/repo/unit/release-kit/helpers.mts new file mode 100644 index 00000000..f2261ccb --- /dev/null +++ b/test/repo/unit/release-kit/helpers.mts @@ -0,0 +1,160 @@ +/** + * @file Shared fakes for the release-kit suites: a canned StepContext and a + * recording BootstrapSeams whose every lane is data-driven — no browser, + * no network, no child process anywhere. Fake I/O, never logic: the fakes + * return canned wire payloads and the REAL classify/plan functions do all + * the work. + */ + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { StepContext } from '../../../../release-kit/payload/scripts/socket-release/bootstrap/plan.mts' +import type { + BootstrapSeams, + ExecResult, + RegistryJsonResult, +} from '../../../../release-kit/payload/scripts/socket-release/bootstrap/seams.mts' +import type { PublishingAccessRead } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import type { PlaceholderResult } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/placeholder.mts' + +export const FIXTURES = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'release-kit', +) + +export function fixture(rel: string): string { + return readFileSync(path.join(FIXTURES, rel), 'utf8') +} + +export const OK: ExecResult = { code: 0, stderr: '', stdout: '' } + +export function makeCtx( + overrides?: Partial | undefined, +): StepContext { + return { + access: 'restricted', + apply: false, + branch: undefined, + channels: ['npm', 'github-release'], + defaultBranch: 'main', + force: false, + nodeVersion: 'v24.0.0', + packageName: '@socketsecurity/example', + packageVersion: '1.0.0', + repoRoot: '/tmp/example-repo', + reserve: undefined, + slug: 'SocketDev/example', + visibility: 'private', + yes: false, + ...overrides, + } +} + +export interface FakeSeamsConfig { + accessReads?: PublishingAccessRead[] | undefined + accessWriteOk?: boolean | undefined + exec?: + | ((cmd: string, args: string[], cwd: string) => ExecResult | undefined) + | undefined + execPtyCode?: number | undefined + files?: Record | undefined + identity?: boolean | undefined + listDirs?: Record | undefined + placeholderResults?: PlaceholderResult[] | undefined + registry?: ((url: string) => RegistryJsonResult) | undefined + resolveDeps?: boolean | undefined +} + +export interface FakeSeams { + accessWrites: Array<{ desired: unknown; pkg: string }> + calls: Array<{ args: string[]; cmd: string; cwd: string; kind: string }> + placeholderCalls: Array<{ access: string; apply: boolean; names: string[] }> + seams: BootstrapSeams + written: Record +} + +/** + * A fully canned BootstrapSeams. Every mutating lane records; every read + * lane serves the configured data. Time is fixed so goldens are stable. + */ +export function fakeSeams(config?: FakeSeamsConfig | undefined): FakeSeams { + const cfg = { __proto__: null, ...config } as FakeSeamsConfig + const calls: FakeSeams['calls'] = [] + const written: Record = {} + const placeholderCalls: FakeSeams['placeholderCalls'] = [] + const accessWrites: FakeSeams['accessWrites'] = [] + const accessReads = [...(cfg.accessReads ?? [])] + let tick = 0 + const seams: BootstrapSeams = { + ensureNpmIdentity: async () => cfg.identity ?? true, + exec: async (cmd, args, cwd) => { + calls.push({ args, cmd, cwd, kind: 'exec' }) + return cfg.exec?.(cmd, args, cwd) ?? OK + }, + execPty: async (cmd, args, cwd) => { + calls.push({ args, cmd, cwd, kind: 'execPty' }) + return cfg.execPtyCode ?? 0 + }, + listDir: p => cfg.listDirs?.[p] ?? [], + now: () => { + tick += 7 + return new Date(Date.UTC(2026, 6, 31, 0, 0, 0, tick)) + }, + readFile: p => written[p] ?? cfg.files?.[p], + readPublishingAccess: async () => + accessReads.shift() ?? { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + }, + registryJson: async url => + cfg.registry?.(url) ?? { body: { error: 'Not found' }, status: 404 }, + resolveKitDep: () => cfg.resolveDeps ?? true, + runPlaceholder: async c => { + placeholderCalls.push({ + access: c.access, + apply: c.apply, + names: c.names, + }) + return ( + cfg.placeholderResults ?? [ + { name: c.names[0]!, status: 'published' as const }, + ] + ) + }, + writeFile: (p, content) => { + written[p] = content + }, + writePublishingAccess: async (pkg, desired) => { + accessWrites.push({ desired, pkg }) + const read = accessReads.shift() ?? { + directEnabled: (desired as { directEnabled: boolean }).directEnabled, + stagedEnabled: (desired as { stagedEnabled: boolean }).stagedEnabled, + state: 'staged-only' as const, + } + return { ok: cfg.accessWriteOk ?? true, read } + }, + } + return { accessWrites, calls, placeholderCalls, seams, written } +} + +/** + * A live packument body in the install-v1 projection. + */ +export function livePackument(): RegistryJsonResult { + return { body: JSON.parse(fixture('packument/live.json')), status: 200 } +} + +export function unpublishedPackument(): RegistryJsonResult { + return { + body: JSON.parse(fixture('packument/unpublished-404.json')), + status: 404, + } +} + +export function unreachableRegistry(): RegistryJsonResult { + return { unreachable: 'connect ETIMEDOUT 104.16.0.1:443' } +} diff --git a/test/repo/unit/release-kit/install/manifest.test.mts b/test/repo/unit/release-kit/install/manifest.test.mts new file mode 100644 index 00000000..6e5fa2b0 --- /dev/null +++ b/test/repo/unit/release-kit/install/manifest.test.mts @@ -0,0 +1,134 @@ +/** + * @file Channel → file-set mapping: the exact npm+github-release EXCLUSION + * list, `common` completeness (every payload file tagged), manifest + * parsing refusals, and the channels flag parser. + */ + +import { describe, expect, it } from 'vitest' + +import { + channelsForPath, + filterByChannels, + parseChannelsFlag, + parseKitManifest, +} from '../../../../../release-kit/install/manifest.mts' +import { walkPayload } from '../../../../../release-kit/install/seams.mts' +import { buildManifest } from '../../../../../release-kit/gen-manifest.mts' + +describe('channelsForPath', () => { + it('routes the channel-specific surfaces', () => { + expect(channelsForPath('publish-infra/npm/registry.mts')).toEqual(['npm']) + expect(channelsForPath('npm-publish.mts')).toEqual(['npm']) + expect(channelsForPath('npm-web-auth.mts')).toEqual(['npm']) + expect(channelsForPath('publish-infra/socket-oauth.mts')).toEqual(['npm']) + expect(channelsForPath('publish-infra/cargo/staged.mts')).toEqual([ + 'crates', + ]) + expect(channelsForPath('cargo-publish.mts')).toEqual(['crates']) + expect(channelsForPath('create-release.mts')).toEqual(['github-release']) + expect(channelsForPath('registry-liveness-gate.mjs')).toEqual([ + 'github-release', + ]) + expect(channelsForPath('lib/release-checksums/core.mts')).toEqual([ + 'github-release', + ]) + expect(channelsForPath('publish-infra/brew/formula.mts')).toEqual(['brew']) + expect(channelsForPath('lib/commit-via-github-api.mts')).toEqual(['brew']) + expect(channelsForPath('util/pack-app-triplets.mts')).toEqual(['brew']) + expect( + channelsForPath('templates/actions/socket-release-app-token/action.yml'), + ).toEqual(['brew']) + }) + + it('everything unclaimed is common (bootstrap, shared, config templates)', () => { + expect(channelsForPath('bootstrap.mts')).toEqual(['common']) + expect(channelsForPath('bootstrap/steps/verify.mts')).toEqual(['common']) + expect(channelsForPath('_shared/human-gate.mts')).toEqual(['common']) + expect(channelsForPath('util/napi-targets.mts')).toEqual(['common']) + expect(channelsForPath('templates/config/socket-release.json')).toEqual([ + 'common', + ]) + expect(channelsForPath('templates/gitignore-block.txt')).toEqual(['common']) + }) + + it('COMPLETENESS: every real payload file is tagged with a channel', () => { + const files = walkPayload() + expect(files.length).toBeGreaterThan(80) + for (const rel of files) { + const channels = channelsForPath(rel) + expect(channels.length, rel).toBeGreaterThan(0) + } + }) +}) + +describe('filterByChannels (the npm+github-release exclusion list)', () => { + it('npm+github-release EXCLUDES exactly the cargo/brew surfaces', () => { + const manifest = buildManifest() + const selected = new Set( + filterByChannels(manifest.files, ['npm', 'github-release']).map( + e => e.path, + ), + ) + // The §3.1 exclusion list for the jdm-aot channel selection. + const excluded = manifest.files + .map(e => e.path) + .filter(p => !selected.has(p)) + for (const p of excluded) { + expect( + p.startsWith('publish-infra/cargo/') || + p.startsWith('publish-infra/brew/') || + p === 'cargo-publish.mts' || + p === 'brew-publish.mts' || + p === 'templates/workflows/cargo-publish.yml' || + p === 'templates/workflows/brew-publish.yml' || + p === 'lib/commit-via-github-api.mts' || + p.startsWith('templates/actions/') || + p === 'util/pack-app-triplets.mts', + p, + ).toBe(true) + } + expect(excluded).toContain('cargo-publish.mts') + expect(excluded).toContain('brew-publish.mts') + expect(selected.has('bootstrap.mts')).toBe(true) + expect(selected.has('npm-publish.mts')).toBe(true) + }) + + it('common is always implied', () => { + const manifest = buildManifest() + const onlyBrew = filterByChannels(manifest.files, ['brew']) + expect(onlyBrew.some(e => e.path === 'bootstrap.mts')).toBe(true) + expect(onlyBrew.some(e => e.path === 'brew-publish.mts')).toBe(true) + expect(onlyBrew.some(e => e.path === 'cargo-publish.mts')).toBe(false) + }) +}) + +describe('parseKitManifest refusals', () => { + it('rejects unparseable, foreign-schema, and malformed entries', () => { + expect(() => parseKitManifest('nope', 'x')).toThrowError(/not valid JSON/) + expect(() => + parseKitManifest('{"schemaVersion":2,"files":[]}', 'x'), + ).toThrowError(/foreign schema/) + expect(() => + parseKitManifest( + '{"schemaVersion":1,"files":[{"path":"a","sha256":"short","channels":["common"]}]}', + 'x', + ), + ).toThrowError(/malformed/) + }) +}) + +describe('parseChannelsFlag', () => { + it('accepts the channel list and drops the implied common', () => { + expect(parseChannelsFlag('npm, github-release,common')).toEqual([ + 'npm', + 'github-release', + ]) + }) + + it('refuses unknown channels naming the valid set', () => { + expect(() => parseChannelsFlag('npm,docker')).toThrowError( + /brew, crates, github-release, npm/, + ) + expect(() => parseChannelsFlag('common')).toThrowError(/no channels/) + }) +}) diff --git a/test/repo/unit/release-kit/install/plan.test.mts b/test/repo/unit/release-kit/install/plan.test.mts new file mode 100644 index 00000000..ee149625 --- /dev/null +++ b/test/repo/unit/release-kit/install/plan.test.mts @@ -0,0 +1,59 @@ +/** + * @file The pure install planner: copy / skip-identical / conflict + * classification, the idempotent empty second plan, and missing-target + * classification. The planner never touches fs — inputs are maps. + */ + +import { describe, expect, it } from 'vitest' + +import { planInstall } from '../../../../../release-kit/install/plan.mts' +import type { ManifestEntry } from '../../../../../release-kit/install/manifest.mts' + +const entries: ManifestEntry[] = [ + { channels: ['common'], path: 'bootstrap.mts', sha256: 'a'.repeat(64) }, + { channels: ['npm'], path: 'npm-publish.mts', sha256: 'b'.repeat(64) }, + { + channels: ['npm'], + path: 'publish-infra/npm/shared.mts', + sha256: 'c'.repeat(64), + }, +] + +describe('planInstall', () => { + it('classifies copy / identical / conflict', () => { + const plan = planInstall({ + entries, + targetReads: new Map([ + ['bootstrap.mts', undefined], + ['npm-publish.mts', 'b'.repeat(64)], + ['publish-infra/npm/shared.mts', 'f'.repeat(64)], + ]), + }) + expect(plan.copies.map(f => f.path)).toEqual(['bootstrap.mts']) + expect(plan.identical.map(f => f.path)).toEqual(['npm-publish.mts']) + expect(plan.conflicts).toEqual([ + { + action: 'conflict', + path: 'publish-infra/npm/shared.mts', + sawSha256: 'f'.repeat(64), + sha256: 'c'.repeat(64), + }, + ]) + }) + + it('a missing target read classifies every file as copy', () => { + const plan = planInstall({ entries, targetReads: new Map() }) + expect(plan.copies).toHaveLength(3) + expect(plan.conflicts).toEqual([]) + }) + + it('an identical target plans an EMPTY second install', () => { + const plan = planInstall({ + entries, + targetReads: new Map(entries.map(e => [e.path, e.sha256])), + }) + expect(plan.copies).toEqual([]) + expect(plan.conflicts).toEqual([]) + expect(plan.identical).toHaveLength(3) + }) +}) diff --git a/test/repo/unit/release-kit/lib/workspace-yaml.test.mts b/test/repo/unit/release-kit/lib/workspace-yaml.test.mts new file mode 100644 index 00000000..241e593e --- /dev/null +++ b/test/repo/unit/release-kit/lib/workspace-yaml.test.mts @@ -0,0 +1,137 @@ +/** + * @file Branch coverage for the pnpm-workspace.yaml string helpers: catalog + * block parsing (quoted/unquoted/comment-tailed), list-block parsing with + * negations and comments, named-catalog parsing, and the splice/remove + * editors including the create-block, in-place-bump, and no-op paths. + */ + +import { describe, expect, it } from 'vitest' + +import { + parseCatalogBlock, + parseListBlock, + parseNamedCatalogs, + removeCatalogEntry, + spliceCatalogEntry, +} from '../../../../../release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts' + +const WS = [ + 'packages:', + " - '.config/fleet/oxlint-plugin'", + ' # a comment line inside the list', + ' - "double-quoted"', + ' - bare-entry', + ' - !negated', + '', + 'catalog:', + " '@types/node': 26.1.1 # trailing comment", + ' micromark: 4.0.2', + '', + 'overrides:', + ' glob: 13.0.6', + '', + 'catalogs:', + ' react17:', + ' react: 17.0.2', + " 'react-dom': 17.0.2", + ' vue2:', + ' vue: 2.7.16', + '', + 'minimumReleaseAge: 10080', +].join('\n') + +describe('parseCatalogBlock', () => { + it('reads quoted and unquoted keys, dropping trailing comments', () => { + const catalog = parseCatalogBlock(WS) + expect(catalog['@types/node']).toBe('26.1.1') + expect(catalog['micromark']).toBe('4.0.2') + expect(Object.keys(catalog)).toHaveLength(2) + }) + + it('targets another block via blockKey', () => { + expect(parseCatalogBlock(WS, { blockKey: 'overrides' })['glob']).toBe( + '13.0.6', + ) + }) + + it('returns empty when the block is absent', () => { + expect(parseCatalogBlock('packages:\n - x\n')).toEqual({}) + }) +}) + +describe('parseListBlock', () => { + it('reads single/double/bare/negated entries and skips comments', () => { + const list = parseListBlock(WS, { blockKey: 'packages' }) + expect(list).toEqual([ + '.config/fleet/oxlint-plugin', + 'double-quoted', + 'bare-entry', + '!negated', + ]) + }) + + it('returns empty for a missing block', () => { + expect( + parseListBlock('catalog:\n a: 1\n', { blockKey: 'packages' }), + ).toEqual([]) + }) +}) + +describe('parseNamedCatalogs', () => { + it('reads two-level named catalogs and ignores entries with no active name', () => { + const named = parseNamedCatalogs(WS) + expect(named['react17']).toEqual({ react: '17.0.2', 'react-dom': '17.0.2' }) + expect(named['vue2']).toEqual({ vue: '2.7.16' }) + }) + + it('returns empty when there is no catalogs block', () => { + expect(parseNamedCatalogs('catalog:\n a: 1\n')).toEqual({}) + }) +}) + +describe('spliceCatalogEntry', () => { + it('inserts alphabetically and preserves siblings', () => { + const next = spliceCatalogEntry(WS, 'lodash', '4.17.21') + const catalog = parseCatalogBlock(next) + expect(catalog['lodash']).toBe('4.17.21') + expect(catalog['micromark']).toBe('4.0.2') + const idxTypes = next.indexOf("'@types/node'") + const idxLodash = next.indexOf("'lodash'") + const idxMicro = next.indexOf('micromark:') + expect(idxTypes).toBeLessThan(idxLodash) + expect(idxLodash).toBeLessThan(idxMicro) + }) + + it('rewrites an existing entry in place on a version bump', () => { + const bumped = spliceCatalogEntry(WS, '@types/node', '27.0.0') + expect(parseCatalogBlock(bumped)['@types/node']).toBe('27.0.0') + expect( + bumped.split('\n').filter(l => l.includes('@types/node')), + ).toHaveLength(1) + }) + + it('is a no-op when the entry is already at the wanted version', () => { + expect(spliceCatalogEntry(WS, 'micromark', '4.0.2')).toBe(WS) + }) + + it('creates the catalog block when none exists', () => { + const next = spliceCatalogEntry('packages:\n - x\n', 'semver', '7.8.5') + expect(next.startsWith('catalog:\n')).toBe(true) + expect(parseCatalogBlock(next)['semver']).toBe('7.8.5') + }) +}) + +describe('removeCatalogEntry', () => { + it('removes the named entry regardless of its version', () => { + const next = removeCatalogEntry(WS, '@types/node') + expect(parseCatalogBlock(next)['@types/node']).toBeUndefined() + expect(parseCatalogBlock(next)['micromark']).toBe('4.0.2') + }) + + it('is a no-op for an absent entry or an absent block', () => { + expect(removeCatalogEntry(WS, 'not-present')).toBe(WS) + expect(removeCatalogEntry('packages:\n - x\n', 'anything')).toBe( + 'packages:\n - x\n', + ) + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/brew/formula.test.mts b/test/repo/unit/release-kit/publish-infra/brew/formula.test.mts new file mode 100644 index 00000000..8d45b35a --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/brew/formula.test.mts @@ -0,0 +1,139 @@ +/** + * @file Formula render/parse/plan: byte-exact render vs the golden, the + * parse round-trip vs the parsed golden, planFormulaBump's three actions, + * className edges, and the unparseable-never-throws contract. The + * round-trip property runs over a small spec matrix (deterministic + * property-style loop). + */ + +import { describe, expect, it } from 'vitest' + +import { + FORMULA_PLATFORMS, + parseFormula, + planFormulaBump, + renderFormula, + versionFromUrl, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import type { FormulaSpec } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { formulaClassName } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts' +import { fixture } from '../../helpers.mts' + +function spec(version = '1.2.3', shaSeed = ['1', '2', '3', '4']): FormulaSpec { + const platforms = {} as FormulaSpec['platforms'] + for (let i = 0; i < FORMULA_PLATFORMS.length; i += 1) { + const p = FORMULA_PLATFORMS[i]! + platforms[p] = { + sha256: shaSeed[i]!.repeat(64), + url: `https://github.com/SocketDev/example-cli/releases/download/v${version}/examplecli-${p}.tar.gz`, + } + } + return { + className: 'Examplecli', + desc: 'examplecli (Socket release)', + homepage: 'https://github.com/SocketDev/example-cli', + license: 'MIT', + name: 'examplecli', + platforms, + } +} + +describe('renderFormula', () => { + // BYTE-PIN EXCEPTION: the golden formula is a byte contract with Homebrew. + it('renders byte-exact against examplecli-fresh.golden.rb', () => { + expect(renderFormula(spec())).toBe( + fixture('formula/examplecli-fresh.golden.rb'), + ) + }) + + it('URLs are exact v pins, never latest', () => { + const rendered = renderFormula(spec()) + expect(rendered).toContain('/releases/download/v1.2.3/') + expect(rendered).not.toContain('/releases/latest') + }) +}) + +describe('parseFormula', () => { + it('round-trips the render (vs examplecli-parsed.golden.json)', () => { + const parsed = parseFormula(renderFormula(spec())) + expect(parsed).toEqual( + JSON.parse(fixture('formula/examplecli-parsed.golden.json')), + ) + expect(parsed?.version).toBe('1.2.3') + expect(parsed?.name).toBe('examplecli') + expect(Object.keys(parsed?.platforms ?? {})).toHaveLength(4) + }) + + it('an unparseable file returns undefined — never throws', () => { + expect( + parseFormula(fixture('formula/examplecli-unparseable.rb')), + ).toBeUndefined() + }) +}) + +describe('planFormulaBump', () => { + it('create / update / unchanged', () => { + expect(planFormulaBump(undefined, spec()).action).toBe('create') + expect( + planFormulaBump(fixture('formula/examplecli-existing.rb'), spec()).action, + ).toBe('update') + expect(planFormulaBump(renderFormula(spec()), spec()).action).toBe( + 'unchanged', + ) + }) + + it('unchanged iff version AND all four url/sha256 pairs match', () => { + const drifted = spec() + drifted.platforms['linux-x64'] = { + ...drifted.platforms['linux-x64'], + sha256: '9'.repeat(64), + } + expect(planFormulaBump(renderFormula(spec()), drifted).action).toBe( + 'update', + ) + }) + + it('an unparseable current file is an update (replace-whole-file), never a crash', () => { + expect( + planFormulaBump(fixture('formula/examplecli-unparseable.rb'), spec()) + .action, + ).toBe('update') + }) + + it('property: plan(render(spec), spec).action === unchanged across a spec matrix', () => { + const versions = ['0.1.0', '1.2.3', '10.20.30'] + const seeds = [ + ['a', 'b', 'c', 'd'], + ['1', '2', '3', '4'], + ['f', 'e', 'd', 'c'], + ] + for (const version of versions) { + for (const seed of seeds) { + const s = spec(version, seed) + expect(planFormulaBump(renderFormula(s), s).action).toBe('unchanged') + } + } + }) +}) + +describe('edges', () => { + it('versionFromUrl extracts the pinned tag version', () => { + expect( + versionFromUrl( + 'https://github.com/a/b/releases/download/v9.9.9/x.tar.gz', + ), + ).toBe('9.9.9') + expect(versionFromUrl('https://example.com/no-release')).toBeUndefined() + }) + + it('formulaClassName splits on -_. and capitalizes', () => { + expect(formulaClassName('example-cli')).toBe('ExampleCli') + expect(formulaClassName('my_tool.next')).toBe('MyToolNext') + }) + + it('a digit-leading token throws', () => { + expect(() => formulaClassName('7zip')).toThrowError( + 'Homebrew class names cannot start with a digit', + ) + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/brew/shared.test.mts b/test/repo/unit/release-kit/publish-infra/brew/shared.test.mts new file mode 100644 index 00000000..45fe6d5f --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/brew/shared.test.mts @@ -0,0 +1,86 @@ +/** + * @file Brew pure helpers: tap normalization (both forms + refusal), the + * dual-grammar checksums parser (sha1/sha512 lines ignored, + * duplicate-conflict throws), and asset templating including . + */ + +import { describe, expect, it } from 'vitest' + +import { + assetNamesForTriplets, + formulaPath, + normalizeTap, + parseChecksumsTxt, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts' +import { fixture } from '../../helpers.mts' + +describe('normalizeTap', () => { + it('accepts the brew slug form', () => { + expect(normalizeTap('SocketDev/socket')).toEqual({ + repo: 'SocketDev/homebrew-socket', + slug: 'SocketDev/socket', + }) + }) + + it('accepts the repo form', () => { + expect(normalizeTap('SocketDev/homebrew-socket')).toEqual({ + repo: 'SocketDev/homebrew-socket', + slug: 'SocketDev/socket', + }) + }) + + it('refuses anything else naming both forms', () => { + expect(() => normalizeTap('just-a-name')).toThrowError(/homebrew-socket/) + }) +}) + +describe('parseChecksumsTxt', () => { + it('parses the plain shasum grammar', () => { + const map = parseChecksumsTxt(fixture('checksums/shasum-format.txt')) + expect(map.size).toBe(4) + expect(map.get('examplecli-darwin-arm64.tar.gz')).toBe('1'.repeat(64)) + }) + + it('parses the kit sha256: grammar and ignores sha1/sha512 lines', () => { + const map = parseChecksumsTxt(fixture('checksums/kit-format.txt')) + expect(map.size).toBe(2) + expect(map.get('examplecli-darwin-arm64.tar.gz')).toBe('1'.repeat(64)) + expect(map.get('examplecli-darwin-x64.tar.gz')).toBe('2'.repeat(64)) + }) + + it('both grammars in one manifest agree on the same file without conflict', () => { + const map = parseChecksumsTxt( + `${'1'.repeat(64)} a.tar.gz\nsha256: ${'1'.repeat(64)} a.tar.gz\n`, + ) + expect(map.size).toBe(1) + }) + + it('a duplicate filename with DIFFERING hex throws', () => { + expect(() => + parseChecksumsTxt(fixture('checksums/duplicate-conflict.txt')), + ).toThrowError(/differing sha256/) + }) +}) + +describe('asset templating', () => { + it('expands //', () => { + expect( + assetNamesForTriplets( + 'examplecli', + '1.2.3', + '--.tar.gz', + ['darwin-arm64', 'linux-x64'], + ), + ).toEqual([ + { + asset: 'examplecli-1.2.3-darwin-arm64.tar.gz', + triplet: 'darwin-arm64', + }, + { asset: 'examplecli-1.2.3-linux-x64.tar.gz', triplet: 'linux-x64' }, + ]) + }) + + it('formulaPath is the unsharded Formula/.rb', () => { + expect(formulaPath('examplecli')).toBe('Formula/examplecli.rb') + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/brew/tap.test.mts b/test/repo/unit/release-kit/publish-infra/brew/tap.test.mts new file mode 100644 index 00000000..ac704045 --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/brew/tap.test.mts @@ -0,0 +1,207 @@ +/** + * @file RunBrewPublish through fake BrewSeams: the four ordered refusals + * (tag / draft / asset / checksums — check ids + exit 1 + ZERO commit + * calls), the unchanged no-op, the dry-run default, the apply commit shape, + * and the re-read-mismatch saved-state-unproven exit. + */ + +import { describe, expect, it } from 'vitest' + +import { runBrewPublish } from '../../../../../../release-kit/payload/scripts/socket-release/brew-publish.mts' +import type { BrewSeams } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/tap.mts' +import { renderFormula } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import type { FormulaSpec } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { fixture } from '../../helpers.mts' + +const ASSETS = [ + 'examplecli-darwin-arm64.tar.gz', + 'examplecli-darwin-x64.tar.gz', + 'examplecli-linux-arm64.tar.gz', + 'examplecli-linux-x64.tar.gz', +] + +interface FakeBrew { + commits: Array<{ + content: string + message: string + path: string + repo: string + }> + seams: BrewSeams +} + +function fakeBrewSeams(config?: { + assets?: string[] | undefined + checksums?: string | undefined + commitEcho?: boolean | undefined + isDraft?: boolean | undefined + releaseExists?: boolean | undefined + tagExists?: boolean | undefined + tapFormula?: string | undefined +}): FakeBrew { + const cfg = { __proto__: null, ...config } as NonNullable + const commits: FakeBrew['commits'] = [] + let tapContent = cfg.tapFormula + const seams: BrewSeams = { + commitFile: async c => { + commits.push({ ...c }) + if (cfg.commitEcho !== false) { + tapContent = c.content + } + }, + downloadChecksums: async () => + cfg.checksums === undefined + ? fixture('checksums/shasum-format.txt') + : cfg.checksums || undefined, + ghApiJson: async p => + p.includes('/git/ref/tags/') + ? (cfg.tagExists ?? true) + ? { body: { object: { sha: 'abc' } }, code: 0 } + : { body: undefined, code: 1 } + : { body: {}, code: 0 }, + ghReleaseView: async () => ({ + assets: cfg.assets ?? ASSETS, + exists: cfg.releaseExists ?? true, + isDraft: cfg.isDraft ?? false, + }), + readTapFile: async () => + tapContent === undefined ? undefined : { content: tapContent, sha: 'x' }, + } + return { commits, seams } +} + +function run(fake: FakeBrew, overrides?: Record) { + return runBrewPublish({ + apply: false, + brewConfig: { + assetTemplate: '-.tar.gz', + formula: 'examplecli', + tap: 'SocketDev/socket', + triplets: ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'], + }, + json: false, + repoRoot: '/tmp/example-repo', + seams: fake.seams, + slug: 'SocketDev/example-cli', + tag: 'v1.2.3', + ...overrides, + }) +} + +describe('refusals (exit 1, zero commits)', () => { + it('tag not on origin → tag-on-origin', async () => { + const fake = fakeBrewSeams({ tagExists: false }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + expect(result.checks.at(-1)!.id).toBe('tag-on-origin') + expect(fake.commits).toEqual([]) + }) + + it('draft release → release-published', async () => { + const fake = fakeBrewSeams({ isDraft: true }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + expect(result.checks.at(-1)!.id).toBe('release-published') + expect(fake.commits).toEqual([]) + }) + + it('missing asset → assets-present naming the asset', async () => { + const fake = fakeBrewSeams({ assets: ASSETS.slice(0, 3) }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + const check = result.checks.at(-1)! + expect(check.id).toBe('assets-present') + expect(check.saw).toContain('examplecli-linux-x64.tar.gz') + expect(fake.commits).toEqual([]) + }) + + it('missing checksums.txt → checksums-authority pointing at github-release.mts, never the dead create-release.mts', async () => { + const fake = fakeBrewSeams({ checksums: '' }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + const check = result.checks.at(-1)! + expect(check.id).toBe('checksums-authority') + expect(check.fix).toContain('github-release.mts') + expect(check.fix).not.toContain('create-release.mts') + expect(fake.commits).toEqual([]) + }) + + it('a checksums.txt not covering an asset → checksums-cover-assets', async () => { + const fake = fakeBrewSeams({ + checksums: `${'1'.repeat(64)} examplecli-darwin-arm64.tar.gz\n`, + }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + expect(result.checks.at(-1)!.id).toBe('checksums-cover-assets') + expect(fake.commits).toEqual([]) + }) +}) + +function desiredSpec(): FormulaSpec { + return { + className: 'Examplecli', + desc: 'examplecli (Socket release)', + homepage: 'https://github.com/SocketDev/example-cli', + license: 'MIT', + name: 'examplecli', + platforms: { + 'darwin-arm64': { + sha256: '1'.repeat(64), + url: 'https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-arm64.tar.gz', + }, + 'darwin-x64': { + sha256: '2'.repeat(64), + url: 'https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-darwin-x64.tar.gz', + }, + 'linux-arm64': { + sha256: '3'.repeat(64), + url: 'https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-arm64.tar.gz', + }, + 'linux-x64': { + sha256: '4'.repeat(64), + url: 'https://github.com/SocketDev/example-cli/releases/download/v1.2.3/examplecli-linux-x64.tar.gz', + }, + }, + } +} + +describe('no-op and dry-run', () => { + it('an identical formula → unchanged, exit 0, zero commits', async () => { + const fake = fakeBrewSeams({ tapFormula: renderFormula(desiredSpec()) }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(0) + expect(result.action).toBe('unchanged') + expect(fake.commits).toEqual([]) + }) + + it('dry-run default performs zero mutating seam calls', async () => { + const fake = fakeBrewSeams() + const result = await run(fake) + expect(result.exitCode).toBe(0) + expect(result.action).toBe('create') + expect(fake.commits).toEqual([]) + }) +}) + +describe('apply', () => { + it('commits once with the expected {repo, path, message, content}', async () => { + const fake = fakeBrewSeams() + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(0) + expect(fake.commits).toHaveLength(1) + expect(fake.commits[0]).toEqual({ + content: renderFormula(desiredSpec()), + message: 'chore: bump examplecli to 1.2.3', + path: 'Formula/examplecli.rb', + repo: 'SocketDev/homebrew-socket', + }) + }) + + it('a re-read mismatch is saved-state unproven (exit 1)', async () => { + const fake = fakeBrewSeams({ commitEcho: false }) + const result = await run(fake, { apply: true }) + expect(result.exitCode).toBe(1) + expect(result.checks.at(-1)!.id).toBe('formula-verified') + expect(result.checks.at(-1)!.ok).toBe(false) + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/cargo/shared.test.mts b/test/repo/unit/release-kit/publish-infra/cargo/shared.test.mts new file mode 100644 index 00000000..83886295 --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/cargo/shared.test.mts @@ -0,0 +1,51 @@ +/** + * @file The pure cargo metadata helpers the staged/direct crate flows share: + * the crates.io publishability rule and the packaged-artifact path. + */ + +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + CARGO_APPROVE_COMMAND, + cratePath, + isPublishable, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/cargo/shared.mts' + +describe('isPublishable', () => { + it('treats the Cargo.toml default (null/undefined) as publishable', () => { + expect(isPublishable(null)).toBe(true) + expect(isPublishable(undefined)).toBe(true) + }) + + it('treats a non-empty registry allowlist as publishable', () => { + expect(isPublishable(['crates-io'])).toBe(true) + }) + + it('treats an explicit empty allowlist (publish = false) as not publishable', () => { + expect(isPublishable([])).toBe(false) + }) + + it('treats any non-array truthy value as not publishable', () => { + expect(isPublishable('crates-io')).toBe(false) + expect(isPublishable(true)).toBe(false) + expect(isPublishable(0)).toBe(false) + }) +}) + +describe('cratePath', () => { + it('resolves the target/package/-.crate artifact', () => { + const p = cratePath('mycrate', '1.2.3') + expect( + p.endsWith(path.join('target', 'package', 'mycrate-1.2.3.crate')), + ).toBe(true) + expect(path.isAbsolute(p)).toBe(true) + }) +}) + +describe('CARGO_APPROVE_COMMAND', () => { + it('is the channel-enforced approve script', () => { + expect(CARGO_APPROVE_COMMAND).toBe('pnpm run cargo:publish -- --approve') + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/npm/parsers.test.mts b/test/repo/unit/release-kit/publish-infra/npm/parsers.test.mts new file mode 100644 index 00000000..8de085b6 --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/npm/parsers.test.mts @@ -0,0 +1,350 @@ +/** + * @file Branch-exhaustive unit coverage for the three pure npm page parsers: + * the publishing-access toggles, the trusted-publisher summary, and the + * staged-tarball passback. Every classification arm, every JSON fallback, + * and every refusal is pinned here; the fuzz suite proves robustness on top. + */ + +import { describe, expect, it } from 'vitest' + +import { + classifyPublishingAccess, + parsePublishingAccess, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import { + allowsAction, + classifyAccessPage, + extractAllowedActions, + parseTrustedPublisherForm, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts' +import { + classifyStagedFetch, + isCloudflareChallenge, + looksLikeHtmlBody, + mapStagedTarball, + parseStagedPayload, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/staged-browser-parse.mts' + +describe('classifyPublishingAccess', () => { + it('maps every readable pair and refuses on either unreadable toggle', () => { + expect(classifyPublishingAccess(true, true)).toBe('both-enabled') + expect(classifyPublishingAccess(true, false)).toBe('direct-only') + expect(classifyPublishingAccess(false, true)).toBe('staged-only') + expect(classifyPublishingAccess(false, false)).toBe('unknown') + expect(classifyPublishingAccess(undefined, true)).toBe('unknown') + expect(classifyPublishingAccess(true, undefined)).toBe('unknown') + expect(classifyPublishingAccess(undefined, undefined)).toBe('unknown') + }) +}) + +describe('parsePublishingAccess', () => { + it('reads checked checkboxes regardless of attribute order', () => { + const html = + '\n' + + '' + const read = parsePublishingAccess(html) + expect(read.directEnabled).toBe(true) + expect(read.stagedEnabled).toBe(false) + expect(read.state).toBe('direct-only') + }) + + it('falls back to plain and escaped React JSON keys when no checkbox tag', () => { + const plain = parsePublishingAccess( + '"directPublishEnabled": true, "stagedPublishEnabled": false', + ) + expect(plain.directEnabled).toBe(true) + expect(plain.stagedEnabled).toBe(false) + const escaped = parsePublishingAccess( + '\\"directPublishEnabled\\": false, \\"stagedPublishEnabled\\": true', + ) + expect(escaped.directEnabled).toBe(false) + expect(escaped.stagedEnabled).toBe(true) + expect(escaped.state).toBe('staged-only') + }) + + it('reads unknown when neither a checkbox nor a JSON key is present', () => { + const read = parsePublishingAccess( + 'no access block', + ) + expect(read.directEnabled).toBeUndefined() + expect(read.stagedEnabled).toBeUndefined() + expect(read.state).toBe('unknown') + }) +}) + +describe('classifyAccessPage', () => { + it('a challenge body wins over any status', () => { + expect(classifyAccessPage({ body: 'Just a moment...', status: 200 })).toBe( + 'challenge', + ) + expect( + classifyAccessPage({ body: 'cdn-cgi/challenge-platform/', status: 503 }), + ).toBe('challenge') + }) + + it('401/403 or a signed-out page reads as auth', () => { + expect(classifyAccessPage({ body: '', status: 401 })).toBe('auth') + expect(classifyAccessPage({ body: '', status: 403 })).toBe('auth') + expect( + classifyAccessPage({ body: 'Please sign in to npm', status: 200 }), + ).toBe('auth') + }) + + it('a sign-in page that also mentions Trusted Publishing is not auth', () => { + expect( + classifyAccessPage({ + body: 'sign in to npm — Trusted Publishing', + status: 200, + }), + ).not.toBe('auth') + }) + + it('non-2xx statuses outside 401/403 are errors', () => { + expect(classifyAccessPage({ body: '', status: 500 })).toBe('error') + expect(classifyAccessPage({ body: '', status: 100 })).toBe('error') + }) + + it('configured pages via marker or JSON keys', () => { + expect( + classifyAccessPage({ + body: '
o/r
', + status: 200, + }), + ).toBe('configured') + expect( + classifyAccessPage({ body: '"trustedPublisher":{}', status: 200 }), + ).toBe('configured') + expect( + classifyAccessPage({ + body: '\\"trustedPublisherConfigured\\":true', + status: 200, + }), + ).toBe('configured') + }) + + it('unconfigured shells via any of the three access-page markers', () => { + expect(classifyAccessPage({ body: 'Trusted Publisher', status: 200 })).toBe( + 'unconfigured', + ) + expect(classifyAccessPage({ body: 'Publishing access', status: 200 })).toBe( + 'unconfigured', + ) + expect( + classifyAccessPage({ body: 'window.publishingAccess = 1', status: 200 }), + ).toBe('unconfigured') + }) + + it('a bare HTML body with no markers is unconfigured, a non-HTML body is an error', () => { + expect( + classifyAccessPage({ body: 'hi', status: 200 }), + ).toBe('unconfigured') + expect(classifyAccessPage({ body: 'plain text', status: 200 })).toBe( + 'error', + ) + }) +}) + +describe('parseTrustedPublisherForm', () => { + it('parses owner/name/workflow/environment off the configured summary', () => { + const html = [ + 'SocketDev/example', + 'npm-publish.yml', + 'npm-publish', + ].join('\n') + const parsed = parseTrustedPublisherForm(html) + expect(parsed).toEqual({ + allowedActions: [], + environmentName: 'npm-publish', + repositoryName: 'example', + repositoryOwner: 'SocketDev', + workflowFilename: 'npm-publish.yml', + }) + }) + + it('treats a repo with no slash as owner-only and an empty env as undefined', () => { + const parsed = parseTrustedPublisherForm( + 'justowner ', + ) + expect(parsed!.repositoryOwner).toBe('justowner') + expect(parsed!.repositoryName).toBeUndefined() + expect(parsed!.environmentName).toBeUndefined() + }) + + it('reads the environment name from the escaped React JSON fallback', () => { + const parsed = parseTrustedPublisherForm( + 'release.yml\\"githubEnvironmentName\\":\\"prod\\"', + ) + expect(parsed!.workflowFilename).toBe('release.yml') + expect(parsed!.environmentName).toBe('prod') + }) + + it('returns undefined when neither the repo nor the workflow marker is present', () => { + expect( + parseTrustedPublisherForm('nothing here'), + ).toBeUndefined() + }) +}) + +describe('extractAllowedActions / allowsAction', () => { + it('reads permission chips out of the Permissions block only', () => { + const html = + 'Permissions:
npm publish npm stage publish
' + + '
npm publish
' + const actions = extractAllowedActions(html) + expect(actions).toContain('npm publish') + expect(actions).toContain('npm stage publish') + }) + + it('reads checked publish/stage checkboxes', () => { + const html = + '' + const actions = extractAllowedActions(html) + expect(actions).toEqual( + expect.arrayContaining(['npm publish', 'npm stage publish']), + ) + }) + + it('ignores unchecked checkboxes and unrelated code tags', () => { + const html = 'rm -rf' + expect(extractAllowedActions(html)).toEqual([]) + }) + + it('distinguishes the plain publish action from stage publish', () => { + expect(allowsAction(['npm publish'], 'publish')).toBe(true) + expect(allowsAction(['npm publish'], 'stage-publish')).toBe(false) + expect(allowsAction(['npm stage publish'], 'stage-publish')).toBe(true) + expect(allowsAction(['npm stage publish'], 'publish')).toBe(false) + expect(allowsAction([], 'publish')).toBe(false) + }) +}) + +describe('staged-browser-parse helpers', () => { + it('detects each Cloudflare marker and rejects an empty body', () => { + expect(isCloudflareChallenge('')).toBe(false) + expect(isCloudflareChallenge('Just a moment')).toBe(true) + expect(isCloudflareChallenge('cf-chl-bypass')).toBe(true) + expect(isCloudflareChallenge('_cf_chl_opt')).toBe(true) + expect(isCloudflareChallenge('challenges.cloudflare.com/turnstile')).toBe( + true, + ) + expect( + isCloudflareChallenge('Checking if the site connection is secure'), + ).toBe(true) + }) + + it('recognizes HTML documents and rejects JSON bodies', () => { + expect(looksLikeHtmlBody('')).toBe(true) + expect(looksLikeHtmlBody(' ')).toBe(true) + expect(looksLikeHtmlBody('x')).toBe(true) + expect(looksLikeHtmlBody('{"ok":true}')).toBe(false) + }) + + it('classifies fetch outcomes by body then status', () => { + expect(classifyStagedFetch({ body: 'Just a moment', status: 200 })).toBe( + 'challenge', + ) + expect(classifyStagedFetch({ body: '', status: 200 })).toBe( + 'challenge', + ) + expect(classifyStagedFetch({ body: '{}', status: 401 })).toBe('auth') + expect(classifyStagedFetch({ body: '{}', status: 403 })).toBe('auth') + expect(classifyStagedFetch({ body: '{}', status: 500 })).toBe('error') + expect(classifyStagedFetch({ body: '{}', status: 200 })).toBe('ok') + expect(classifyStagedFetch({ status: 200 })).toBe('ok') + }) +}) + +describe('mapStagedTarball / parseStagedPayload', () => { + it('maps the primary field names', () => { + const t = mapStagedTarball({ + dateStaged: '2026-07-31', + packageName: '@scope/pkg', + shasum: 'abc', + stageId: 'stage-1', + stagedBy: { tarballUrl: 'https://x/t.tgz' }, + tag: 'latest', + version: '1.0.0', + }) + expect(t).toEqual({ + createdAt: '2026-07-31', + id: 'stage-1', + packageName: '@scope/pkg', + shasum: 'abc', + tag: 'latest', + tarballUrl: 'https://x/t.tgz', + version: '1.0.0', + }) + }) + + it('falls back to the alternate field names', () => { + const t = mapStagedTarball({ + created: '2026-01-01', + id: 'id-2', + name: 'plain', + tarball: 'https://x/fallback.tgz', + version: '2.0.0', + }) + expect(t.createdAt).toBe('2026-01-01') + expect(t.id).toBe('id-2') + expect(t.packageName).toBe('plain') + expect(t.tarballUrl).toBe('https://x/fallback.tgz') + }) + + it('defaults identity fields to empty strings for an empty record', () => { + const t = mapStagedTarball({}) + expect(t.id).toBe('') + expect(t.packageName).toBe('') + expect(t.version).toBe('') + expect(t.tarballUrl).toBeUndefined() + }) + + it('parses the full envelope and reads total from the payload number', () => { + const body = JSON.stringify({ + approveURL: '/approve', + csrftoken: 'tok', + rejectURL: '/reject', + stagedVersions: { + objects: [{ packageName: 'a', stageId: 's1', version: '1.0.0' }], + total: 7, + }, + }) + const payload = parseStagedPayload(body) + expect(payload.approveUrl).toBe('/approve') + expect(payload.csrfToken).toBe('tok') + expect(payload.rejectUrl).toBe('/reject') + expect(payload.total).toBe(7) + expect(payload.tarballs).toHaveLength(1) + }) + + it('falls back total to the object count when the payload omits it', () => { + const body = JSON.stringify({ + stagedVersions: { + objects: [{ packageName: 'a', stageId: 's1', version: '1' }], + }, + }) + expect(parseStagedPayload(body).total).toBe(1) + }) + + it('narrows the tarball list by a scoped package filter', () => { + const body = JSON.stringify({ + stagedVersions: { + objects: [ + { packageName: '@scope/keep', stageId: 's1', version: '1' }, + { packageName: 'other', stageId: 's2', version: '1' }, + ], + }, + }) + const payload = parseStagedPayload(body, '@scope/keep') + expect(payload.tarballs.map(t => t.packageName)).toEqual(['@scope/keep']) + }) + + it('degrades a non-object JSON body to an empty envelope instead of crashing', () => { + const payload = parseStagedPayload('null') + expect(payload.tarballs).toEqual([]) + expect(payload.total).toBe(0) + }) + + it('throws loudly on a non-JSON body', () => { + expect(() => parseStagedPayload('Just a moment')).toThrow() + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/npm/publish-helpers.test.mts b/test/repo/unit/release-kit/publish-infra/npm/publish-helpers.test.mts new file mode 100644 index 00000000..d4a9d4aa --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/npm/publish-helpers.test.mts @@ -0,0 +1,328 @@ +/** + * @file Unit coverage for the release-time publish helpers the workflows drive + * but nothing exercised before: the access resolver (kit config wins, then + * publishConfig, then the scoped/unscoped default — the regression guard for + * the hard-coded `--access public` bug), the already-published decision, the + * staged-shasum reader across both wire shapes, the packument URL's scope + * escaping, unknown-flag detection, and the changelog section extractor. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { packumentUrl } from '../../../../../../release-kit/payload/scripts/socket-release/constants/npm-registry.mts' +import { + unexpectedPositionalsMessage, + unknownFlags, + unknownFlagsMessage, +} from '../../../../../../release-kit/payload/scripts/socket-release/_shared/cli-flags.mts' +import { + readPublishConfigAccess, + resolveNpmAccess, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/shared.mts' +import { + runDirect, + stageAction, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/staged.mts' +import { readStagedShasum } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/shared.mts' +import { extractChangelogSection } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/release.mts' +import { + parsePublishArgs, + parsePublishArgv, +} from '../../../../../../release-kit/payload/scripts/socket-release/npm-publish.mts' + +describe('resolveNpmAccess', () => { + it('honors the kit config over everything', () => { + expect( + resolveNpmAccess({ + kitConfigAccess: 'restricted', + packageName: 'plain-pkg', + publishConfigAccess: 'public', + }), + ).toBe('restricted') + }) + + it('falls back to publishConfig.access when the kit config is silent', () => { + expect( + resolveNpmAccess({ + kitConfigAccess: undefined, + packageName: '@scope/pkg', + publishConfigAccess: 'restricted', + }), + ).toBe('restricted') + }) + + it('defaults a scoped package to restricted and an unscoped one to public', () => { + expect(resolveNpmAccess({ packageName: '@scope/pkg' })).toBe('restricted') + expect(resolveNpmAccess({ packageName: 'plain-pkg' })).toBe('public') + }) +}) + +describe('readPublishConfigAccess', () => { + it('reads publishConfig.access from a manifest, undefined when absent/invalid', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-access-')) + const restricted = path.join(dir, 'restricted.json') + const none = path.join(dir, 'none.json') + const bad = path.join(dir, 'bad.json') + writeFileSync( + restricted, + JSON.stringify({ publishConfig: { access: 'restricted' } }), + ) + writeFileSync(none, JSON.stringify({ name: 'x' })) + writeFileSync( + bad, + JSON.stringify({ publishConfig: { access: 'sideways' } }), + ) + expect(readPublishConfigAccess(restricted)).toBe('restricted') + expect(readPublishConfigAccess(none)).toBeUndefined() + expect(readPublishConfigAccess(bad)).toBeUndefined() + expect( + readPublishConfigAccess(path.join(dir, 'missing.json')), + ).toBeUndefined() + }) +}) + +describe('stageAction', () => { + it('is already-published when the target is the latest dist-tag', () => { + expect( + stageAction({ + publishedLatest: '1.2.3', + publishedVersions: [], + target: '1.2.3', + }), + ).toBe('already-published') + }) + + it('is already-published when the target appears in the versions list', () => { + expect( + stageAction({ + publishedLatest: '2.0.0', + publishedVersions: ['1.0.0', '1.2.3'], + target: '1.2.3', + }), + ).toBe('already-published') + }) + + it('is stage when the target is neither latest nor a known version', () => { + expect( + stageAction({ + publishedLatest: '1.2.3', + publishedVersions: ['1.0.0', '1.2.3'], + target: '1.3.0', + }), + ).toBe('stage') + }) +}) + +describe('readStagedShasum', () => { + it('prefers the top-level shasum shape', () => { + expect(readStagedShasum({ shasum: 'abc123' })).toBe('abc123') + }) + + it('falls back to dist.shasum', () => { + expect(readStagedShasum({ dist: { shasum: 'def456' } })).toBe('def456') + }) + + it('is undefined when neither shape carries a digest', () => { + expect(readStagedShasum({})).toBeUndefined() + expect(readStagedShasum({ shasum: '' })).toBeUndefined() + }) +}) + +describe('packumentUrl', () => { + it('joins the registry base and an unscoped name', () => { + expect(packumentUrl('lodash')).toBe('https://registry.npmjs.org/lodash') + }) + + it('un-escapes the scope @ (%40 → @) while leaving the encoded slash', () => { + expect(packumentUrl('@socketsecurity/lib')).toBe( + 'https://registry.npmjs.org/@socketsecurity%2Flib', + ) + }) +}) + +describe('unknownFlags', () => { + it('accepts a dash flag and its parseArgs camelCase mirror', () => { + const values = { 'dry-run': true, dryRun: true, staged: true } + expect(unknownFlags(values, ['dry-run', 'staged'])).toEqual([]) + }) + + it('flags a typo that is neither the dash form nor the camelCase mirror', () => { + const values = { dryrun: true, staged: true } + expect(unknownFlags(values, ['dry-run', 'staged'])).toEqual(['dryrun']) + }) + + it('renders each unknown flag with leading dashes', () => { + expect(unknownFlagsMessage(['dryrun'])).toBe('Unknown flag: --dryrun') + expect(unknownFlagsMessage(['x', 'yy'])).toBe('Unknown flags: -x, --yy') + }) +}) + +describe('unexpectedPositionalsMessage', () => { + it('names the stray token(s) and hints the dropped dashes', () => { + expect(unexpectedPositionalsMessage(['approve'])).toBe( + 'Unexpected argument: approve (did you drop a leading --?)', + ) + expect(unexpectedPositionalsMessage(['a', 'b'])).toBe( + 'Unexpected arguments: a, b (did you drop a leading --?)', + ) + }) +}) + +describe('parsePublishArgv captures stray positionals (dash-less mode typos)', () => { + it('folds a bare `approve` into positionals instead of a silent --staged fallthrough', () => { + const { positionals } = parsePublishArgv(['approve']) + expect(positionals).toEqual(['approve']) + }) + + it('separates a trailing positional from a real flag value', () => { + const { positionals } = parsePublishArgv(['--tag', 'latest', 'stray']) + expect(positionals).toEqual(['stray']) + }) + + it('reports no positionals for a well-formed flag-only invocation', () => { + expect(parsePublishArgv(['--staged', '--dry-run']).positionals).toEqual([]) + }) +}) + +describe('runDirect --dry-run on an already-published version', () => { + function tempRepo(version: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-direct-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: '@scope/pkg', version }), + ) + return dir + } + const alreadyPublished = async () => ({ + latest: '1.0.0', + versions: ['1.0.0'], + }) + + it('never touches the tag + GitHub release gate (no remote mutations)', async () => { + let releaseCalls = 0 + await runDirect('latest', { + dryRun: true, + root: tempRepo('1.0.0'), + fetchPublished: alreadyPublished, + ensureAlreadyPublishedRelease: async () => { + releaseCalls += 1 + return true + }, + }) + expect(releaseCalls).toBe(0) + }) + + it('runs the release gate for a real (non-dry-run) already-published direct publish', async () => { + let releaseCalls = 0 + await runDirect('latest', { + dryRun: false, + root: tempRepo('1.0.0'), + fetchPublished: alreadyPublished, + ensureAlreadyPublishedRelease: async () => { + releaseCalls += 1 + return true + }, + }) + expect(releaseCalls).toBe(1) + }) +}) + +describe('parsePublishArgs (documented --no-* opt-outs)', () => { + it('maps --no-reconcile to its declared key, never a phantom reconcile key', () => { + const values = parsePublishArgs(['--no-reconcile']) + expect(values['no-reconcile']).toBe(true) + expect(values['reconcile']).toBeUndefined() + }) + + it('maps --no-release to its declared key, never a phantom release key', () => { + const values = parsePublishArgs(['--approve', '--no-release']) + expect(values['no-release']).toBe(true) + expect(values['release']).toBeUndefined() + }) + + it('maps --no-scan to its declared key, never a phantom scan key', () => { + const values = parsePublishArgs(['--approve', '--no-scan']) + expect(values['no-scan']).toBe(true) + expect(values['scan']).toBeUndefined() + }) + + it('leaves every opt-out at its false default when omitted', () => { + const values = parsePublishArgs(['--staged']) + expect(values['no-reconcile']).toBe(false) + expect(values['no-release']).toBe(false) + expect(values['no-scan']).toBe(false) + }) +}) + +describe('extractChangelogSection', () => { + it('returns the section body for the version and stops at the next heading', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-changelog-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'x', version: '1.2.3' }), + ) + writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## 1.2.3\n\n- added a thing\n- fixed a thing\n\n## 1.2.2\n\n- older\n', + ) + expect(extractChangelogSection('1.2.3', dir)).toBe( + '- added a thing\n- fixed a thing', + ) + }) + + it('falls back to a one-liner when the version section is absent', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-changelog-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'x', version: '9.9.9' }), + ) + writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## 1.0.0\n\n- old\n', + ) + expect(extractChangelogSection('9.9.9', dir)).toBe('Release 9.9.9.') + }) + + it('does not grab a longer version whose heading begins with the target (1.2.30 above 1.2.3)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-changelog-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'x', version: '1.2.3' }), + ) + writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## 1.2.30\n\n- notes for 1.2.30\n\n## 1.2.3\n\n- notes for 1.2.3\n', + ) + expect(extractChangelogSection('1.2.3', dir)).toBe('- notes for 1.2.3') + }) + + it('does not grab a prerelease heading when the target is the final version (2.0.0-rc.1 above 2.0.0)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-changelog-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'x', version: '2.0.0' }), + ) + writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## 2.0.0-rc.1\n\n- prerelease notes\n\n## 2.0.0\n\n- final notes\n', + ) + expect(extractChangelogSection('2.0.0', dir)).toBe('- final notes') + }) + + it('still matches bracketed and dated headings at the version boundary', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-changelog-')) + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'x', version: '1.2.3' }), + ) + writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [1.2.3] - 2024-01-01\n\n- dated notes\n\n## [1.2.2]\n\n- older\n', + ) + expect(extractChangelogSection('1.2.3', dir)).toBe('- dated notes') + }) +}) diff --git a/test/repo/unit/release-kit/publish-infra/npm/trust-sweep.test.mts b/test/repo/unit/release-kit/publish-infra/npm/trust-sweep.test.mts new file mode 100644 index 00000000..b884103e --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/npm/trust-sweep.test.mts @@ -0,0 +1,74 @@ +/** + * @file ConformsToLaw pins every field of the trusted-publisher law. One case + * per field diverges exactly that field and asserts the config no longer + * conforms, so dropping any single comparison (type, file, repository, + * environment, or the permission set) fails a test — the sweep's idempotent + * no-op can never green-light a config bound to the wrong workflow, + * environment, or repo. + */ + +import { describe, expect, it } from 'vitest' + +import { + conformsToLaw, + trustedPublisherLaw, +} from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts' +import type { TrustConfig } from '../../../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trust-sweep.mts' + +const LAW = trustedPublisherLaw('SocketDev/example') + +function conformingConfig(): TrustConfig { + return { + environment: 'npm-publish', + file: 'npm-publish.yml', + id: 'tp-001', + permissions: ['createPackage', 'createStagedPackage'], + repository: 'SocketDev/example', + type: 'github', + } +} + +describe('conformsToLaw', () => { + it('accepts a config that matches the law on every field', () => { + expect(conformsToLaw(conformingConfig(), LAW)).toBe(true) + }) + + it('is order-independent on the permission set', () => { + const swapped = { + ...conformingConfig(), + permissions: ['createStagedPackage', 'createPackage'], + } + expect(conformsToLaw(swapped, LAW)).toBe(true) + }) + + const divergences: Array<{ + field: string + mutate: (c: TrustConfig) => void + }> = [ + { field: 'type', mutate: c => (c.type = 'gitlab') }, + { field: 'file', mutate: c => (c.file = 'release.yml') }, + { field: 'repository', mutate: c => (c.repository = 'SocketDev/other') }, + { field: 'environment', mutate: c => (c.environment = 'production') }, + { + field: 'permissions (missing one)', + mutate: c => (c.permissions = ['createPackage']), + }, + { + field: 'permissions (extra one)', + mutate: c => + (c.permissions = [ + 'createPackage', + 'createStagedPackage', + 'deletePackage', + ]), + }, + ] + + for (const { field, mutate } of divergences) { + it(`rejects a config that diverges on ${field}`, () => { + const config = conformingConfig() + mutate(config) + expect(conformsToLaw(config, LAW)).toBe(false) + }) + } +}) diff --git a/test/repo/unit/release-kit/publish-infra/release.test.mts b/test/repo/unit/release-kit/publish-infra/release.test.mts new file mode 100644 index 00000000..9105f98f --- /dev/null +++ b/test/repo/unit/release-kit/publish-infra/release.test.mts @@ -0,0 +1,41 @@ +/** + * @file The 3-line release checksum writer: its output parses under the brew + * tier's parseChecksumsTxt (the two tiers share one grammar), and the + * sha256 line round-trips a fixture tarball digest. + */ + +import { createHash } from 'node:crypto' + +import { describe, expect, it } from 'vitest' + +import { formatReleaseChecksums } from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/release.mts' +import { parseChecksumsTxt } from '../../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts' + +describe('formatReleaseChecksums ↔ parseChecksumsTxt', () => { + const bytes = Buffer.from('fixture tarball bytes — deterministic digest') + const name = 'example-lib-1.0.0.tgz' + + it('emits exactly three lines per asset', () => { + const text = formatReleaseChecksums(name, bytes) + const lines = text.trimEnd().split('\n') + expect(lines).toHaveLength(3) + expect(lines[0]!.startsWith('sha1: ')).toBe(true) + expect(lines[1]!.startsWith('sha256: ')).toBe(true) + expect(lines[2]!.startsWith('sha512-base64: ')).toBe(true) + }) + + it('the sha256 line round-trips the fixture digest through the brew parser', () => { + const text = formatReleaseChecksums(name, bytes) + const map = parseChecksumsTxt(text) + expect(map.size).toBe(1) + expect(map.get(name)).toBe(createHash('sha256').update(bytes).digest('hex')) + }) + + it('multi-asset manifests concatenate without conflict', () => { + const text = + formatReleaseChecksums('a.tgz', Buffer.from('a')) + + formatReleaseChecksums('b.tgz', Buffer.from('b')) + const map = parseChecksumsTxt(text) + expect([...map.keys()].toSorted()).toEqual(['a.tgz', 'b.tgz']) + }) +}) diff --git a/test/repo/unit/release-kit/pure-branches-2.test.mts b/test/repo/unit/release-kit/pure-branches-2.test.mts new file mode 100644 index 00000000..6060c1dd --- /dev/null +++ b/test/repo/unit/release-kit/pure-branches-2.test.mts @@ -0,0 +1,223 @@ +/** + * @file Remaining reachable branch coverage for the formula parser's partial + * and out-of-order blocks, the installer seams' default args and real-fs + * round-trip, and the workspace-yaml block-boundary paths. + */ + +import { mkdtempSync, readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + parseFormula, + renderFormula, +} from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import type { FormulaSpec } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { FORMULA_PLATFORMS } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { + parseCatalogBlock, + parseListBlock, + parseNamedCatalogs, + spliceCatalogEntry, +} from '../../../../release-kit/payload/scripts/socket-release/lib/workspace-yaml.mts' +import { + PAYLOAD_ROOT, + readTargetShas, + resolveInstallSeams, + sha256Hex, + walkPayload, +} from '../../../../release-kit/install/seams.mts' + +describe('parseFormula partial and out-of-order blocks', () => { + it('parses a formula with only the macOS arm block present', () => { + const raw = [ + 'class R < Formula', + ' on_macos do', + ' on_arm do', + ' url "https://x/releases/download/v1.0.0/r-darwin-arm64.tar.gz"', + ` sha256 "${'a'.repeat(64)}"`, + ' end', + ' end', + ' def install', + ' bin.install "r"', + ' end', + 'end', + ].join('\n') + const parsed = parseFormula(raw) + expect(parsed).toBeDefined() + expect(parsed!.platforms['darwin-arm64']).toBeDefined() + expect(parsed!.platforms['darwin-x64']).toBeUndefined() + expect(parsed!.platforms['linux-arm64']).toBeUndefined() + }) + + it('ignores an arch block that has a url but no valid sha', () => { + const raw = [ + 'class R < Formula', + ' on_linux do', + ' on_intel do', + ' url "https://x/releases/download/v1.0.0/r-linux-x64.tar.gz"', + ' end', + ' end', + ' bin.install "r"', + 'end', + ].join('\n') + const parsed = parseFormula(raw) + expect(parsed).toBeDefined() + expect(parsed!.platforms['linux-x64']).toBeUndefined() + }) + + it('handles the linux block appearing before the macos block', () => { + const arm = ` sha256 "${'b'.repeat(64)}"` + const raw = [ + 'class R < Formula', + ' on_linux do', + ' on_arm do', + ' url "https://x/releases/download/v1.0.0/r-linux-arm64.tar.gz"', + arm, + ' end', + ' end', + ' on_macos do', + ' on_arm do', + ' url "https://x/releases/download/v1.0.0/r-darwin-arm64.tar.gz"', + arm, + ' end', + ' end', + ' bin.install "r"', + 'end', + ].join('\n') + const parsed = parseFormula(raw) + expect(parsed).toBeDefined() + expect(parsed!.platforms['linux-arm64']).toBeDefined() + expect(parsed!.platforms['darwin-arm64']).toBeDefined() + }) +}) + +describe('renderFormula with a non-canonical version URL', () => { + it('renders an empty version when the darwin-arm64 URL carries no version segment', () => { + const platforms = {} as FormulaSpec['platforms'] + for (let i = 0; i < FORMULA_PLATFORMS.length; i += 1) { + platforms[FORMULA_PLATFORMS[i]!] = { + sha256: `${i}`.repeat(64), + url: 'https://example.com/no-version-here.tar.gz', + } + } + const rendered = renderFormula({ + className: 'R', + desc: 'r', + homepage: 'https://example.com', + license: 'MIT', + name: 'r', + platforms, + }) + expect(rendered).toContain('version ""') + }) +}) + +describe('installer seams with defaults and real fs', () => { + it('walkPayload() and resolveInstallSeams() default to the real payload root', () => { + const files = walkPayload() + expect(files.length).toBeGreaterThan(0) + expect(files).not.toContain('kit-manifest.json') + const seams = resolveInstallSeams() + expect(seams.readPayloadFile('bootstrap.mts')).toBeDefined() + expect(seams.readPayloadFile('bootstrap.mts')).toBe( + readFileSync(path.join(PAYLOAD_ROOT, 'bootstrap.mts'), 'utf8'), + ) + }) + + it('copies, hashes, and reads back a file through the real seams', () => { + const target = mkdtempSync(path.join(os.tmpdir(), 'kit-seams-')) + const seams = resolveInstallSeams() + seams.copyFile('bootstrap.mts', target) + const expected = sha256Hex( + readFileSync(path.join(PAYLOAD_ROOT, 'bootstrap.mts')), + ) + expect(seams.hashTargetFile('bootstrap.mts', target)).toBe(expected) + const reads = readTargetShas(seams, ['bootstrap.mts', 'absent.mts'], target) + expect(reads.get('bootstrap.mts')).toBe(expected) + expect(reads.get('absent.mts')).toBeUndefined() + }) + + it('writeTargetFile creates the parent directory and targetFileExists reports it', () => { + const target = mkdtempSync(path.join(os.tmpdir(), 'kit-seams-w-')) + const seams = resolveInstallSeams() + const p = path.join(target, 'nested', 'deep', 'file.txt') + expect(seams.targetFileExists(p)).toBe(false) + seams.writeTargetFile(p, 'hello') + expect(seams.targetFileExists(p)).toBe(true) + expect(readFileSync(p, 'utf8')).toBe('hello') + }) + + it('sha256Hex accepts a Buffer and a string identically', () => { + expect(sha256Hex('abc')).toBe(sha256Hex(Buffer.from('abc'))) + }) +}) + +describe('workspace-yaml block boundaries', () => { + it('stops the catalog block at a following top-level key', () => { + const content = ['catalog:', ' a: 1', 'overrides:', ' b: 2'].join('\n') + const catalog = parseCatalogBlock(content) + expect(catalog).toEqual({ a: '1' }) + }) + + it('inserts at the block end when the new name sorts last', () => { + const content = ['catalog:', ' aaa: 1', ' bbb: 2'].join('\n') + const next = spliceCatalogEntry(content, 'zzz', '9') + expect(parseCatalogBlock(next)['zzz']).toBe('9') + const lines = next.split('\n') + expect(lines[lines.length - 1]).toContain('zzz') + }) + + it('skips non-matching lines inside the catalog block', () => { + const content = [ + 'catalog:', + ' aaa: 1', + ' # a bare comment with no colon', + ' bbb: 2', + ].join('\n') + expect(parseCatalogBlock(content)).toEqual({ aaa: '1', bbb: '2' }) + }) + + it('skips indented non-bullet lines inside a list block', () => { + const content = [ + 'packages:', + " - 'a'", + ' indented but not a bullet', + " - 'b'", + ].join('\n') + expect(parseListBlock(content, { blockKey: 'packages' })).toEqual([ + 'a', + 'b', + ]) + }) + + it('ignores orphan and comment lines inside the catalogs block', () => { + const content = [ + 'catalogs:', + ' orphan: 1.0.0', + ' react17:', + ' # a comment under the name', + ' react: 17.0.2', + ].join('\n') + expect(parseNamedCatalogs(content)).toEqual({ + react17: { react: '17.0.2' }, + }) + }) + + it('sorts around a comment line when splicing', () => { + const content = [ + 'catalog:', + ' aaa: 1', + ' # comment no colon', + ' ccc: 3', + ].join('\n') + const next = spliceCatalogEntry(content, 'bbb', '2') + expect(parseCatalogBlock(next)).toMatchObject({ + aaa: '1', + bbb: '2', + ccc: '3', + }) + }) +}) diff --git a/test/repo/unit/release-kit/pure-branches.test.mts b/test/repo/unit/release-kit/pure-branches.test.mts new file mode 100644 index 00000000..091a0aab --- /dev/null +++ b/test/repo/unit/release-kit/pure-branches.test.mts @@ -0,0 +1,196 @@ +/** + * @file Residual branch coverage for the pure modules: the throw/edge arms in + * the brew class-name deriver, the access-plan idempotence + refusal, the + * formula parse-path no-op, the trusted-publisher repo/workflow edges, the + * installer seam fallbacks, and the manifest kitVersion default. + */ + +import { describe, expect, it } from 'vitest' + +import { formulaClassName } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/shared.mts' +import { + accessMatchesDesired, + diffPublishingAccess, +} from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts' +import type { PublishingAccessDesired } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-plan.mts' +import type { PublishingAccessRead } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/access-parse.mts' +import { + parseFormula, + planFormulaBump, + renderFormula, +} from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import type { FormulaSpec } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { FORMULA_PLATFORMS } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/brew/formula.mts' +import { parseTrustedPublisherForm } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/npm/trusted-publisher-parse.mts' +import { parseKitManifest } from '../../../../release-kit/install/manifest.mts' +import { resolveInstallSeams } from '../../../../release-kit/install/seams.mts' + +function spec(version = '1.2.3'): FormulaSpec { + const platforms = {} as FormulaSpec['platforms'] + for (let i = 0; i < FORMULA_PLATFORMS.length; i += 1) { + const p = FORMULA_PLATFORMS[i]! + platforms[p] = { + sha256: `${i}`.repeat(64), + url: `https://github.com/o/r/releases/download/v${version}/r-${p}.tar.gz`, + } + } + return { + className: 'R', + desc: 'r', + homepage: 'https://github.com/o/r', + license: 'MIT', + name: 'r', + platforms, + } +} + +describe('formulaClassName edge arms', () => { + it('throws when the name yields no tokens', () => { + expect(() => formulaClassName('---')).toThrow(/no tokens/) + expect(() => formulaClassName('')).toThrow(/no tokens/) + }) + + it('throws when the first token starts with a digit', () => { + expect(() => formulaClassName('1cli')).toThrow(/cannot start with a digit/) + }) + + it('capitalizes and joins multi-token names', () => { + expect(formulaClassName('example-cli.tool')).toBe('ExampleCliTool') + }) +}) + +describe('accessMatchesDesired', () => { + const desired: PublishingAccessDesired = { + directEnabled: false, + stagedEnabled: true, + } + it('an unknown read never matches', () => { + const read: PublishingAccessRead = { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + } + expect(accessMatchesDesired(read, desired)).toBe(false) + }) + + it('a readable matching pair matches', () => { + const read: PublishingAccessRead = { + directEnabled: false, + stagedEnabled: true, + state: 'staged-only', + } + expect(accessMatchesDesired(read, desired)).toBe(true) + }) + + it('a readable differing pair does not match', () => { + const read: PublishingAccessRead = { + directEnabled: true, + stagedEnabled: true, + state: 'both-enabled', + } + expect(accessMatchesDesired(read, desired)).toBe(false) + }) +}) + +describe('diffPublishingAccess', () => { + it('refuses to plan against an unknown read', () => { + expect(() => + diffPublishingAccess( + { + directEnabled: undefined, + stagedEnabled: undefined, + state: 'unknown', + }, + { directEnabled: false, stagedEnabled: true }, + ), + ).toThrow(/unknown/) + }) + + it('emits only the toggles that differ', () => { + const edits = diffPublishingAccess( + { directEnabled: true, stagedEnabled: false, state: 'direct-only' }, + { directEnabled: false, stagedEnabled: true }, + ) + expect(edits).toEqual([ + { checkbox: 'allowDirectPublish', to: false }, + { checkbox: 'allowStagedPublish', to: true }, + ]) + }) + + it('emits no edits when the read already matches', () => { + const edits = diffPublishingAccess( + { directEnabled: false, stagedEnabled: true, state: 'staged-only' }, + { directEnabled: false, stagedEnabled: true }, + ) + expect(edits).toEqual([]) + }) +}) + +describe('planFormulaBump parse path', () => { + it('a byte-different but semantically identical formula is unchanged', () => { + const rendered = renderFormula(spec()) + const withTrailingComment = `${rendered}# an extra trailing comment\n` + expect(withTrailingComment).not.toBe(rendered) + expect(parseFormula(withTrailingComment)).toBeDefined() + expect(planFormulaBump(withTrailingComment, spec()).action).toBe( + 'unchanged', + ) + }) + + it('a parseable formula at a different version is an update', () => { + const current = renderFormula(spec('1.0.0')) + expect(planFormulaBump(current, spec('2.0.0')).action).toBe('update') + }) +}) + +describe('parseTrustedPublisherForm repo/workflow edges', () => { + it('a trailing-slash repo has an undefined name', () => { + const parsed = parseTrustedPublisherForm( + 'owner/', + ) + expect(parsed!.repositoryOwner).toBe('owner') + expect(parsed!.repositoryName).toBeUndefined() + }) + + it('a leading-slash repo has an undefined owner', () => { + const parsed = parseTrustedPublisherForm( + '/name', + ) + expect(parsed!.repositoryOwner).toBeUndefined() + expect(parsed!.repositoryName).toBe('name') + }) + + it('an empty workflow marker reads as undefined', () => { + const parsed = parseTrustedPublisherForm( + 'o/r ', + ) + expect(parsed!.workflowFilename).toBeUndefined() + }) + + it('a Permissions block with only unrelated chips grants nothing', () => { + const parsed = parseTrustedPublisherForm( + 'o/rPermissions:
read only
', + ) + expect(parsed!.allowedActions).toEqual([]) + }) +}) + +describe('installer seam fallbacks', () => { + it('readPayloadFile and hashTargetFile return undefined for missing files', () => { + const seams = resolveInstallSeams('/nonexistent-payload-root') + expect(seams.readPayloadFile('nope.mts')).toBeUndefined() + expect( + seams.hashTargetFile('nope.mts', '/nonexistent-target'), + ).toBeUndefined() + }) +}) + +describe('parseKitManifest kitVersion default', () => { + it('defaults kitVersion when the field is absent or non-string', () => { + const raw = JSON.stringify({ + files: [{ channels: ['common'], path: 'a.mts', sha256: 'a'.repeat(64) }], + schemaVersion: 1, + }) + expect(parseKitManifest(raw, 'test').kitVersion).toBeTruthy() + }) +}) diff --git a/test/repo/unit/release-kit/registry-liveness-gate.test.mts b/test/repo/unit/release-kit/registry-liveness-gate.test.mts new file mode 100644 index 00000000..1c06d949 --- /dev/null +++ b/test/repo/unit/release-kit/registry-liveness-gate.test.mts @@ -0,0 +1,205 @@ +/** + * @file The registry-liveness gate github-release.yml runs on the runner's + * system Node before any install. It cuts the tag + immutable release only + * once the version resolves on its registry, so a false green here would + * publish a release for a package that was never actually published. Every + * pure decision function is pinned, and runGate is driven end-to-end with an + * injected fetch across the live / 404 / unreachable cases with the network + * closed. + */ + +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { + cacheBustedNpmUrl, + checkNpmLive, + crateIndexPath, + deriveCrateNames, + indexHasVersion, + planGate, + runGate, + versionFromTag, +} from '../../../../release-kit/payload/scripts/socket-release/registry-liveness-gate.mjs' + +const GATE_SOURCE = fileURLToPath( + new URL( + '../../../../release-kit/payload/scripts/socket-release/registry-liveness-gate.mjs', + import.meta.url, + ), +) + +function tempRepo(files: Record): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-gate-')) + for (const [name, body] of Object.entries(files)) { + writeFileSync(path.join(dir, name), body) + } + return dir +} + +const okFetch = async () => ({ ok: true, text: async () => '' }) as Response +const notFoundFetch = async () => + ({ ok: false, status: 404, text: async () => '' }) as Response +const unreachableFetch = async () => { + throw new Error('connect ETIMEDOUT') +} + +describe('versionFromTag', () => { + it('strips a single leading v, leaves a bare version alone', () => { + expect(versionFromTag('v1.2.3')).toBe('1.2.3') + expect(versionFromTag('1.2.3')).toBe('1.2.3') + }) +}) + +describe('crateIndexPath', () => { + it('shards by name length like the crates.io sparse index', () => { + expect(crateIndexPath('a')).toBe('1/a') + expect(crateIndexPath('ab')).toBe('2/ab') + expect(crateIndexPath('abc')).toBe('3/a/abc') + expect(crateIndexPath('serde')).toBe('se/rd/serde') + }) +}) + +describe('indexHasVersion', () => { + it('matches the exact vers token', () => { + expect(indexHasVersion('{"vers":"1.2.3"}', '1.2.3')).toBe(true) + expect(indexHasVersion('{"vers":"1.2.30"}', '1.2.3')).toBe(false) + }) +}) + +describe('cacheBustedNpmUrl', () => { + it('appends the nonce with the right separator', () => { + expect(cacheBustedNpmUrl('https://r/x', 'n1')).toBe('https://r/x?_cb=n1') + expect(cacheBustedNpmUrl('https://r/x?a=1', 'n2')).toBe( + 'https://r/x?a=1&_cb=n2', + ) + }) +}) + +describe('planGate', () => { + it('plans npm for a public package.json', () => { + const dir = tempRepo({ 'package.json': '{"name":"pkg","version":"1.0.0"}' }) + expect(planGate(dir)).toEqual({ name: 'pkg', registry: 'npm' }) + }) + + it('falls through a private package.json to Cargo.toml', () => { + const dir = tempRepo({ + 'package.json': '{"name":"pkg","private":true}', + 'Cargo.toml': '[package]\nname = "crate-x"\nversion = "1.0.0"\n', + }) + expect(planGate(dir)).toEqual({ names: ['crate-x'], registry: 'crates' }) + }) + + it('skips a repo with neither manifest', () => { + expect(planGate(tempRepo({}))).toEqual({ registry: 'none' }) + }) +}) + +describe('loads on the runner system Node <22 (globSync deferred, not statically imported)', () => { + it('does not statically import globSync from node:fs (a Node 22+ named export)', () => { + const src = readFileSync(GATE_SOURCE, 'utf8') + const fsImport = /import\s*\{([^}]*)\}\s*from\s*'node:fs'/.exec(src) + expect(fsImport).not.toBeNull() + expect(fsImport![1]).not.toContain('globSync') + }) + + it('still expands a workspace-member glob (globSync resolved lazily at call time)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'kit-gate-glob-')) + writeFileSync( + path.join(dir, 'Cargo.toml'), + '[workspace]\nmembers = ["crates/*"]\n', + ) + mkdirSync(path.join(dir, 'crates', 'alpha'), { recursive: true }) + writeFileSync( + path.join(dir, 'crates', 'alpha', 'Cargo.toml'), + '[package]\nname = "alpha"\nversion = "1.0.0"\n', + ) + expect(deriveCrateNames(dir)).toEqual(['alpha']) + }) +}) + +describe('checkNpmLive', () => { + it('is true on a resolvable read', async () => { + expect(await checkNpmLive('pkg', '1.0.0', okFetch, () => {})).toBe(true) + }) + + it('is false on a 404', async () => { + expect(await checkNpmLive('pkg', '1.0.0', notFoundFetch, () => {})).toBe( + false, + ) + }) + + it('is false (never throws) when the registry is unreachable', async () => { + expect(await checkNpmLive('pkg', '1.0.0', unreachableFetch, () => {})).toBe( + false, + ) + }) +}) + +describe('runGate', () => { + const npmRepo = () => + tempRepo({ 'package.json': '{"name":"pkg","version":"1.0.0"}' }) + + it('exits 1 when TAG is unset', async () => { + expect( + await runGate({ + log: () => {}, + logError: () => {}, + rootDir: npmRepo(), + tag: undefined, + }), + ).toBe(1) + }) + + it('exits 0 when the version is live on npm', async () => { + expect( + await runGate({ + fetchImpl: okFetch, + log: () => {}, + logError: () => {}, + rootDir: npmRepo(), + tag: 'v1.0.0', + }), + ).toBe(0) + }) + + it('exits 1 when the version 404s (staged-but-not-approved)', async () => { + expect( + await runGate({ + fetchImpl: notFoundFetch, + log: () => {}, + logError: () => {}, + rootDir: npmRepo(), + tag: 'v1.0.0', + }), + ).toBe(1) + }) + + it('exits 1 when the registry is unreachable', async () => { + expect( + await runGate({ + fetchImpl: unreachableFetch, + log: () => {}, + logError: () => {}, + rootDir: npmRepo(), + tag: 'v1.0.0', + }), + ).toBe(1) + }) + + it('exits 0 (skips) for a github-release-only repo', async () => { + expect( + await runGate({ + fetchImpl: unreachableFetch, + log: () => {}, + logError: () => {}, + rootDir: tempRepo({}), + tag: 'v1.0.0', + }), + ).toBe(0) + }) +}) diff --git a/test/repo/unit/release-kit/spawn-missing-binary.test.mts b/test/repo/unit/release-kit/spawn-missing-binary.test.mts new file mode 100644 index 00000000..c548d022 --- /dev/null +++ b/test/repo/unit/release-kit/spawn-missing-binary.test.mts @@ -0,0 +1,30 @@ +/** + * @file The spawn seams must degrade to a non-zero exit code when a binary is + * absent (ENOENT) rather than rejecting the promise — every `.code`-checking + * caller (preflight's gh/git red-checks, the publish legs' pnpm pack) then + * surfaces its designed refusal instead of an unhandled-rejection stack. + */ + +import { describe, expect, it } from 'vitest' + +import { resolveSeams } from '../../../../release-kit/payload/scripts/socket-release/bootstrap/seams.mts' +import { runCapture } from '../../../../release-kit/payload/scripts/socket-release/publish-infra/shared.mts' + +const MISSING = 'socket-release-nonexistent-binary-xyzzy' + +describe('spawn seams on a missing binary (ENOENT)', () => { + it('bootstrap seams.exec resolves a non-zero code instead of rejecting', async () => { + const result = await resolveSeams().exec( + MISSING, + ['--version'], + process.cwd(), + ) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + }) + + it('publish-infra runCapture resolves a non-zero code instead of rejecting', async () => { + const result = await runCapture(MISSING, ['--version'], process.cwd()) + expect(result.code).not.toBe(0) + }) +}) diff --git a/test/repo/unit/structural/doc-command-lint.test.mts b/test/repo/unit/structural/doc-command-lint.test.mts index 5ac561ed..7317f561 100644 --- a/test/repo/unit/structural/doc-command-lint.test.mts +++ b/test/repo/unit/structural/doc-command-lint.test.mts @@ -181,6 +181,37 @@ export function readRunnableCommands( return commands } +describe('documented --backfill commands carry the mandatory non-latest --tag', () => { + it('every documented npm-publish --backfill invocation names a non-latest --tag', () => { + const docs = getLintedDocs() + const offenders: string[] = [] + for (let i = 0, { length } = docs; i < length; i += 1) { + const doc = docs[i]! + const relDoc = path.relative(REPO_ROOT, doc) + const content = readFileSync(doc, 'utf-8') + const commands = readRunnableCommands(content) + for (let j = 0, count = commands.length; j < count; j += 1) { + const command = commands[j]! + if ( + !/npm-publish\.mts\b/.test(command.text) || + !/--backfill\b/.test(command.text) + ) { + continue + } + const tag = /--tag\s+(\S+)/.exec(command.text) + if (!tag || tag[1] === 'latest') { + offenders.push(`${relDoc}:${command.line} \`${command.text}\``) + } + } + } + expect( + offenders, + `A backfill never moves the latest pointer, so the gate refuses any backfill without an explicit non-latest --tag. ${offenders.length} documented command(s) omit it:\n` + + offenders.map(offender => ` - ${offender}`).join('\n'), + ).toEqual([]) + }) +}) + describe('Doc Command Lint', () => { const docs = getLintedDocs() diff --git a/test/repo/unit/structural/release-kit-tests-mirror-payload.test.mts b/test/repo/unit/structural/release-kit-tests-mirror-payload.test.mts new file mode 100644 index 00000000..0eae260a --- /dev/null +++ b/test/repo/unit/structural/release-kit-tests-mirror-payload.test.mts @@ -0,0 +1,80 @@ +// socket-lint: mirror-exempt — enforces naming law rule 8 across the whole release-kit test tree, so the tree is the subject, not a module. +import { describe, expect, it } from 'vitest' +import { existsSync, readdirSync } from 'node:fs' +import * as path from 'node:path' +import { REPO_ROOT } from '../../../../scripts/fleet/paths.mts' + +const TEST_ROOT = path.join(REPO_ROOT, 'test', 'repo', 'unit', 'release-kit') +const PAYLOAD_ROOT = path.join( + REPO_ROOT, + 'release-kit', + 'payload', + 'scripts', + 'socket-release', +) + +const NON_MIRROR_DIRS = new Set(['fixtures', 'fuzz', 'install', 'lib']) + +function walk(dir: string): string[] { + const out: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + out.push(...walk(full)) + } else { + out.push(full) + } + } + return out +} + +function payloadModulesByBasename(): Map { + const byBase = new Map() + for (const file of walk(PAYLOAD_ROOT)) { + if (!file.endsWith('.mts') && !file.endsWith('.mjs')) { + continue + } + const rel = path.relative(PAYLOAD_ROOT, file).replace(/\.(?:mts|mjs)$/, '') + const base = path.basename(rel) + const bucket = byBase.get(base) ?? [] + bucket.push(rel) + byBase.set(base, bucket) + } + return byBase +} + +describe('naming law rule 8: release-kit tests mirror the payload path', () => { + it('no module test drops a payload directory segment from its path', () => { + const byBase = payloadModulesByBasename() + const misfiled: string[] = [] + for (const file of walk(TEST_ROOT)) { + if (!file.endsWith('.test.mts')) { + continue + } + const relFromTestRoot = path.relative(TEST_ROOT, file) + const topSegment = relFromTestRoot.split(path.sep)[0]! + if (NON_MIRROR_DIRS.has(topSegment)) { + continue + } + const subjectRel = relFromTestRoot.replace(/\.test\.mts$/, '') + if ( + existsSync(path.join(PAYLOAD_ROOT, `${subjectRel}.mts`)) || + existsSync(path.join(PAYLOAD_ROOT, `${subjectRel}.mjs`)) + ) { + continue + } + const deeper = byBase.get(path.basename(subjectRel)) + if (deeper && deeper.length > 0) { + misfiled.push( + `${relFromTestRoot} — the module lives at ${deeper.join(' / ')}; ` + + `file the test at test/repo/unit/release-kit/${deeper[0]}.test.mts`, + ) + } + } + expect( + misfiled, + `${misfiled.length} release-kit test(s) drop a payload directory segment (rule 8):\n` + + misfiled.map(entry => ` - ${entry}`).join('\n'), + ).toEqual([]) + }) +})