From 03bb0d384421e0c77af4cb854bb9bfa9dd037ffb Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 03:36:51 +0000 Subject: [PATCH 01/10] feat(npm): launcher manifest, lockfile, runtime version read (U1) --- .codegraph/.gitignore | 5 + .oxlintrc.json | 10 + LICENSE | 201 +++ npm/packages/comment-checker/package.json | 44 + npm/packages/comment-checker/src/index.ts | 86 + npm/packages/comment-checker/tsconfig.json | 36 + npm/packages/comment-checker/tsdown.config.ts | 9 + package.json | 18 + pnpm-lock.yaml | 1459 +++++++++++++++++ pnpm-workspace.yaml | 4 + tests/npm-launcher/manifest.test.mjs | 55 + 11 files changed, 1927 insertions(+) create mode 100644 .codegraph/.gitignore create mode 100644 .oxlintrc.json create mode 100644 LICENSE create mode 100644 npm/packages/comment-checker/package.json create mode 100755 npm/packages/comment-checker/src/index.ts create mode 100644 npm/packages/comment-checker/tsconfig.json create mode 100644 npm/packages/comment-checker/tsdown.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tests/npm-launcher/manifest.test.mjs diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..3ebcf6e --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "warn" + }, + "ignorePatterns": ["repos/**", "node_modules/**", "dist/**"] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/npm/packages/comment-checker/package.json b/npm/packages/comment-checker/package.json new file mode 100644 index 0000000..20e4d15 --- /dev/null +++ b/npm/packages/comment-checker/package.json @@ -0,0 +1,44 @@ +{ + "name": "@systemfsoftware/claude-code-comment-checker", + "version": "0.1.0", + "description": "Claude Code PostToolUse hook that blocks unnecessary comments (npm distribution launcher)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/systemfsoftware/comment-checker.git" + }, + "type": "module", + "bin": { + "comment-checker": "./dist/index.mjs" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@effect/platform-node": "4.0.0-rc.108", + "effect": "4.0.0-rc.108" + }, + "devDependencies": { + "@types/node": "latest", + "tsdown": "latest", + "typescript": "latest" + }, + "optionalDependencies": { + "@systemfsoftware/claude-code-comment-checker-linux-x64": "0.1.0", + "@systemfsoftware/claude-code-comment-checker-linux-arm64": "0.1.0", + "@systemfsoftware/claude-code-comment-checker-darwin-x64": "0.1.0", + "@systemfsoftware/claude-code-comment-checker-darwin-arm64": "0.1.0", + "@systemfsoftware/claude-code-comment-checker-win32-x64": "0.1.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} \ No newline at end of file diff --git a/npm/packages/comment-checker/src/index.ts b/npm/packages/comment-checker/src/index.ts new file mode 100755 index 0000000..d77e5b0 --- /dev/null +++ b/npm/packages/comment-checker/src/index.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env node +import { createRequire } from "node:module" +import { Data, Effect, FileSystem, Option, Path } from "effect" +import { NodeRuntime, NodeServices } from "@effect/platform-node" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { Command, Flag } from "effect/unstable/cli" + +const require = createRequire(import.meta.url) + +class BinaryNotFound extends Data.TaggedError("BinaryNotFound")<{ + readonly platform: string + readonly arch: string + readonly package: string + readonly message: string +}> {} + +const binaryFileName = (platform: NodeJS.Platform): string => + platform === "win32" ? "comment-checker.exe" : "comment-checker" + +const optionalDepName = (platform: string, arch: string): string => + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` + +const getBinaryPath = Effect.gen(function* () { + const path = yield* Path.Path + const fs = yield* FileSystem.FileSystem + + const platform = process.platform + const arch = process.arch + const pkg = optionalDepName(platform, arch) + + const pkgJsonPath = yield* Effect.try({ + try: () => require.resolve(`${pkg}/package.json`), + catch: () => + new BinaryNotFound({ + platform, + arch, + package: pkg, + message: `the npm platform package for ${platform}/${arch} (${pkg}) is not installed`, + }), + }) + + const binaryPath = path.join(path.dirname(pkgJsonPath), binaryFileName(platform)) + + if (!(yield* fs.exists(binaryPath))) { + return yield* new BinaryNotFound({ + platform, + arch, + package: pkg, + message: `the npm platform package for ${platform}/${arch} (${pkg}) is not installed`, + }) + } + + return binaryPath +}) + +const command = Command.make( + "comment-checker", + { + prompt: Flag.optional(Flag.string("prompt")), + }, + (config) => + Effect.gen(function* () { + const binaryPath = yield* getBinaryPath + const args = Option.match(config.prompt, { + onNone: () => [], + onSome: (prompt) => ["--prompt", prompt], + }) + + const child = ChildProcess.make(binaryPath, args, { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const exitCode = yield* spawner.exitCode(child) + + process.exitCode = exitCode + }) +) + +const { version } = require("../package.json") as { version: string } + +NodeRuntime.runMain( + Command.run(command, { version }).pipe(Effect.provide(NodeServices.layer)) +) diff --git a/npm/packages/comment-checker/tsconfig.json b/npm/packages/comment-checker/tsconfig.json new file mode 100644 index 0000000..9cb3f63 --- /dev/null +++ b/npm/packages/comment-checker/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "plugins": [ + { + "name": "@effect/language-service", + "diagnostics": true, + "diagnosticSeverity": { + "floatingEffect": "error", + "missingEffectContext": "error", + "missingEffectError": "error", + "missingLayerContext": "error", + "missingReturnYieldStar": "error", + "missingStarInYieldEffectGen": "error", + "returnEffectInGen": "error", + "anyUnknownInErrorContext": "error" + } + } + ] + }, + "include": ["src"] +} diff --git a/npm/packages/comment-checker/tsdown.config.ts b/npm/packages/comment-checker/tsdown.config.ts new file mode 100644 index 0000000..96f1c3a --- /dev/null +++ b/npm/packages/comment-checker/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + clean: true, + sourcemap: true, + outDir: "dist", +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b97486 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "claude-code-comment-checker", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.21.0", + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "lint": "oxlint -c .oxlintrc.json --no-error-on-unmatched-pattern" + }, + "devDependencies": { + "@effect/tsgo": "latest", + "oxlint": "latest", + "tsdown": "latest", + "typescript": "latest" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b4b18d0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1459 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@effect/tsgo': + specifier: latest + version: 0.36.5 + oxlint: + specifier: latest + version: 1.78.0 + tsdown: + specifier: latest + version: 0.22.14(typescript@7.0.2) + typescript: + specifier: latest + version: 7.0.2 + + npm/packages/comment-checker: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1) + effect: + specifier: 4.0.0-rc.108 + version: 4.0.0-rc.108 + devDependencies: + '@types/node': + specifier: latest + version: 26.2.0 + tsdown: + specifier: latest + version: 0.22.14(typescript@7.0.2) + typescript: + specifier: latest + version: 7.0.2 + +packages: + + '@effect/platform-node-shared@4.0.0-rc.109': + resolution: {integrity: sha512-jhZJnf81N7Z5+Z41Qr0cKT75WzGu6M1W05xrSdu/V8PK0kSFtU5hJMBxSGDWNlgWhBwK0WRZtnMHY8HLdONg1w==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.109 + + '@effect/platform-node@4.0.0-rc.108': + resolution: {integrity: sha512-Nof78154BaHGdSYr4TPQFZ5+Dg+HkpmbI3SQUdwsby5QNs6yahGJPu2AgdIVqdx7pKZ2w7j/bvdnqoMmkG0PbA==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.108 + ioredis: '>=5.7.0 <6.0.0' + + '@effect/tsgo-darwin-arm64@0.36.5': + resolution: {integrity: sha512-+JPS65Ekod5NS41Kg9OIyuUsygNcSw5/4Y+UNbY6Wob6dvP+CkEa51pGBAmHKQp3Z/D3g/A9GNE66ndu9kz8dw==} + cpu: [arm64] + os: [darwin] + + '@effect/tsgo-darwin-x64@0.36.5': + resolution: {integrity: sha512-S67mS1GTSvfeN5Tsij7QarJNuv3q7U/PMhjTbGtKO9mqgDTOA2IOlxq7YPIxkWq+AiOIv0H3aHGk7ToRjcPWxg==} + cpu: [x64] + os: [darwin] + + '@effect/tsgo-linux-arm64@0.36.5': + resolution: {integrity: sha512-bNLzLrQ/4Sf0N7NlAqcEpr+g+RW/ERIvmSFQN9Nu+hSFti4Kx9Nrfo1LJ4V0qASry5Rj2DG4Wa9C5ydQAD9qqA==} + cpu: [arm64] + os: [linux] + + '@effect/tsgo-linux-arm@0.36.5': + resolution: {integrity: sha512-UqtTPUgoVMRHAOcHiK7sddxjUEj6kOkXAlu4Y2TL/MhGiu81ZBzZrg8TXf0ApNeEMOD0EIK8hwU2kik8O+buxA==} + cpu: [arm] + os: [linux] + + '@effect/tsgo-linux-x64@0.36.5': + resolution: {integrity: sha512-QWdyuUcAb1kZBeItycHg1ZKloNbVDtlVm1C0bbDVHqZIZ/d2rqhKi6Zajm64GnHtfdwPTn8Oi1iOCH1IYgo0IQ==} + cpu: [x64] + os: [linux] + + '@effect/tsgo-win32-arm64@0.36.5': + resolution: {integrity: sha512-5tO5em1DfplFz5GqpQVGRSrAoE+kEgIc7Y0ClbT9jkxaK18IFVq9KIYybt4d6VT4+QnNQGdqWOahMcGE4JAZ8w==} + cpu: [arm64] + os: [win32] + + '@effect/tsgo-win32-x64@0.36.5': + resolution: {integrity: sha512-pmaKwdYAIs9GFpMV9glIUks0UZDHE1/xwP6+GCGhSFxB76cDLMnrHr/IBt5VfGddT65SK+Dyw/vAtcX7fRp4KA==} + cpu: [x64] + os: [win32] + + '@effect/tsgo@0.36.5': + resolution: {integrity: sha512-BHxVjeRK1/XlqYHWXkbT4W9JpOrT3sNA2wrfwQPBTojD5OSyxI2TUIltobYhWudyfuCrn770qP6uOpDjdrmghA==} + hasBin: true + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@oxlint/binding-android-arm-eabi@1.78.0': + resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.78.0': + resolution: {integrity: sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.78.0': + resolution: {integrity: sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.78.0': + resolution: {integrity: sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.78.0': + resolution: {integrity: sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + resolution: {integrity: sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + resolution: {integrity: sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.78.0': + resolution: {integrity: sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.78.0': + resolution: {integrity: sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + resolution: {integrity: sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + resolution: {integrity: sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.78.0': + resolution: {integrity: sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.78.0': + resolution: {integrity: sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.78.0': + resolution: {integrity: sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.78.0': + resolution: {integrity: sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.78.0': + resolution: {integrity: sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.78.0': + resolution: {integrity: sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.78.0': + resolution: {integrity: sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.78.0': + resolution: {integrity: sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.0-rc.108: + resolution: {integrity: sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + oxlint@1.78.0: + resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + +snapshots: + + '@effect/platform-node-shared@4.0.0-rc.109(effect@4.0.0-rc.108)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-rc.108 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.109(effect@4.0.0-rc.108) + effect: 4.0.0-rc.108 + ioredis: 5.11.1 + mime: 4.1.0 + undici: 8.10.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/tsgo-darwin-arm64@0.36.5': + optional: true + + '@effect/tsgo-darwin-x64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm@0.36.5': + optional: true + + '@effect/tsgo-linux-x64@0.36.5': + optional: true + + '@effect/tsgo-win32-arm64@0.36.5': + optional: true + + '@effect/tsgo-win32-x64@0.36.5': + optional: true + + '@effect/tsgo@0.36.5': + optionalDependencies: + '@effect/tsgo-darwin-arm64': 0.36.5 + '@effect/tsgo-darwin-x64': 0.36.5 + '@effect/tsgo-linux-arm': 0.36.5 + '@effect/tsgo-linux-arm64': 0.36.5 + '@effect/tsgo-linux-x64': 0.36.5 + '@effect/tsgo-win32-arm64': 0.36.5 + '@effect/tsgo-win32-x64': 0.36.5 + + '@ioredis/commands@1.10.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@oxc-project/types@0.144.0': {} + + '@oxlint/binding-android-arm-eabi@1.78.0': + optional: true + + '@oxlint/binding-android-arm64@1.78.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.78.0': + optional: true + + '@oxlint/binding-darwin-x64@1.78.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.78.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.78.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.78.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.78.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.78.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.78.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.78.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.78.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.78.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.78.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.78.0': + optional: true + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + + ansis@4.3.1: {} + + cac@7.0.0: {} + + cluster-key-slot@1.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + defu@6.1.7: {} + + denque@2.1.0: {} + + detect-libc@2.1.2: + optional: true + + dts-resolver@3.0.0: {} + + effect@4.0.0-rc.108: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + uuid: 14.0.1 + + empathic@2.0.1: {} + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.1: {} + + import-without-cache@0.4.0: {} + + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + kubernetes-types@1.30.0: {} + + mime@4.1.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.1.4: {} + + oxlint@1.78.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.78.0 + '@oxlint/binding-android-arm64': 1.78.0 + '@oxlint/binding-darwin-arm64': 1.78.0 + '@oxlint/binding-darwin-x64': 1.78.0 + '@oxlint/binding-freebsd-x64': 1.78.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.78.0 + '@oxlint/binding-linux-arm-musleabihf': 1.78.0 + '@oxlint/binding-linux-arm64-gnu': 1.78.0 + '@oxlint/binding-linux-arm64-musl': 1.78.0 + '@oxlint/binding-linux-ppc64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-gnu': 1.78.0 + '@oxlint/binding-linux-riscv64-musl': 1.78.0 + '@oxlint/binding-linux-s390x-gnu': 1.78.0 + '@oxlint/binding-linux-x64-gnu': 1.78.0 + '@oxlint/binding-linux-x64-musl': 1.78.0 + '@oxlint/binding-openharmony-arm64': 1.78.0 + '@oxlint/binding-win32-arm64-msvc': 1.78.0 + '@oxlint/binding-win32-ia32-msvc': 1.78.0 + '@oxlint/binding-win32-x64-msvc': 1.78.0 + + picomatch@4.0.5: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.4)(typescript@7.0.2): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.4 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + standard-as-callback@2.1.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tree-kill@1.2.2: {} + + tsdown@0.22.14(typescript@7.0.2): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.4 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.4)(typescript@7.0.2) + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@8.3.0: {} + + undici@8.10.0: {} + + uuid@14.0.1: {} + + verkit@0.3.2: {} + + ws@8.21.3: {} + + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ef687f3 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'npm/packages/*' +allowBuilds: + msgpackr-extract: false diff --git a/tests/npm-launcher/manifest.test.mjs b/tests/npm-launcher/manifest.test.mjs new file mode 100644 index 0000000..403c8ff --- /dev/null +++ b/tests/npm-launcher/manifest.test.mjs @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs" +import { test } from "node:test" +import assert from "node:assert/strict" + +const manifest = JSON.parse( + readFileSync(new URL("../../npm/packages/comment-checker/package.json", import.meta.url), "utf8") +) + +// The five platform packages the launcher resolves by identity +// (process.platform/process.arch -> -). Keep in sync with +// scripts/release/targets.json (checked by tests/release). +const PLATFORMS = [ + ["linux", "x64"], + ["linux", "arm64"], + ["darwin", "x64"], + ["darwin", "arm64"], + ["win32", "x64"], +] + +const expectedOptionalDeps = Object.fromEntries( + PLATFORMS.map(([platform, arch]) => [ + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}`, + "0.1.0", + ]) +) + +test("launcher manifest declares no install-time script", () => { + assert.equal(manifest.postinstall, undefined) + assert.equal(manifest.prepare, undefined) + assert.equal(manifest.install, undefined) +}) + +test("launcher manifest ships only the built launcher", () => { + assert.deepEqual(manifest.files, ["dist"]) + assert.deepEqual(manifest.bin, { "comment-checker": "./dist/index.mjs" }) + assert.equal(manifest.private, undefined) +}) + +test("optionalDependencies pins exactly the five platform packages", () => { + assert.deepEqual(manifest.optionalDependencies, expectedOptionalDeps) +}) + +test("publishConfig requests public access and provenance", () => { + assert.equal(manifest.publishConfig.access, "public") + assert.equal(manifest.publishConfig.provenance, true) +}) + +test("engines floor is Node >= 18", () => { + assert.equal(manifest.engines.node, ">=18") +}) + +test("Effect deps pinned to the vendored rc revision", () => { + assert.equal(manifest.dependencies.effect, "4.0.0-rc.108") + assert.equal(manifest.dependencies["@effect/platform-node"], "4.0.0-rc.108") +}) \ No newline at end of file From 0157d8dee0a0c23bddd3ca0e1f8c47fbe8690351 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:05:28 +0000 Subject: [PATCH 02/10] feat(release): Deno release scripts, targets table, matrix checks (U2) - targets.json: five platform targets (KD1/KTD2) - generate-platform-manifest.ts: emit per-platform package.json (--dry-run) - sync-root-version.ts: inject version + five optionalDeps pins at publish - check-matrix.ts: identity rules, product set, cross-file workflow agreement - launcher manifest carries no optionalDeps pre-publish (pnpm cannot lock unresolvable optional deps; pins injected at release time) --- npm/packages/comment-checker/package.json | 7 - scripts/deno.jsonc | 19 + scripts/deno.lock | 32 ++ scripts/release/check-matrix.ts | 182 +++++++++ scripts/release/generate-platform-manifest.ts | 89 +++++ scripts/release/sync-root-version.ts | 106 ++++++ scripts/release/targets.json | 39 ++ tests/npm-launcher/manifest.test.mjs | 26 +- tests/release/release-scripts.test.ts | 349 ++++++++++++++++++ 9 files changed, 822 insertions(+), 27 deletions(-) create mode 100644 scripts/deno.jsonc create mode 100644 scripts/deno.lock create mode 100755 scripts/release/check-matrix.ts create mode 100755 scripts/release/generate-platform-manifest.ts create mode 100755 scripts/release/sync-root-version.ts create mode 100644 scripts/release/targets.json create mode 100644 tests/release/release-scripts.test.ts diff --git a/npm/packages/comment-checker/package.json b/npm/packages/comment-checker/package.json index 20e4d15..d2224af 100644 --- a/npm/packages/comment-checker/package.json +++ b/npm/packages/comment-checker/package.json @@ -30,13 +30,6 @@ "tsdown": "latest", "typescript": "latest" }, - "optionalDependencies": { - "@systemfsoftware/claude-code-comment-checker-linux-x64": "0.1.0", - "@systemfsoftware/claude-code-comment-checker-linux-arm64": "0.1.0", - "@systemfsoftware/claude-code-comment-checker-darwin-x64": "0.1.0", - "@systemfsoftware/claude-code-comment-checker-darwin-arm64": "0.1.0", - "@systemfsoftware/claude-code-comment-checker-win32-x64": "0.1.0" - }, "publishConfig": { "access": "public", "provenance": true diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc new file mode 100644 index 0000000..31ef1c1 --- /dev/null +++ b/scripts/deno.jsonc @@ -0,0 +1,19 @@ +{ + "imports": { + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/path": "jsr:@std/path@1.1.6" + }, + "tasks": { + "manifest:generate": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json release/generate-platform-manifest.ts", + "manifest:sync-root": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json --allow-write=../npm/packages/comment-checker/package.json release/sync-root-version.ts", + "check-matrix": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json,../.github/workflows/release.yml release/check-matrix.ts", + "test": "deno test --allow-read --allow-write --allow-env --allow-run=deno ../tests/release", + "lint": "deno lint --config ./deno.jsonc . ../tests/release" + }, + "fmt": { + "lineWidth": 100, + "indentWidth": 2, + "singleQuote": true, + "semiColons": false + } +} diff --git a/scripts/deno.lock b/scripts/deno.lock new file mode 100644 index 0000000..42ce904 --- /dev/null +++ b/scripts/deno.lock @@ -0,0 +1,32 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@1.1.6": "1.1.6" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/path@1.1.6" + ] + } +} diff --git a/scripts/release/check-matrix.ts b/scripts/release/check-matrix.ts new file mode 100755 index 0000000..0651b22 --- /dev/null +++ b/scripts/release/check-matrix.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env -S deno run --allow-read +import { join, resolve } from '@std/path' + +const ROOT = join(import.meta.dirname!, '..', '..') +const DEFAULT_TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json') +const MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') +const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') + +// The supported platform set (KD1): the launcher resolves its platform package +// by identity as --. This is the product policy +// the table must name — a known set, not a copy of the table under check. +const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'] + +const failures: string[] = [] +const fail = (reason: string) => failures.push(reason) +const note = (message: string) => console.error(`check-matrix: note: ${message}`) + +// CLI: --targets overrides the packaged table (defaults to +// scripts/release/targets.json); --manifest-path overrides the launcher +// manifest (defaults to npm/packages/comment-checker/package.json). +let targetsPath = DEFAULT_TARGETS_PATH +let manifestPath = MANIFEST_PATH +const args = Deno.args +for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--targets') { + const value = args[++i] + if (value === undefined) { + console.error('check-matrix: missing value for --targets') + Deno.exit(1) + } + targetsPath = resolve(value) + } else if (arg === '--manifest-path') { + const value = args[++i] + if (value === undefined) { + console.error('check-matrix: missing value for --manifest-path') + Deno.exit(1) + } + manifestPath = resolve(value) + } else { + console.error(`check-matrix: unknown argument: ${arg}`) + Deno.exit(1) + } +} + +let targets: Array< + { target: string; suffix: string; os: string; cpu: string; libc?: string; bin: string } +> +try { + targets = JSON.parse(await Deno.readTextFile(targetsPath)) +} catch (error) { + console.error( + `check-matrix: FAIL: cannot read targets file ${targetsPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + Deno.exit(1) +} + +// 1. Identity convention (KTD2): each entry must be self-consistent with the +// launcher's platform/arch naming — suffix "os-cpu", Windows bin .exe, libc on +// Linux only — and the table must name exactly the product platform set. +if (!Array.isArray(targets)) { + fail('targets table is not an array') +} else { + const suffixes = targets.map((t) => t.suffix) + const missing = EXPECTED_SUFFIXES.filter((s) => !suffixes.includes(s)) + const extra = suffixes.filter((s) => !(EXPECTED_SUFFIXES as string[]).includes(s)) + if (missing.length > 0 || extra.length > 0) { + fail( + `targets table must name exactly the supported platform set; missing: ${ + missing.join(', ') || 'none' + }, extra: ${extra.join(', ') || 'none'}`, + ) + } + for (const entry of targets) { + if (entry.suffix !== `${entry.os}-${entry.cpu}`) { + fail( + `target ${entry.target}: suffix "${entry.suffix}" must equal os-cpu "${entry.os}-${entry.cpu}"`, + ) + } + if ((entry.os === 'win32') !== (entry.bin === 'comment-checker.exe')) { + fail( + `target ${entry.target}: bin must be comment-checker.exe iff os is win32 (os: ${entry.os}, bin: ${entry.bin})`, + ) + } + if (entry.os === 'linux' && entry.libc !== 'glibc') { + fail( + `target ${entry.target}: linux targets must carry libc "glibc", got ${ + JSON.stringify(entry.libc) + }`, + ) + } + if (entry.os !== 'linux' && entry.libc !== undefined) { + fail( + `target ${entry.target}: non-linux targets must not carry libc, got ${ + JSON.stringify(entry.libc) + }`, + ) + } + } +} + +// 2. Launcher manifest agreement: when optionalDependencies are present (the +// publish-time sync-root-version run), they must be exactly the platform +// packages named by this table, pinned to the root version. The committed +// manifest carries none before the first release — pnpm cannot lock +// unresolvable optional deps — so absence is expected, not a failure. +let manifest: Record & { + name: string + version: string + optionalDependencies?: Record +} +try { + manifest = JSON.parse(await Deno.readTextFile(manifestPath)) +} catch (error) { + console.error( + `check-matrix: FAIL: cannot read launcher manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + Deno.exit(1) +} + +const expectedNames = targets.map((t) => `${manifest.name}-${t.suffix}`) +const declaredNames = Object.keys(manifest.optionalDependencies ?? {}) +if (declaredNames.length === 0) { + note( + 'launcher manifest carries no optionalDependencies (pre-publish); sync-root-version.ts injects the five platform pins from targets.json', + ) +} else { + const missingNames = expectedNames.filter((name) => !declaredNames.includes(name)) + const extraNames = declaredNames.filter((name) => !expectedNames.includes(name)) + if (missingNames.length > 0 || extraNames.length > 0) { + fail( + `launcher manifest optionalDependencies must be exactly the platform packages from the table; missing: ${ + missingNames.join(', ') || 'none' + }, extra: ${extraNames.join(', ') || 'none'}`, + ) + } + for (const name of expectedNames) { + const pin = manifest.optionalDependencies?.[name] + if (pin !== manifest.version) { + fail( + `optionalDependency ${name} must be pinned to the root version ${manifest.version}, got ${ + JSON.stringify(pin) + }`, + ) + } + } +} + +// 3. Cross-file agreement (the anti-drift surface): the workflow matrix must +// name exactly the triples in the table, and vice versa. Absent workflow is +// a skip (with a note), not a failure. +try { + await Deno.lstat(RELEASE_WORKFLOW_PATH) + const content = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) + const workflowTargets = new Set(content.match(/(?:x86_64|aarch64)-[a-z0-9-]+/g) ?? []) + const tableTargets = new Set(targets.map((t) => t.target)) + for (const target of tableTargets) { + if (!workflowTargets.has(target)) { + fail(`release.yml does not list release target ${target}`) + } + } + for (const target of workflowTargets) { + if (!tableTargets.has(target)) { + fail(`release.yml lists ${target}, which is not a row in targets.json`) + } + } +} catch { + note(`skipped: ${RELEASE_WORKFLOW_PATH} not found (workflow agreement not checked)`) +} + +if (failures.length > 0) { + for (const reason of failures) { + console.error(`check-matrix: FAIL: ${reason}`) + } + Deno.exit(1) +} + +console.error('check-matrix: ok') diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/release/generate-platform-manifest.ts new file mode 100755 index 0000000..777f9b2 --- /dev/null +++ b/scripts/release/generate-platform-manifest.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { join } from '@std/path' + +const ROOT = join(import.meta.dirname!, '..', '..') + +interface Target { + target: string + suffix: string + os: string + cpu: string + libc?: string + bin: string +} + +const TARGETS: Target[] = JSON.parse( + await Deno.readTextFile(join(ROOT, 'scripts', 'release', 'targets.json')), +) +const LAUNCHER: { name: string; repository: { type: string; url: string } } = JSON.parse( + await Deno.readTextFile(join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json')), +) + +interface Options { + suffix?: string + version?: string + out?: string + dryRun: boolean +} + +// CLI: --suffix --version --out [--dry-run] +const args = Deno.args +const opts: Options = { dryRun: false } +for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--dry-run') { + opts.dryRun = true + } else if (arg === '--suffix' || arg === '--version' || arg === '--out') { + const value = args[++i] + if (value === undefined) { + console.error(`generate-platform-manifest: missing value for ${arg}`) + Deno.exit(1) + } + opts[arg.slice(2) as 'suffix' | 'version' | 'out'] = value + } else { + console.error(`generate-platform-manifest: unknown argument: ${arg}`) + Deno.exit(1) + } +} + +for (const flag of ['suffix', 'version', 'out'] as const) { + if (opts[flag] === undefined) { + console.error(`generate-platform-manifest: missing required --${flag}`) + Deno.exit(1) + } +} + +const entry = TARGETS.find((t) => t.suffix === opts.suffix) +if (!entry) { + console.error( + `generate-platform-manifest: unknown suffix "${opts.suffix}"; supported suffixes: ${ + TARGETS.map((t) => t.suffix).join(', ') + }`, + ) + Deno.exit(1) +} + +const pkg: Record = { + name: `${LAUNCHER.name}-${entry.suffix}`, + version: opts.version, + description: `${LAUNCHER.name} ${entry.suffix} platform package`, + license: 'Apache-2.0', + repository: LAUNCHER.repository, + os: [entry.os], + cpu: [entry.cpu], + files: [entry.bin], + bin: { 'comment-checker': `./${entry.bin}` }, + publishConfig: { access: 'public', provenance: true }, +} +if (entry.libc !== undefined) { + pkg.libc = [entry.libc] +} + +const output = JSON.stringify(pkg, null, 2) + '\n' + +if (opts.dryRun) { + await Deno.stdout.write(new TextEncoder().encode(output)) +} else { + await Deno.mkdir(opts.out!, { recursive: true }) + await Deno.writeTextFile(join(opts.out!, 'package.json'), output) +} diff --git a/scripts/release/sync-root-version.ts b/scripts/release/sync-root-version.ts new file mode 100755 index 0000000..47e9ab0 --- /dev/null +++ b/scripts/release/sync-root-version.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +import { join, resolve } from '@std/path' + +const ROOT = join(import.meta.dirname!, '..', '..') +const DEFAULT_MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') +const TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json') +const VERSION_RE = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/ + +// VERSION is validated BEFORE any write; a value that never reaches end-of-input +// (e.g. trailing line breaks) is invalid even though $ can match before one. +const version = Deno.env.get('VERSION') ?? '' +if (!VERSION_RE.test(version) || version.includes('\n')) { + console.error(`sync-root-version: invalid VERSION: ${JSON.stringify(version)}`) + Deno.exit(1) +} + +// CLI: [--manifest-path ] [--dry-run]. The committed launcher manifest +// carries NO optionalDependencies (pnpm cannot record unresolvable optional +// deps in a lockfile, so listing unpublished platform packages would break +// `pnpm install --frozen-lockfile`); this script injects the five pins from +// targets.json at publish time, when the platform packages already exist. +let manifestPath = DEFAULT_MANIFEST_PATH +const dryRun = Deno.args.includes('--dry-run') +const args = Deno.args +for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--manifest-path') { + const value = args[++i] + if (value === undefined) { + console.error('sync-root-version: missing value for --manifest-path') + Deno.exit(1) + } + manifestPath = resolve(value) + } else if (arg === '--dry-run') { + // handled above + } else { + console.error(`sync-root-version: unknown argument: ${arg}`) + Deno.exit(1) + } +} + +const targets = JSON.parse(await Deno.readTextFile(TARGETS_PATH)) +if (!Array.isArray(targets) || targets.length !== 5) { + console.error('sync-root-version: targets.json must declare exactly five platform targets') + Deno.exit(1) +} + +const original = await Deno.readTextFile(manifestPath) +const manifest = JSON.parse(original) +manifest.version = version +// The published root's optionalDependencies are pinned to the tag version for +// exactly the five platform packages declared in targets.json. +manifest.optionalDependencies = Object.fromEntries( + targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]), +) + +// Preserve the launcher manifest's own formatting so an unchanged sync is a +// byte-identical round trip: 2-space indent plus the file's existing +// trailing-newline convention. +const next = JSON.stringify(manifest, null, 2) + (original.endsWith('\n') ? '\n' : '') + +function diffLines(before: string, after: string): string[] { + const a = before.split('\n') + const b = after.split('\n') + // LCS-based line diff over two small JSON texts. + const n = a.length + const m = b.length + const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)) + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + const out = [] + let i = 0 + let j = 0 + while (i < n && j < m) { + if (a[i] === b[j]) { + i++ + j++ + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push(`- ${a[i]}`) + i++ + } else { + out.push(`+ ${b[j]}`) + j++ + } + } + while (i < n) { + out.push(`- ${a[i]}`) + i++ + } + while (j < m) { + out.push(`+ ${b[j]}`) + j++ + } + return out +} + +if (dryRun) { + for (const line of diffLines(original, next)) { + console.log(line) + } +} else { + await Deno.writeTextFile(manifestPath, next) +} diff --git a/scripts/release/targets.json b/scripts/release/targets.json new file mode 100644 index 0000000..68b3dc5 --- /dev/null +++ b/scripts/release/targets.json @@ -0,0 +1,39 @@ +[ + { + "target": "x86_64-unknown-linux-gnu", + "suffix": "linux-x64", + "os": "linux", + "cpu": "x64", + "libc": "glibc", + "bin": "comment-checker" + }, + { + "target": "aarch64-unknown-linux-gnu", + "suffix": "linux-arm64", + "os": "linux", + "cpu": "arm64", + "libc": "glibc", + "bin": "comment-checker" + }, + { + "target": "x86_64-apple-darwin", + "suffix": "darwin-x64", + "os": "darwin", + "cpu": "x64", + "bin": "comment-checker" + }, + { + "target": "aarch64-apple-darwin", + "suffix": "darwin-arm64", + "os": "darwin", + "cpu": "arm64", + "bin": "comment-checker" + }, + { + "target": "x86_64-pc-windows-msvc", + "suffix": "win32-x64", + "os": "win32", + "cpu": "x64", + "bin": "comment-checker.exe" + } +] diff --git a/tests/npm-launcher/manifest.test.mjs b/tests/npm-launcher/manifest.test.mjs index 403c8ff..129baa1 100644 --- a/tests/npm-launcher/manifest.test.mjs +++ b/tests/npm-launcher/manifest.test.mjs @@ -6,24 +6,6 @@ const manifest = JSON.parse( readFileSync(new URL("../../npm/packages/comment-checker/package.json", import.meta.url), "utf8") ) -// The five platform packages the launcher resolves by identity -// (process.platform/process.arch -> -). Keep in sync with -// scripts/release/targets.json (checked by tests/release). -const PLATFORMS = [ - ["linux", "x64"], - ["linux", "arm64"], - ["darwin", "x64"], - ["darwin", "arm64"], - ["win32", "x64"], -] - -const expectedOptionalDeps = Object.fromEntries( - PLATFORMS.map(([platform, arch]) => [ - `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}`, - "0.1.0", - ]) -) - test("launcher manifest declares no install-time script", () => { assert.equal(manifest.postinstall, undefined) assert.equal(manifest.prepare, undefined) @@ -36,8 +18,12 @@ test("launcher manifest ships only the built launcher", () => { assert.equal(manifest.private, undefined) }) -test("optionalDependencies pins exactly the five platform packages", () => { - assert.deepEqual(manifest.optionalDependencies, expectedOptionalDeps) +test("committed manifest carries no optionalDependencies (pre-publish)", () => { + // pnpm cannot record unresolvable optional deps in a lockfile, so the + // committed manifest must not name the unpublished platform packages; + // sync-root-version.ts injects the platforms.json pins at publish time. + assert.equal(manifest.optionalDependencies, undefined) + assert.equal(manifest.version, "0.1.0") }) test("publishConfig requests public access and provenance", () => { diff --git a/tests/release/release-scripts.test.ts b/tests/release/release-scripts.test.ts new file mode 100644 index 0000000..828c937 --- /dev/null +++ b/tests/release/release-scripts.test.ts @@ -0,0 +1,349 @@ +import { join } from '@std/path' +import { + assertEquals, + assertMatch, + assertNotEquals, + assertObjectMatch, + assertRejects, +} from '@std/assert' + +const ROOT = join(import.meta.dirname!, '..', '..') +const RELEASE = join(ROOT, 'scripts', 'release') +const MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') +const TARGETS_PATH = join(RELEASE, 'targets.json') +const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') + +// The supported platform set (KD1) — the launcher resolves its platform package +// by identity as --, so these five pairs are the +// product contract the table must name exactly. +const EXPECTED_SUFFIXES = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', +] as const +const LAUNCHER_PREFIX = '@systemfsoftware/claude-code-comment-checker' + +const decode = (u8: Uint8Array) => new TextDecoder().decode(u8) + +async function withTmp(fn: (dir: string) => Promise) { + const dir = await Deno.makeTempDir({ prefix: 'release-scripts-' }) + try { + await fn(dir) + } finally { + await Deno.remove(dir, { recursive: true }) + } +} + +// Scripts run with the same permission surface the deno.jsonc tasks declare: +// read the table + launcher manifest + workflow, write only the caller's +// explicit --out / copies. The suite never touches the committed manifest. +function runScript( + script: string, + args: string[], + opts: { env?: Record; cwd?: string; writeAllow?: string } = {}, +): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> { + const { env = {}, cwd = ROOT, writeAllow = 'unused' } = opts + const callerPaths: string[] = [] + for (let i = 0; i < args.length; i++) { + if (args[i] === '--suffix' || args[i] === '--version') { + i++ + } else if (args[i] === '--out' || args[i] === '--manifest-path' || args[i] === '--targets') { + callerPaths.push(args[++i]) + } + } + const read = [TARGETS_PATH, MANIFEST_PATH, RELEASE_WORKFLOW_PATH, ...callerPaths].join(',') + const cmd = new Deno.Command('deno', { + args: [ + 'run', + `--allow-read=${read}`, + `--allow-write=${writeAllow}`, + `--allow-env`, + join(RELEASE, script), + ...args, + ], + cwd, + env: { ...Deno.env.toObject(), ...env }, + stdout: 'piped', + stderr: 'piped', + }) + return cmd.output().then((r) => ({ + code: r.code, + stdout: r.stdout, + stderr: r.stderr, + })) +} + +const readTable = async () => + JSON.parse(await Deno.readTextFile(TARGETS_PATH)) as Array<{ + target: string + suffix: string + os: string + cpu: string + libc?: string + bin: string + }> + +Deno.test('targets.json names exactly the five supported platform/arch pairs', async () => { + const table = await readTable() + const suffixes = table.map((t) => t.suffix).sort() + assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()) +}) + +Deno.test('generate-platform-manifest emits the correct package.json for each targets.json entry', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + const cases = table.map(async (entry) => { + const out = join(tmp, `gen-${entry.suffix}`) + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + entry.suffix, + '--version', + '0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + + const pkg = JSON.parse(await Deno.readTextFile(join(out, 'package.json'))) + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`) + assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`) + assertEquals(pkg.version, '0.1.0') + assertEquals(pkg.license, 'Apache-2.0') + assertObjectMatch(pkg.repository, launcher.repository) + assertEquals(pkg.os, [entry.os]) + assertEquals(pkg.cpu, [entry.cpu]) + assertEquals(pkg.files, [entry.bin]) + assertEquals(pkg.bin, { 'comment-checker': `./${entry.bin}` }) + assertEquals(pkg.publishConfig, { access: 'public', provenance: true }) + if (entry.libc !== undefined) { + assertEquals(pkg.libc, [entry.libc]) + } else { + assertEquals(pkg.libc, undefined) + } + }) + await Promise.all(cases) + }) +}) + +Deno.test('generate-platform-manifest --dry-run leaves the filesystem untouched', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'dry-run-out') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-x64', + '--version', + '0.1.0', + '--out', + out, + '--dry-run', + ]) + assertEquals(res.code, 0, decode(res.stderr)) + await assertRejects(() => Deno.stat(out)) + const stdout = decode(res.stdout) + assertMatch(stdout, /"name": "@systemfsoftware\/claude-code-comment-checker-linux-x64"/) + assertMatch(stdout, /"version": "0.1.0"/) + }) +}) + +Deno.test('generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'gen-unknown') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-mips', + '--version', + '0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertNotEquals(res.code, 0) + const stderr = decode(res.stderr) + for (const suffix of EXPECTED_SUFFIXES) { + assertMatch(stderr, new RegExp(suffix)) + } + await assertRejects(() => Deno.stat(join(out, 'package.json'))) + }) +}) + +Deno.test('sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-copy.json') + await Deno.copyFile(MANIFEST_PATH, copy) + + const res = await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + assertEquals(res.code, 0, decode(res.stderr)) + + const manifest = JSON.parse(await Deno.readTextFile(copy)) + assertEquals(manifest.version, '0.1.0') + assertEquals( + Object.keys(manifest.optionalDependencies).sort(), + [...EXPECTED_SUFFIXES].sort().map((s) => `${LAUNCHER_PREFIX}-${s}`), + ) + for (const value of Object.values(manifest.optionalDependencies)) { + assertEquals(value, '0.1.0') + } + // The committed manifest is not touched by the release-time sync. + const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + assertEquals(committed.optionalDependencies, undefined) + }) +}) + +Deno.test('sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-copy2.json') + await Deno.copyFile(MANIFEST_PATH, copy) + // Seed the copy at 0.1.0 so the dry-run shift is a clean version bump. + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + const before = await Deno.readTextFile(copy) + + const res = await runScript('sync-root-version.ts', ['--manifest-path', copy, '--dry-run'], { + env: { VERSION: '0.2.0' }, + writeAllow: copy, + }) + assertEquals(res.code, 0, decode(res.stderr)) + assertEquals(await Deno.readTextFile(copy), before, 'dry-run never writes') + + const stdout = decode(res.stdout) + const minus = stdout.split('\n').filter((line) => line.startsWith('- ')) + const plus = stdout.split('\n').filter((line) => line.startsWith('+ ')) + const manifest = JSON.parse(before) + const changedLineCount = Object.keys(manifest.optionalDependencies).length + 1 // version + five deps + assertEquals(minus.length, changedLineCount) + assertEquals(plus.length, changedLineCount) + assertMatch(minus[0], /- {3}"version": "0.1.0",/) + assertMatch(plus[0], /\+ {3}"version": "0.2.0",/) + for (let i = 0; i < minus.length; i++) { + const minusLine = minus[i].replace(/^- /, '') + const plusLine = plus[i].replace(/^\+ /, '') + assertEquals( + plusLine, + minusLine.replace('"0.1.0"', '"0.2.0"'), + `each diff pair changes only the version value (line ${i}: ${minus[i]})`, + ) + } + }) +}) + +Deno.test('sync-root-version: malformed VERSION exits non-zero and writes nothing', async () => { + const malformed = ['v0.1.0', 'v0.1.0"', '0.1.0\n--provenance=false', 'not-a-version'] + const cases = malformed.map((version) => + withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-malformed.json') + await Deno.copyFile(MANIFEST_PATH, copy) + const before = await Deno.readTextFile(copy) + const res = await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: version }, + writeAllow: copy, + }) + assertNotEquals(res.code, 0, `VERSION=${JSON.stringify(version)} must fail`) + const stderr = decode(res.stderr) + assertMatch( + stderr, + /invalid VERSION/, + `stderr reports the invalid value for ${JSON.stringify(version)}: ${stderr}`, + ) + assertEquals( + await Deno.readTextFile(copy), + before, + `no write for VERSION=${JSON.stringify(version)}`, + ) + }) + ) + await Promise.all(cases) +}) + +Deno.test('check-matrix: passes on the packaged table and pre-publish launcher manifest', async () => { + const res = await runScript('check-matrix.ts', []) + assertEquals(res.code, 0, decode(res.stderr)) + const stderr: string = decode(res.stderr) + assertMatch( + stderr, + /no optionalDependencies \(pre-publish\)/, + 'absence of pins is noted, not failed', + ) +}) + +Deno.test('check-matrix: fails on a manifest with wrong pin', async () => { + await withTmp(async (tmp) => { + const copy = join(tmp, 'manifest-wrongpin.json') + await Deno.copyFile(MANIFEST_PATH, copy) + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, + writeAllow: copy, + }) + // Corrupt one pin to a different exact version after the sync. + const manifest = JSON.parse(await Deno.readTextFile(copy)) + manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = '0.2.0' + await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + '\n') + + const res = await runScript('check-matrix.ts', ['--manifest-path', copy]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/, 'stderr names the failure') + }) +}) + +Deno.test('check-matrix: fails on a reversed os/cpu identity violation', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + const reversed = table.map(({ target, suffix, os, cpu, libc, bin }) => ({ + target, + suffix, + os: cpu, + cpu: os, + bin, + ...(libc !== undefined ? { libc } : {}), + })) + const targetsPath = join(tmp, 'targets.json') + await Deno.writeTextFile(targetsPath, JSON.stringify(reversed, null, 2) + '\n') + const res = await runScript('check-matrix.ts', ['--targets', targetsPath]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + }) +}) + +Deno.test('check-matrix: fails on a missing or extra table target', async () => { + await withTmp(async (tmp) => { + const table = await readTable() + + const missingPath = join(tmp, 'missing.json') + await Deno.writeTextFile( + missingPath, + JSON.stringify(table.filter((entry) => entry.target !== 'x86_64-apple-darwin'), null, 2) + + '\n', + ) + const resMissing = await runScript('check-matrix.ts', ['--targets', missingPath]) + assertNotEquals(resMissing.code, 0) + assertMatch(decode(resMissing.stderr), /FAIL/) + + const extraPath = join(tmp, 'extra.json') + await Deno.writeTextFile( + extraPath, + JSON.stringify( + [ + ...table, + { + target: 'x86_64-unknown-freebsd', + suffix: 'freebsd-x64', + os: 'freebsd', + cpu: 'x64', + bin: 'comment-checker', + }, + ], + null, + 2, + ) + '\n', + ) + const resExtra = await runScript('check-matrix.ts', ['--targets', extraPath]) + assertNotEquals(resExtra.code, 0) + assertMatch(decode(resExtra.stderr), /FAIL/) + }) +}) From 5f2485764e7d2e5da263ffb2427f33485963a3b4 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:15:09 +0000 Subject: [PATCH 03/10] feat(release): tag-triggered publish workflow with OIDC provenance (U3) - matrix job per target: build, gate (check-matrix + smoke), sha256 sidecar, tar.gz upload, platform publish with trusted publishing - publish-npm-main: tag gates (semver, reachability), registry re-verify (version/os/cpu/libc vs table), binary sha cross-check, root publish - upload-gh-release-assets: tarballs attached via pinned action - workflow-level permissions: {}; per-job id-token/contents grants only - every third-party action pinned by full commit SHA --- .github/workflows/release.yml | 313 +++++++++++ scripts/release/check-matrix.ts | 8 +- scripts/release/generate-platform-manifest.ts | 14 +- tests/release/release-scripts.test.ts | 512 ++++++++++-------- 4 files changed, 629 insertions(+), 218 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..67ddc1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,313 @@ +# Release pipeline: tag-triggered publish of the five per-platform binary +# packages plus the root launcher, all with npm OIDC provenance (no static +# tokens). Version comes only from the tag (KTD5); platform packages are +# published before the root so the root's optionalDependencies resolve. +# +# Fork safety: `push: tags` — only users with push access can push tags. +# pull_request_target is intentionally not used. The npm trusted-publisher +# record binds workflow filename + environment, not tag patterns; the +# `on: refs/tags/v*` filter is the tag gate. +# +# Note: release flows from tags only; ci.yml gates the default branch. + +name: Release + +on: + push: + tags: + - 'v*' + +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # One job per target in the matrix: build, gate, smoke, upload artifacts, + # publish the platform package with provenance (OIDC, no static token). + release: + name: release-${{ matrix.suffix }} + runs-on: ${{ matrix.runner }} + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + suffix: linux-x64 + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + suffix: linux-arm64 + runner: ubuntu-latest + - target: x86_64-apple-darwin + suffix: darwin-x64 + runner: macos-13 + - target: aarch64-apple-darwin + suffix: darwin-arm64 + runner: macos-14 + - target: x86_64-pc-windows-msvc + suffix: win32-x64 + runner: windows-2022 + env: + BINARY_NAME: comment-checker${{ matrix.target == 'x86_64-pc-windows-msvc' && '.exe' || '' }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + targets: ${{ matrix.target }} + + - name: Linux arm64 cross-linker + if: matrix.target == 'aarch64-unknown-linux-gnu' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + { + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" + } >> "$GITHUB_ENV" + + - name: Cache cargo + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Install Deno (release scripts) + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 + + - name: Setup Node 24 (publish; npm >= 11.5.1 for trusted publishing) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # NODE_AUTH_TOKEN intentionally unset — the registry-url wiring + # performs the OIDC exchange for trusted publishing. + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.21.0 + + - name: "Gate: check-matrix (table vs workflow agreement)" + run: | + deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts + + - name: "Gate: binary exists" + shell: bash + run: | + test -f "target/${{ matrix.target }}/release/${BINARY_NAME}" + + - name: "Gate: binary smoke tests" + shell: bash + run: | + BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" + echo '{"tool_name":"Write","tool_input":{"file_path":"src/client.py","content":"# SPDX-License-Identifier: Apache-2.0\ndef load(path):\n return open(path).read()\n"}}' | "$BIN" + rc=$? + test "$rc" -eq 0 || { echo "clean payload exited $rc, expected 0" >&2; exit 1; } + echo '{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\n # Parse the config file\n data = json.load(open(path))\n # TODO: fix this later\n return data\n"}}' | "$BIN" >/dev/null 2>&1 + rc=$? + test "$rc" -eq 2 || { echo "flagged payload exit $rc, expected 2" >&2; exit 1; } + + - name: Stage platform package (outside workspace) + record binarySha256 + shell: bash + run: | + STAGE="$RUNNER_TEMP/platform-${{ matrix.suffix }}" + mkdir -p "$STAGE" + BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" + cp "$BIN" "$STAGE/${BINARY_NAME}" + SHA="$(sha256sum "$STAGE/${BINARY_NAME}" | cut -d' ' -f1)" + deno run \ + --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ + --allow-write="$STAGE" \ + scripts/release/generate-platform-manifest.ts \ + --suffix "${{ matrix.suffix }}" \ + --version "${GITHUB_REF#refs/tags/v}" \ + --binary-sha256 "$SHA" \ + --out "$STAGE" + echo "$SHA" > "$RUNNER_TEMP/binary-${{ matrix.suffix }}.sha256" + echo "STAGE=$STAGE" >> "$GITHUB_ENV" + + - name: Upload tar.gz + sha sidecar + shell: bash + run: | + cd "target/${{ matrix.target }}/release" + tar -czf "$RUNNER_TEMP/comment-checker-${{ matrix.target }}.tar.gz" "${BINARY_NAME}" + + - name: Upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-${{ matrix.suffix }} + path: | + ${{ runner.temp }}/comment-checker-${{ matrix.target }}.tar.gz + ${{ runner.temp }}/binary-${{ matrix.suffix }}.sha256 + + - name: Publish platform package (OIDC provenance) + shell: bash + run: | + cd "$STAGE" + pnpm publish --provenance --access public --no-git-checks + + publish-npm-main: + name: Publish root launcher + needs: release + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: "Tag gate: derive VERSION from tag" + shell: bash + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "invalid tag semver: '$VERSION'" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: "Tag gate: commit reachable from default branch" + shell: bash + run: | + DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" + git fetch origin "$DEFAULT_BRANCH" --depth=1 + git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" || { + echo "tag commit not an ancestor of $DEFAULT_BRANCH; refusing publish" >&2 + exit 1 + } + + - name: Setup Node 24 (npm >= 11.5.1 for trusted publishing) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # No NODE_AUTH_TOKEN — OIDC trusted publishing only. + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.21.0 + + - name: Install Deno (release scripts) + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 + + - name: Download recorded binary sha sidecars + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + merge-multiple: true + path: sidecars + + - name: "Gate: every platform package published before root" + shell: bash + run: | + for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" + npm view "$PKG@${VERSION}" version --json | grep -q "\"$VERSION\"" || { + echo "platform package $PKG@$VERSION missing" >&2 + exit 1 + } + done + + - name: "Gate: published os/cpu/libc match the table" + shell: bash + run: | + for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" + META="$(npm view "$PKG@$VERSION" os cpu libc --json)" + EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu], libc: (.libc // null)}' scripts/release/targets.json)" + echo "$META" | jq -e -c '{os, cpu, libc}' >/dev/null || { + echo "$PKG@$VERSION metadata malformed: $META" >&2 + exit 1 + } + ACTUAL="$(echo "$META" | jq -c '{os, cpu, libc}')" + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "$PKG@$VERSION os/cpu/libc mismatch: got $ACTUAL want $EXPECTED" >&2 + exit 1 + fi + done + + - name: Cross-check published tarballs vs recorded binary sha + shell: bash + run: | + for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")" + PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" + TARBALL="$(npm pack "$PKG@${VERSION}" --pack-destination "$RUNNER_TEMP" 2>/dev/null | tail -1)" + rm -rf "$RUNNER_TEMP/unpack" + mkdir -p "$RUNNER_TEMP/unpack" + tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP/unpack" + BIN="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | head -1)" + SHA_PUBLISHED="$(sha256sum "$BIN" | cut -d' ' -f1)" + if [ "$SHA_RECORDED" != "$SHA_PUBLISHED" ]; then + echo "binary sha mismatch for $SUFFIX: recorded $SHA_RECORDED, got $SHA_PUBLISHED" >&2 + exit 1 + fi + echo "$SUFFIX ok" + done + + - name: Build launcher (frozen) + run: | + pnpm install --frozen-lockfile --registry https://registry.npmjs.org + pnpm -r build + + - name: Sync root version + optionalDependencies from tag + shell: bash + run: | + VERSION="$VERSION" deno run \ + --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ + --allow-write=npm/packages/comment-checker/package.json \ + scripts/release/sync-root-version.ts + + - name: Publish root launcher (OIDC provenance) + shell: bash + run: | + cd npm/packages/comment-checker + pnpm publish --provenance --access public --no-git-checks + + - name: Verify root publish + exact pins + shell: bash + run: | + npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" version --json | grep -q "\"$VERSION\"" + for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" optionalDependencies --json | \ + grep -q "\"@systemfsoftware/claude-code-comment-checker-${SUFFIX}\": \"$VERSION\"" || { + echo "optional pin missing for $SUFFIX@$VERSION" >&2 + exit 1 + } + done + + upload-gh-release-assets: + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Download platform artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: release-assets + + - name: Attach tarballs to GitHub release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: release-assets/**/*.tar.gz \ No newline at end of file diff --git a/scripts/release/check-matrix.ts b/scripts/release/check-matrix.ts index 0651b22..a9049a7 100755 --- a/scripts/release/check-matrix.ts +++ b/scripts/release/check-matrix.ts @@ -156,7 +156,13 @@ if (declaredNames.length === 0) { try { await Deno.lstat(RELEASE_WORKFLOW_PATH) const content = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) - const workflowTargets = new Set(content.match(/(?:x86_64|aarch64)-[a-z0-9-]+/g) ?? []) + // Only literal matrix include rows (target: ) — env-var names like + // CC_aarch64_unknown_linux_gnu or gcc-aarch64-linux-gnu are not rows. + const workflowTargets = new Set( + [...content.matchAll(/^\s*-\s*target:\s*((?:x86_64|aarch64)-[a-z0-9-]+)\s*$/gm)].map((m) => + m[1] + ), + ) const tableTargets = new Set(targets.map((t) => t.target)) for (const target of tableTargets) { if (!workflowTargets.has(target)) { diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/release/generate-platform-manifest.ts index 777f9b2..4539e4a 100755 --- a/scripts/release/generate-platform-manifest.ts +++ b/scripts/release/generate-platform-manifest.ts @@ -23,23 +23,28 @@ interface Options { suffix?: string version?: string out?: string + binarySha256?: string dryRun: boolean } -// CLI: --suffix --version --out [--dry-run] +// CLI: --suffix --version --out [--binary-sha256 ] [--dry-run] const args = Deno.args const opts: Options = { dryRun: false } for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg === '--dry-run') { opts.dryRun = true - } else if (arg === '--suffix' || arg === '--version' || arg === '--out') { + } else if ( + arg === '--suffix' || arg === '--version' || arg === '--out' || arg === '--binary-sha256' + ) { const value = args[++i] if (value === undefined) { console.error(`generate-platform-manifest: missing value for ${arg}`) Deno.exit(1) } - opts[arg.slice(2) as 'suffix' | 'version' | 'out'] = value + opts[ + arg === '--binary-sha256' ? 'binarySha256' : arg.slice(2) as 'suffix' | 'version' | 'out' + ] = value } else { console.error(`generate-platform-manifest: unknown argument: ${arg}`) Deno.exit(1) @@ -78,6 +83,9 @@ const pkg: Record = { if (entry.libc !== undefined) { pkg.libc = [entry.libc] } +if (opts.binarySha256 !== undefined) { + pkg.binarySha256 = opts.binarySha256 +} const output = JSON.stringify(pkg, null, 2) + '\n' diff --git a/tests/release/release-scripts.test.ts b/tests/release/release-scripts.test.ts index 828c937..2f247ee 100644 --- a/tests/release/release-scripts.test.ts +++ b/tests/release/release-scripts.test.ts @@ -1,38 +1,44 @@ -import { join } from '@std/path' +import { join } from "@std/path"; import { assertEquals, assertMatch, assertNotEquals, assertObjectMatch, assertRejects, -} from '@std/assert' +} from "@std/assert"; -const ROOT = join(import.meta.dirname!, '..', '..') -const RELEASE = join(ROOT, 'scripts', 'release') -const MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') -const TARGETS_PATH = join(RELEASE, 'targets.json') -const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') +const ROOT = join(import.meta.dirname!, "..", ".."); +const RELEASE = join(ROOT, "scripts", "release"); +const MANIFEST_PATH = join( + ROOT, + "npm", + "packages", + "comment-checker", + "package.json", +); +const TARGETS_PATH = join(RELEASE, "targets.json"); +const RELEASE_WORKFLOW_PATH = join(ROOT, ".github", "workflows", "release.yml"); // The supported platform set (KD1) — the launcher resolves its platform package // by identity as --, so these five pairs are the // product contract the table must name exactly. const EXPECTED_SUFFIXES = [ - 'linux-x64', - 'linux-arm64', - 'darwin-x64', - 'darwin-arm64', - 'win32-x64', -] as const -const LAUNCHER_PREFIX = '@systemfsoftware/claude-code-comment-checker' + "linux-x64", + "linux-arm64", + "darwin-x64", + "darwin-arm64", + "win32-x64", +] as const; +const LAUNCHER_PREFIX = "@systemfsoftware/claude-code-comment-checker"; -const decode = (u8: Uint8Array) => new TextDecoder().decode(u8) +const decode = (u8: Uint8Array) => new TextDecoder().decode(u8); async function withTmp(fn: (dir: string) => Promise) { - const dir = await Deno.makeTempDir({ prefix: 'release-scripts-' }) + const dir = await Deno.makeTempDir({ prefix: "release-scripts-" }); try { - await fn(dir) + await fn(dir); } finally { - await Deno.remove(dir, { recursive: true }) + await Deno.remove(dir, { recursive: true }); } } @@ -42,21 +48,30 @@ async function withTmp(fn: (dir: string) => Promise) { function runScript( script: string, args: string[], - opts: { env?: Record; cwd?: string; writeAllow?: string } = {}, + opts: { env?: Record; cwd?: string; writeAllow?: string } = + {}, ): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> { - const { env = {}, cwd = ROOT, writeAllow = 'unused' } = opts - const callerPaths: string[] = [] + const { env = {}, cwd = ROOT, writeAllow = "unused" } = opts; + const callerPaths: string[] = []; for (let i = 0; i < args.length; i++) { - if (args[i] === '--suffix' || args[i] === '--version') { - i++ - } else if (args[i] === '--out' || args[i] === '--manifest-path' || args[i] === '--targets') { - callerPaths.push(args[++i]) + if (args[i] === "--suffix" || args[i] === "--version") { + i++; + } else if ( + args[i] === "--out" || args[i] === "--manifest-path" || + args[i] === "--targets" + ) { + callerPaths.push(args[++i]); } } - const read = [TARGETS_PATH, MANIFEST_PATH, RELEASE_WORKFLOW_PATH, ...callerPaths].join(',') - const cmd = new Deno.Command('deno', { + const read = [ + TARGETS_PATH, + MANIFEST_PATH, + RELEASE_WORKFLOW_PATH, + ...callerPaths, + ].join(","); + const cmd = new Deno.Command("deno", { args: [ - 'run', + "run", `--allow-read=${read}`, `--allow-write=${writeAllow}`, `--allow-env`, @@ -65,235 +80,291 @@ function runScript( ], cwd, env: { ...Deno.env.toObject(), ...env }, - stdout: 'piped', - stderr: 'piped', - }) + stdout: "piped", + stderr: "piped", + }); return cmd.output().then((r) => ({ code: r.code, stdout: r.stdout, stderr: r.stderr, - })) + })); } const readTable = async () => JSON.parse(await Deno.readTextFile(TARGETS_PATH)) as Array<{ - target: string - suffix: string - os: string - cpu: string - libc?: string - bin: string - }> + target: string; + suffix: string; + os: string; + cpu: string; + libc?: string; + bin: string; + }>; -Deno.test('targets.json names exactly the five supported platform/arch pairs', async () => { - const table = await readTable() - const suffixes = table.map((t) => t.suffix).sort() - assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()) -}) +Deno.test("targets.json names exactly the five supported platform/arch pairs", async () => { + const table = await readTable(); + const suffixes = table.map((t) => t.suffix).sort(); + assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()); +}); -Deno.test('generate-platform-manifest emits the correct package.json for each targets.json entry', async () => { +Deno.test("generate-platform-manifest emits the correct package.json for each targets.json entry", async () => { await withTmp(async (tmp) => { - const table = await readTable() - const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + const table = await readTable(); + const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)); const cases = table.map(async (entry) => { - const out = join(tmp, `gen-${entry.suffix}`) - const res = await runScript('generate-platform-manifest.ts', [ - '--suffix', + const out = join(tmp, `gen-${entry.suffix}`); + const res = await runScript("generate-platform-manifest.ts", [ + "--suffix", entry.suffix, - '--version', - '0.1.0', - '--out', + "--version", + "0.1.0", + "--out", out, - ], { writeAllow: out }) - assertEquals(res.code, 0, decode(res.stderr)) + ], { writeAllow: out }); + assertEquals(res.code, 0, decode(res.stderr)); - const pkg = JSON.parse(await Deno.readTextFile(join(out, 'package.json'))) - assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`) - assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`) - assertEquals(pkg.version, '0.1.0') - assertEquals(pkg.license, 'Apache-2.0') - assertObjectMatch(pkg.repository, launcher.repository) - assertEquals(pkg.os, [entry.os]) - assertEquals(pkg.cpu, [entry.cpu]) - assertEquals(pkg.files, [entry.bin]) - assertEquals(pkg.bin, { 'comment-checker': `./${entry.bin}` }) - assertEquals(pkg.publishConfig, { access: 'public', provenance: true }) + const pkg = JSON.parse( + await Deno.readTextFile(join(out, "package.json")), + ); + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`); + assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`); + assertEquals(pkg.version, "0.1.0"); + assertEquals(pkg.license, "Apache-2.0"); + assertObjectMatch(pkg.repository, launcher.repository); + assertEquals(pkg.os, [entry.os]); + assertEquals(pkg.cpu, [entry.cpu]); + assertEquals(pkg.files, [entry.bin]); + assertEquals(pkg.bin, { "comment-checker": `./${entry.bin}` }); + assertEquals(pkg.publishConfig, { access: "public", provenance: true }); if (entry.libc !== undefined) { - assertEquals(pkg.libc, [entry.libc]) + assertEquals(pkg.libc, [entry.libc]); } else { - assertEquals(pkg.libc, undefined) + assertEquals(pkg.libc, undefined); } - }) - await Promise.all(cases) - }) -}) + assertEquals( + pkg.binarySha256, + undefined, + "no binarySha256 without the flag", + ); + }); + await Promise.all(cases); + }); +}); + +Deno.test("generate-platform-manifest records binarySha256 when passed", async () => { + await withTmp(async (tmp) => { + const out = join(tmp, "gen-sha"); + const res = await runScript("generate-platform-manifest.ts", [ + "--suffix", + "linux-x64", + "--version", + "0.1.0", + "--binary-sha256", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "--out", + out, + ], { writeAllow: out }); + assertEquals(res.code, 0, decode(res.stderr)); + const pkg = JSON.parse(await Deno.readTextFile(join(out, "package.json"))); + assertEquals( + pkg.binarySha256, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + }); +}); -Deno.test('generate-platform-manifest --dry-run leaves the filesystem untouched', async () => { +Deno.test("generate-platform-manifest --dry-run leaves the filesystem untouched", async () => { await withTmp(async (tmp) => { - const out = join(tmp, 'dry-run-out') - const res = await runScript('generate-platform-manifest.ts', [ - '--suffix', - 'linux-x64', - '--version', - '0.1.0', - '--out', + const out = join(tmp, "dry-run-out"); + const res = await runScript("generate-platform-manifest.ts", [ + "--suffix", + "linux-x64", + "--version", + "0.1.0", + "--out", out, - '--dry-run', - ]) - assertEquals(res.code, 0, decode(res.stderr)) - await assertRejects(() => Deno.stat(out)) - const stdout = decode(res.stdout) - assertMatch(stdout, /"name": "@systemfsoftware\/claude-code-comment-checker-linux-x64"/) - assertMatch(stdout, /"version": "0.1.0"/) - }) -}) + "--dry-run", + ]); + assertEquals(res.code, 0, decode(res.stderr)); + await assertRejects(() => Deno.stat(out)); + const stdout = decode(res.stdout); + assertMatch( + stdout, + /"name": "@systemfsoftware\/claude-code-comment-checker-linux-x64"/, + ); + assertMatch(stdout, /"version": "0.1.0"/); + }); +}); -Deno.test('generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes', async () => { +Deno.test("generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes", async () => { await withTmp(async (tmp) => { - const out = join(tmp, 'gen-unknown') - const res = await runScript('generate-platform-manifest.ts', [ - '--suffix', - 'linux-mips', - '--version', - '0.1.0', - '--out', + const out = join(tmp, "gen-unknown"); + const res = await runScript("generate-platform-manifest.ts", [ + "--suffix", + "linux-mips", + "--version", + "0.1.0", + "--out", out, - ], { writeAllow: out }) - assertNotEquals(res.code, 0) - const stderr = decode(res.stderr) + ], { writeAllow: out }); + assertNotEquals(res.code, 0); + const stderr = decode(res.stderr); for (const suffix of EXPECTED_SUFFIXES) { - assertMatch(stderr, new RegExp(suffix)) + assertMatch(stderr, new RegExp(suffix)); } - await assertRejects(() => Deno.stat(join(out, 'package.json'))) - }) -}) + await assertRejects(() => Deno.stat(join(out, "package.json"))); + }); +}); -Deno.test('sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path', async () => { +Deno.test("sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path", async () => { await withTmp(async (tmp) => { - const copy = join(tmp, 'manifest-copy.json') - await Deno.copyFile(MANIFEST_PATH, copy) + const copy = join(tmp, "manifest-copy.json"); + await Deno.copyFile(MANIFEST_PATH, copy); - const res = await runScript('sync-root-version.ts', ['--manifest-path', copy], { - env: { VERSION: '0.1.0' }, + const res = await runScript("sync-root-version.ts", [ + "--manifest-path", + copy, + ], { + env: { VERSION: "0.1.0" }, writeAllow: copy, - }) - assertEquals(res.code, 0, decode(res.stderr)) + }); + assertEquals(res.code, 0, decode(res.stderr)); - const manifest = JSON.parse(await Deno.readTextFile(copy)) - assertEquals(manifest.version, '0.1.0') + const manifest = JSON.parse(await Deno.readTextFile(copy)); + assertEquals(manifest.version, "0.1.0"); assertEquals( Object.keys(manifest.optionalDependencies).sort(), [...EXPECTED_SUFFIXES].sort().map((s) => `${LAUNCHER_PREFIX}-${s}`), - ) + ); for (const value of Object.values(manifest.optionalDependencies)) { - assertEquals(value, '0.1.0') + assertEquals(value, "0.1.0"); } // The committed manifest is not touched by the release-time sync. - const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) - assertEquals(committed.optionalDependencies, undefined) - }) -}) + const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)); + assertEquals(committed.optionalDependencies, undefined); + }); +}); -Deno.test('sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs', async () => { +Deno.test("sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs", async () => { await withTmp(async (tmp) => { - const copy = join(tmp, 'manifest-copy2.json') - await Deno.copyFile(MANIFEST_PATH, copy) + const copy = join(tmp, "manifest-copy2.json"); + await Deno.copyFile(MANIFEST_PATH, copy); // Seed the copy at 0.1.0 so the dry-run shift is a clean version bump. - await runScript('sync-root-version.ts', ['--manifest-path', copy], { - env: { VERSION: '0.1.0' }, + await runScript("sync-root-version.ts", ["--manifest-path", copy], { + env: { VERSION: "0.1.0" }, writeAllow: copy, - }) - const before = await Deno.readTextFile(copy) + }); + const before = await Deno.readTextFile(copy); - const res = await runScript('sync-root-version.ts', ['--manifest-path', copy, '--dry-run'], { - env: { VERSION: '0.2.0' }, + const res = await runScript("sync-root-version.ts", [ + "--manifest-path", + copy, + "--dry-run", + ], { + env: { VERSION: "0.2.0" }, writeAllow: copy, - }) - assertEquals(res.code, 0, decode(res.stderr)) - assertEquals(await Deno.readTextFile(copy), before, 'dry-run never writes') + }); + assertEquals(res.code, 0, decode(res.stderr)); + assertEquals(await Deno.readTextFile(copy), before, "dry-run never writes"); - const stdout = decode(res.stdout) - const minus = stdout.split('\n').filter((line) => line.startsWith('- ')) - const plus = stdout.split('\n').filter((line) => line.startsWith('+ ')) - const manifest = JSON.parse(before) - const changedLineCount = Object.keys(manifest.optionalDependencies).length + 1 // version + five deps - assertEquals(minus.length, changedLineCount) - assertEquals(plus.length, changedLineCount) - assertMatch(minus[0], /- {3}"version": "0.1.0",/) - assertMatch(plus[0], /\+ {3}"version": "0.2.0",/) + const stdout = decode(res.stdout); + const minus = stdout.split("\n").filter((line) => line.startsWith("- ")); + const plus = stdout.split("\n").filter((line) => line.startsWith("+ ")); + const manifest = JSON.parse(before); + const changedLineCount = Object.keys(manifest.optionalDependencies).length + + 1; // version + five deps + assertEquals(minus.length, changedLineCount); + assertEquals(plus.length, changedLineCount); + assertMatch(minus[0], /- {3}"version": "0.1.0",/); + assertMatch(plus[0], /\+ {3}"version": "0.2.0",/); for (let i = 0; i < minus.length; i++) { - const minusLine = minus[i].replace(/^- /, '') - const plusLine = plus[i].replace(/^\+ /, '') + const minusLine = minus[i].replace(/^- /, ""); + const plusLine = plus[i].replace(/^\+ /, ""); assertEquals( plusLine, minusLine.replace('"0.1.0"', '"0.2.0"'), - `each diff pair changes only the version value (line ${i}: ${minus[i]})`, - ) + `each diff pair changes only the version value (line ${i}: ${ + minus[i] + })`, + ); } - }) -}) + }); +}); -Deno.test('sync-root-version: malformed VERSION exits non-zero and writes nothing', async () => { - const malformed = ['v0.1.0', 'v0.1.0"', '0.1.0\n--provenance=false', 'not-a-version'] +Deno.test("sync-root-version: malformed VERSION exits non-zero and writes nothing", async () => { + const malformed = [ + "v0.1.0", + 'v0.1.0"', + "0.1.0\n--provenance=false", + "not-a-version", + ]; const cases = malformed.map((version) => withTmp(async (tmp) => { - const copy = join(tmp, 'manifest-malformed.json') - await Deno.copyFile(MANIFEST_PATH, copy) - const before = await Deno.readTextFile(copy) - const res = await runScript('sync-root-version.ts', ['--manifest-path', copy], { + const copy = join(tmp, "manifest-malformed.json"); + await Deno.copyFile(MANIFEST_PATH, copy); + const before = await Deno.readTextFile(copy); + const res = await runScript("sync-root-version.ts", [ + "--manifest-path", + copy, + ], { env: { VERSION: version }, writeAllow: copy, - }) - assertNotEquals(res.code, 0, `VERSION=${JSON.stringify(version)} must fail`) - const stderr = decode(res.stderr) + }); + assertNotEquals( + res.code, + 0, + `VERSION=${JSON.stringify(version)} must fail`, + ); + const stderr = decode(res.stderr); assertMatch( stderr, /invalid VERSION/, - `stderr reports the invalid value for ${JSON.stringify(version)}: ${stderr}`, - ) + `stderr reports the invalid value for ${ + JSON.stringify(version) + }: ${stderr}`, + ); assertEquals( await Deno.readTextFile(copy), before, `no write for VERSION=${JSON.stringify(version)}`, - ) + ); }) - ) - await Promise.all(cases) -}) + ); + await Promise.all(cases); +}); -Deno.test('check-matrix: passes on the packaged table and pre-publish launcher manifest', async () => { - const res = await runScript('check-matrix.ts', []) - assertEquals(res.code, 0, decode(res.stderr)) - const stderr: string = decode(res.stderr) +Deno.test("check-matrix: passes on the packaged table and pre-publish launcher manifest", async () => { + const res = await runScript("check-matrix.ts", []); + assertEquals(res.code, 0, decode(res.stderr)); + const stderr: string = decode(res.stderr); assertMatch( stderr, /no optionalDependencies \(pre-publish\)/, - 'absence of pins is noted, not failed', - ) -}) + "absence of pins is noted, not failed", + ); +}); -Deno.test('check-matrix: fails on a manifest with wrong pin', async () => { +Deno.test("check-matrix: fails on a manifest with wrong pin", async () => { await withTmp(async (tmp) => { - const copy = join(tmp, 'manifest-wrongpin.json') - await Deno.copyFile(MANIFEST_PATH, copy) - await runScript('sync-root-version.ts', ['--manifest-path', copy], { - env: { VERSION: '0.1.0' }, + const copy = join(tmp, "manifest-wrongpin.json"); + await Deno.copyFile(MANIFEST_PATH, copy); + await runScript("sync-root-version.ts", ["--manifest-path", copy], { + env: { VERSION: "0.1.0" }, writeAllow: copy, - }) + }); // Corrupt one pin to a different exact version after the sync. - const manifest = JSON.parse(await Deno.readTextFile(copy)) - manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = '0.2.0' - await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + '\n') + const manifest = JSON.parse(await Deno.readTextFile(copy)); + manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = "0.2.0"; + await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + "\n"); - const res = await runScript('check-matrix.ts', ['--manifest-path', copy]) - assertNotEquals(res.code, 0) - assertMatch(decode(res.stderr), /FAIL/, 'stderr names the failure') - }) -}) + const res = await runScript("check-matrix.ts", ["--manifest-path", copy]); + assertNotEquals(res.code, 0); + assertMatch(decode(res.stderr), /FAIL/, "stderr names the failure"); + }); +}); -Deno.test('check-matrix: fails on a reversed os/cpu identity violation', async () => { +Deno.test("check-matrix: fails on a reversed os/cpu identity violation", async () => { await withTmp(async (tmp) => { - const table = await readTable() + const table = await readTable(); const reversed = table.map(({ target, suffix, os, cpu, libc, bin }) => ({ target, suffix, @@ -301,49 +372,62 @@ Deno.test('check-matrix: fails on a reversed os/cpu identity violation', async ( cpu: os, bin, ...(libc !== undefined ? { libc } : {}), - })) - const targetsPath = join(tmp, 'targets.json') - await Deno.writeTextFile(targetsPath, JSON.stringify(reversed, null, 2) + '\n') - const res = await runScript('check-matrix.ts', ['--targets', targetsPath]) - assertNotEquals(res.code, 0) - assertMatch(decode(res.stderr), /FAIL/) - }) -}) + })); + const targetsPath = join(tmp, "targets.json"); + await Deno.writeTextFile( + targetsPath, + JSON.stringify(reversed, null, 2) + "\n", + ); + const res = await runScript("check-matrix.ts", ["--targets", targetsPath]); + assertNotEquals(res.code, 0); + assertMatch(decode(res.stderr), /FAIL/); + }); +}); -Deno.test('check-matrix: fails on a missing or extra table target', async () => { +Deno.test("check-matrix: fails on a missing or extra table target", async () => { await withTmp(async (tmp) => { - const table = await readTable() + const table = await readTable(); - const missingPath = join(tmp, 'missing.json') + const missingPath = join(tmp, "missing.json"); await Deno.writeTextFile( missingPath, - JSON.stringify(table.filter((entry) => entry.target !== 'x86_64-apple-darwin'), null, 2) + - '\n', - ) - const resMissing = await runScript('check-matrix.ts', ['--targets', missingPath]) - assertNotEquals(resMissing.code, 0) - assertMatch(decode(resMissing.stderr), /FAIL/) + JSON.stringify( + table.filter((entry) => entry.target !== "x86_64-apple-darwin"), + null, + 2, + ) + + "\n", + ); + const resMissing = await runScript("check-matrix.ts", [ + "--targets", + missingPath, + ]); + assertNotEquals(resMissing.code, 0); + assertMatch(decode(resMissing.stderr), /FAIL/); - const extraPath = join(tmp, 'extra.json') + const extraPath = join(tmp, "extra.json"); await Deno.writeTextFile( extraPath, JSON.stringify( [ ...table, { - target: 'x86_64-unknown-freebsd', - suffix: 'freebsd-x64', - os: 'freebsd', - cpu: 'x64', - bin: 'comment-checker', + target: "x86_64-unknown-freebsd", + suffix: "freebsd-x64", + os: "freebsd", + cpu: "x64", + bin: "comment-checker", }, ], null, 2, - ) + '\n', - ) - const resExtra = await runScript('check-matrix.ts', ['--targets', extraPath]) - assertNotEquals(resExtra.code, 0) - assertMatch(decode(resExtra.stderr), /FAIL/) - }) -}) + ) + "\n", + ); + const resExtra = await runScript("check-matrix.ts", [ + "--targets", + extraPath, + ]); + assertNotEquals(resExtra.code, 0); + assertMatch(decode(resExtra.stderr), /FAIL/); + }); +}); From f5371c4472282263e64d79c34ee0d3607c6b42ba Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:20:52 +0000 Subject: [PATCH 04/10] test(npm): extract platform helpers + black-box launcher suite (U4) - src/platform.ts: pure optionalDepName/binaryFileName (no Effect imports) - index.ts imports from platform; dist emits index.mjs + platform.mjs - launcher.test.mjs: shim spawn + arg passthrough, exit-code propagation, BinaryNotFound naming host package, targets.json round-trip (15 tests) --- npm/packages/comment-checker/src/index.ts | 7 +- npm/packages/comment-checker/src/platform.ts | 5 + npm/packages/comment-checker/tsdown.config.ts | 2 +- tests/npm-launcher/launcher.test.mjs | 108 ++++++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 npm/packages/comment-checker/src/platform.ts create mode 100644 tests/npm-launcher/launcher.test.mjs diff --git a/npm/packages/comment-checker/src/index.ts b/npm/packages/comment-checker/src/index.ts index d77e5b0..e8bd280 100755 --- a/npm/packages/comment-checker/src/index.ts +++ b/npm/packages/comment-checker/src/index.ts @@ -4,6 +4,7 @@ import { Data, Effect, FileSystem, Option, Path } from "effect" import { NodeRuntime, NodeServices } from "@effect/platform-node" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Command, Flag } from "effect/unstable/cli" +import { binaryFileName, optionalDepName } from "./platform.js" const require = createRequire(import.meta.url) @@ -14,12 +15,6 @@ class BinaryNotFound extends Data.TaggedError("BinaryNotFound")<{ readonly message: string }> {} -const binaryFileName = (platform: NodeJS.Platform): string => - platform === "win32" ? "comment-checker.exe" : "comment-checker" - -const optionalDepName = (platform: string, arch: string): string => - `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` - const getBinaryPath = Effect.gen(function* () { const path = yield* Path.Path const fs = yield* FileSystem.FileSystem diff --git a/npm/packages/comment-checker/src/platform.ts b/npm/packages/comment-checker/src/platform.ts new file mode 100644 index 0000000..81c7979 --- /dev/null +++ b/npm/packages/comment-checker/src/platform.ts @@ -0,0 +1,5 @@ +export const binaryFileName = (platform: string): string => + platform === "win32" ? "comment-checker.exe" : "comment-checker" + +export const optionalDepName = (platform: string, arch: string): string => + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` \ No newline at end of file diff --git a/npm/packages/comment-checker/tsdown.config.ts b/npm/packages/comment-checker/tsdown.config.ts index 96f1c3a..45cc00a 100644 --- a/npm/packages/comment-checker/tsdown.config.ts +++ b/npm/packages/comment-checker/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown" export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/platform.ts"], format: ["esm"], clean: true, sourcemap: true, diff --git a/tests/npm-launcher/launcher.test.mjs b/tests/npm-launcher/launcher.test.mjs new file mode 100644 index 0000000..227fd87 --- /dev/null +++ b/tests/npm-launcher/launcher.test.mjs @@ -0,0 +1,108 @@ +// Black-box suite for the npm launcher (dist/index.mjs). Requires a prior +// `pnpm -r build`: the launcher entry and the extracted platform module are +// imported from the built dist/ output below. +import { spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { test } from "node:test" +import assert from "node:assert/strict" + +import { binaryFileName, optionalDepName } from "../../npm/packages/comment-checker/dist/platform.mjs" + +const launcherPath = fileURLToPath( + new URL("../../npm/packages/comment-checker/dist/index.mjs", import.meta.url) +) + +const hostDep = `@systemfsoftware/claude-code-comment-checker-${process.platform}-${process.arch}` +const skipNoPosixShim = + process.platform === "win32" + ? "cannot fabricate a .exe shim in a test fixture; the released binary covers win32" + : false + +// Build a fake install: /node_modules/@systemfsoftware// with a +// package.json and an executable shim named after the host binary. NODE_PATH +// entries are joined directly with the requested id, so it must point at the +// fixture's node_modules dir for createRequire to resolve the package. +function makeFixture() { + const root = mkdtempSync(join(tmpdir(), "comment-checker-launcher-")) + const pkgDir = join(root, "node_modules", hostDep) + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), `${JSON.stringify({ name: hostDep, version: "0.0.0" })}\n`) + if (process.platform !== "win32") { + const shim = ["#!/bin/sh", "printf '%s\\n' \"$*\"", 'exit "${SHIM_EXIT:-0}"'].join("\n") + writeFileSync(join(pkgDir, binaryFileName(process.platform)), `${shim}\n`, { mode: 0o755 }) + } + return { root, npmPath: join(root, "node_modules") } +} + +const runLauncher = (npmPath, args = [], extraEnv = {}) => + spawnSync(process.execPath, [launcherPath, ...args], { + encoding: "utf8", + env: { ...process.env, NODE_PATH: npmPath, ...extraEnv }, + timeout: 30_000, + }) + +test( + "launcher spawns the host platform shim and passes args through", + { skip: skipNoPosixShim }, + () => { + const { root, npmPath } = makeFixture() + try { + const res = runLauncher(npmPath, ["--prompt", "hello"]) + assert.equal(res.status, 0, res.stdout) + assert.match(res.stdout, /hello/) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) + +test("launcher propagates the child exit code", { skip: skipNoPosixShim }, () => { + const { root, npmPath } = makeFixture() + try { + const res = runLauncher(npmPath, ["--prompt", "hello"], { SHIM_EXIT: "42" }) + assert.equal(res.status, 42, res.stdout) + assert.match(res.stdout, /hello/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("missing platform package fails with BinaryNotFound naming the package", () => { + const root = mkdtempSync(join(tmpdir(), "comment-checker-missing-")) + const npmPath = join(root, "node_modules") + mkdirSync(npmPath, { recursive: true }) + try { + const res = runLauncher(npmPath, ["--prompt", "hello"]) + assert.notEqual(res.status, 0) + // Effect rc.108's default console logger writes to stdout, not stderr, so + // the failure report is matched against the combined output. + const output = `${res.stdout}${res.stderr}` + assert.match(output, /BinaryNotFound/) + assert.ok(output.includes(hostDep), `output should name ${hostDep}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("platform helpers follow the release naming convention", () => { + assert.equal(optionalDepName("win32", "x64"), "@systemfsoftware/claude-code-comment-checker-win32-x64") + assert.equal(binaryFileName("win32"), "comment-checker.exe") + assert.equal(binaryFileName("linux"), "comment-checker") +}) + +const targets = JSON.parse( + readFileSync(new URL("../../scripts/release/targets.json", import.meta.url), "utf8") +) + +for (const target of targets) { + test(`target ${target.target} maps to optional dep ${target.suffix}`, () => { + assert.equal( + optionalDepName(target.os, target.cpu), + `@systemfsoftware/claude-code-comment-checker-${target.suffix}` + ) + assert.equal(binaryFileName(target.os), target.bin) + }) +} \ No newline at end of file From a440434f6ab4009db0980168195d3bc84239e2bb Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:21:57 +0000 Subject: [PATCH 05/10] docs(npm): publishing docs + first-release checklist (U5) - README Publishing section: tag flow, trusted-publisher bindings, verify steps - docs/publishing/first-release-checklist.md: one-time org setup, tag watch, post-publish verification, deprecate/bump escape hatch, action-pin duty --- README.md | 40 +++++++++ docs/publishing/first-release-checklist.md | 100 +++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 docs/publishing/first-release-checklist.md diff --git a/README.md b/README.md index 9f30d41..ad9a855 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,46 @@ cargo install --git https://github.com/systemfsoftware/comment-checker --package Requires Rust 1.85+. Each [GitHub release](https://github.com/systemfsoftware/comment-checker/releases) also attaches `comment-checker-.tar.gz` tarballs for direct download. +## Publishing + +Releases are tag-triggered: pushing a tag `vX.Y.Z` to `main` runs +[`.github/workflows/release.yml`](.github/workflows/release.yml), which builds +all five target binaries, publishes the five platform packages and then the +root launcher — all with npm OIDC trusted publishing and provenance, no static +tokens in CI. The exact step sequence lives in U3 of the release plan +(`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`). + +To release: + +1. Create the six [npm trusted-publisher + entries](https://docs.npmjs.com/generating-provenance-statements) with the + bindings below (one time). +2. Push a semver tag: `git tag v0.1.0 && git push origin v0.1.0`. +3. Watch CI; the root package is published only after every platform package + exists and its published binary matches the recorded sha256. +4. Verify post-publish: a fresh `pnpm dlx`/`npm i -g` install of the root + package, run the binary on Linux and on one non-Linux platform, and confirm + `npm view @systemfsoftware/claude-code-comment-checker provenance` shows + provenance. + +The npm trusted-publisher records bind the publishing identity to this +workflow — the registry-side record has no tag-pattern field, so +`refs/tags/v*` is enforced by the workflow's `on: push: tags` filter, never by +the registry-side record: + +| Package | Trusted publisher binding | +|---|---| +| `@systemfsoftware/claude-code-comment-checker` | Org: `systemfsoftware`, repo: `comment-checker`, workflow: `.github/workflows/release.yml` | +| `@systemfsoftware/claude-code-comment-checker-linux-x64` | same | +| `@systemfsoftware/claude-code-comment-checker-linux-arm64` | same | +| `@systemfsoftware/claude-code-comment-checker-darwin-x64` | same | +| `@systemfsoftware/claude-code-comment-checker-darwin-arm64` | same | +| `@systemfsoftware/claude-code-comment-checker-win32-x64` | same | + +The version pinned in the committed launcher manifest (`0.1.0`) may lag behind +releases by design — the git tag is the single version source; the release +workflow rewrites the published manifest. + ## Quick Start 1. Install (above). diff --git a/docs/publishing/first-release-checklist.md b/docs/publishing/first-release-checklist.md new file mode 100644 index 0000000..be42364 --- /dev/null +++ b/docs/publishing/first-release-checklist.md @@ -0,0 +1,100 @@ +# First npm Release Checklist + +The release pipeline (`.github/workflows/release.yml`) stages everything; this +checklist is the one-time org-admin setup plus the manual verification steps +that only a human with `systemfsoftware` access can run (AGENTS.md Human +Approval Boundaries). Work through it top to bottom. + +## 1. One-time org setup (npm + GitHub) + +- [ ] Confirm the GitHub repo default workflow permissions are read-only + (Settings → Actions → General → Workflow permissions → Read repository + contents and packages permissions). The workflow declares its own + minimal per-job grants on top. +- [ ] Create the six npm trusted-publisher entries + (`npm access` / web form on the npm org): + `@systemfsoftware/claude-code-comment-checker` plus the five platform + packages (`-linux-x64`, `-linux-arm64`, `-darwin-x64`, `-darwin-arm64`, + `-win32-x64`). Every entry binds to: + - Organization / Repository: `systemfsoftware` / `comment-checker` + - Workflow Filename: `.github/workflows/release.yml` + - Environment: `npm-release` (recommended; see note below) + npm's trusted-publisher form has **no tag-pattern field** — the + `refs/tags/v*` gate is enforced by the workflow's `on: push: tags` + filter, never by the registry-side record. +- [ ] Brand-new package names: npm may require a one-time seed publish before a + trusted-publisher record can be configured for a name that has never + existed. If the form rejects a name, seed-publish an empty placeholder + (`npm publish --access public` with a trivial tarball) once per name, + then configure the record. Budget a human seed per name if needed. +- [ ] Recommended: create a GitHub Environment named `npm-release` and add + required reviewers to the publish jobs. Deferrable — if skipped, the + convention is exact semver tags only. If added, the environment must be + referenced in `publish-npm-main`'s `environment:` key in release.yml. +- [ ] Confirm **no PAT** exists in any release job: only the default + `GITHUB_TOKEN` (for uploading release assets) and `id-token: write` for + npm OIDC provenance. Pull requests and tags must not carry secrets. + +## 2. Before the tag + +- [ ] `pnpm lint` and `deno task lint` green (repo gate). +- [ ] `node --test "tests/npm-launcher/*.test.mjs"` green (launcher black-box). +- [ ] `deno task test` green (release scripts; includes targets-table and + matrix checks). +- [ ] `pnpm install --frozen-lockfile` succeeds from a fresh clone, and + `pnpm -r build` + `pnpm -r typecheck` are green. +- [ ] `scripts/release/check-matrix.ts` passes with `.github/workflows/release.yml` + present: `deno run --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/release/check-matrix.ts` +- [ ] Cargo side (the release workflow runs its own build; there is no local + build requirement, but the Rust gate is `cargo fmt --check && cargo + clippy --all-targets -- -D warnings && cargo test --all-targets`). + +## 3. Tag and watch + +- [ ] Confirm the tag commit is an ancestor of the default branch (the + workflow enforces this; also true by construction for the merge). +- [ ] `git tag v0.1.0 && git push origin v0.1.0`. +- [ ] Watch the release run: five `release-*` matrix jobs (one per target), + `publish-npm-main`, `upload-gh-release-assets`. Each `release-*` job + gates on check-matrix, binary existence, smoke (exit 0 clean / exit 2 + flagged), records the binary sha256 sidecar, and publishes its platform + package with provenance. +- [ ] The run fails fast if any gate trips; the root is never published when a + platform package is missing or its published binary sha256 differs. + +## 4. Post-publish verification + +- [ ] `npm view @systemfsoftware/claude-code-comment-checker@v0.1.0 version` is + exactly `0.1.0`, and `optionalDependencies` pins all five platform + packages at `0.1.0` exactly (never a range). +- [ ] Per suffix: `npm view @systemfsoftware/claude-code-comment-checker-@0.1.0` + shows `version: 0.1.0` and `os`/`cpu`/`libc` equal to + `scripts/release/targets.json` (workflow already gates this; re-check by + hand here). +- [ ] Provenance visible: `npm view @systemfsoftware/claude-code-comment-checker@0.1.0 provenance` (npmjs.org shows the OIDC origin). +- [ ] Fresh install on Linux (CI simulates): `pnpm dlx @systemfsoftware/claude-code-comment-checker` or `npm i -g` and run the hook binary with a clean and a flagged payload. +- [ ] Manual fresh install on macOS and on Windows (record both runs' outputs). + +## 5. Sanctions and escape hatches + +- [ ] Wrong binary / duplicate version recovery: npm versions are immutable — + do **not** force-republish. Use `npm deprecate @` and ship + the fixed binary as the next tag. +- [ ] Amending an already-published version (rolling back) is not possible; + the tag-reachability gate prevents stray tags from publishing + unreviewed commits, but the final guard is the human before `git push` + of the tag. + +## 6. Maintenance duty (recorded, one-off) + +- [ ] The workflow pins every third-party action to a full commit SHA. Keep + the SHA→tag mapping fresh via a dependabot `github-actions` group so + pins are updated deliberately, never silently. + +## 7. After the first release + +- [ ] README: remove the "pre-release" status note under Install and keep the + npm install command as primary. +- [ ] Record actual download counts vs the cargo-install era as the adoption + signal (open item; not a release precondition). +- [ ] Optionally deprecate the direct GitHub tarball path once npm is proven. \ No newline at end of file From e8af67466721dd5a64b7cb454df67fb72357a0fe Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:23:52 +0000 Subject: [PATCH 06/10] docs(plan): npm distribution release plan --- ...-001-feat-npm-distribution-release-plan.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md diff --git a/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md b/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md new file mode 100644 index 0000000..9475006 --- /dev/null +++ b/docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md @@ -0,0 +1,325 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +created: 2026-08-17 +updated: 2026-08-17 +deepened: 2026-08-17 +type: feat +--- + +# npm Distribution Release - Plan + +## Goal Capsule + +- **Objective:** Publish the first npm release of comment-checker — five platform binary packages plus a root launcher package, shipped by a tag-triggered GitHub Actions release pipeline with OIDC provenance, no install-time scripts, and verified installs. +- **Product authority:** User-directed. The user confirmed the distribution scope ("OK plan the release of this thing") on 2026-08-17; the distribution pattern (per-platform packages + `optionalDependencies`, Node launcher) was examined in-session and accepted. Publishing to npm under the `systemfsoftware` org is a human-controlled action per AGENTS.md — the pipeline stages everything; a human cuts the tag. +- **Open blockers:** None. npm trusted-publisher entries (one per package, six total) and the package names require one-time org-admin setup, owned by the first-release checklist (U5). +- **Stop conditions:** the plan is done when `v0.1.0` is live on npm with provenance, a fresh install on Linux and on one of macOS/Windows runs the real binary, and `--ignore-scripts` installs still work. + +--- + +## Summary + +Comment-checker is a Rust CLI distributed today via `cargo install` and GitHub-release tarballs. This plan lands the npm distribution layer built in `npm/packages/comment-checker/`: five per-platform binary packages named `@systemfsoftware/claude-code-comment-checker--`, and a root launcher package `@systemfsoftware/claude-code-comment-checker`. Package managers install exactly one platform package per machine; the Node launcher resolves and spawns it. The release pipeline rebuilds the un-merged `feat/npm-optional-dependencies` branch work with its gaps fixed: a tag-triggered GitHub Actions workflow publishing platform packages first, then the root, all with OIDC provenance and no static token. + +## Problem Frame + +A Claude Code hook distributed via npm must give each user a working native binary with zero friction and zero install-time execution. The previous distribution experiments (`feat/npm-optional-dependencies`, `feat/modern-npm-distribution-pnpm-11`) produced an implemented launcher and a draft release workflow but never merged: the current tree has no launcher manifest, no release workflow, and no published packages. The SOTA classifier plan (`docs/plans/2026-08-12-002-feat-sota-comment-adjudication-plan.md`) explicitly deferred the npm distribution layer to follow-up. The npm layer changes no Rust code; it makes the binary installable. + +**Premise for this release.** The audience is Claude Code hook users, and Claude Code runs on Node — every hook user therefore already has a Node runtime, while `cargo install` demands a Rust toolchain this audience commonly lacks. The existing paths (cargo install from git, GitHub tarballs) stay live for Rust users and for non-npm sinks; npm closes the gap for the primary audience at zero install-time code execution. The release decision is user-directed in-session; demand validation (download counts vs the cargo-install era, install-friction issues) is tracked as an open adoption signal after `v0.1.0` rather than a precondition. + +## Requirements + +**Packaging** + +- R1. Per-platform packages. Exactly one npm package per supported target, named `@systemfsoftware/claude-code-comment-checker--`, containing only the native binary and a manifest declaring `os`/`cpu` (`libc` on Linux, see KTD4). Binary name is `comment-checker` on POSIX, `comment-checker.exe` on Windows. +- R2. Root launcher package. `@systemfsoftware/claude-code-comment-checker` exposes `bin` → the built ESM launcher, ships only the built launcher in `files`, and runs no `postinstall` or any install-time script. +- R3. Install-time selection. A plain install on Linux x64 installs exactly `…-linux-x64` and yields a working binary. Supported: Linux (x64, arm64), macOS (x64, arm64), Windows (x64). + +**Release pipeline** + +- R4. Tag-triggered publish. Pushing tag `vX.Y.Z` builds the release binary for all five targets, publishes the five platform packages then the root package at version `X.Y.Z`, with `optionalDependencies` pinned to that exact version (never a range). +- R5. Trusted publishing. All publishes use OIDC (`id-token: write`) and npm provenance; no static token is stored in CI or repo. +- R6. GitHub release tarballs. The per-target `.tar.gz` archives remain attached to the release for non-npm users. + +**Verification** + +- R7. Every platform's binary is smoke-tested before publish; the published root is verified after publish; the launcher resolution is covered by automated tests. + +**Documentation** + +- R8. README install/publish claims match the live state after the first release, and maintainers have documented publish steps (tag flow, trusted-publisher setup, post-publish verification). + +## Key Decisions + +- KD1 — Ship the README-documented matrix (5 targets: Linux x64/arm64, macOS x64/arm64, Windows x64). No musl, no win32-arm64 in the first release; matches the README/FAQ, prior plan, and workflow drafts. +- KD2 — No install-time download or fallback. A missing binary fails cleanly with `BinaryNotFound` naming the platform. Chosen over a Sentry-style postinstall fallback: the fallback reintroduces network access and script execution at install time, and the launcher's current behavior already satisfies this. *(session-settled: user-approved — chosen over an install-time download fallback: keeps zero install-time network/script execution.)* +- KD3 — Provenance on every published package and no static tokens (OIDC identity from the Actions runner). +- KD4 — Distribution via per-platform packages + `optionalDependencies` in a root launcher package. *(session-settled: user-approved — chosen over single-package postinstall download and all-platforms-in-one-tarball: managers select one platform package natively, no install scripts.)* +- KD5 — The package entry is a Node launcher. *(session-settled: user-approved — chosen over Deno/Bun shims: npm/pnpm/yarn are Node programs, so Node is the one runtime the distribution channel guarantees.)* + +--- + +## Planning Contract + +### Key Technical Decisions + +- **KTD1 — Pin the Effect v4 RC deps to the vendored revision.** The launcher depends on `effect@4.0.0-rc.108` and `@effect/platform-node@4.0.0-rc.108` — the exact versions matching the `repos/effect` subtree — resolved from the npm registry by pnpm and bundled by tsdown from the installed packages (which ship TS source; `node:` built-ins stay external). The vendored subtree is reference only, never a build input. *(Alternative rejected: `file:`-linking the vendored subtree's packages into the pnpm workspace — pulls a multi-package upstream subtree into the workspace and inflates install.)* The published surface is the packed tarball, not the workspace build; the rehearsal verifies `npm pack` output per package (U3, Verification Contract). +- **KTD2 — One canonical targets table.** `scripts/release/targets.json` declares the five targets (triple, suffix, `os`/`cpu`/`libc`, bin name). The manifest generator and the workflow's matrix check consume it; a unit test keeps it consistent. The launcher derives package/group names by identity from `process.platform`/`process.arch`, so the only drift surface is the table vs the workflow matrix, closed by a workflow check step (`check-matrix`). +- **KTD3 — Release workflow shape.** `push: tags ['v*']` → one `release` job per target in a 5-row matrix (build, gate, smoke, tar.gz upload, then publish the platform package via `pnpm publish --provenance`), then `publish-npm-main` (`needs: release`) installs frozen lockfile, builds the launcher, syncs versions from the tag, and publishes the root; `upload-gh-release-assets` attaches tarballs. The un-merged draft workflow is precedent; this shape adds the pre-publish gate and smoke, and a tested version-sync script. +- **KTD4 — Linux packages declare `"libc": ["glibc"]`.** Without it, musl/Alpine users (also `linux-x64`) install the glibc binary and hit a loader crash at runtime instead of the intended clean `BinaryNotFound`. Modern npm and pnpm honor the `libc` field and skip a mismatched platform package. +- **KTD5 — The Git tag is the single version source.** The workflow rewrites launcher `version` and every `optionalDependencies` value to the tag version via a tested script; the committed manifest may lag behind (stays at `0.1.0` until the first bump). Full version automation is deferred. + +### High-Level Technical Design + +**Target matrix** + +| Target | Runner | Suffix | os | cpu | libc | Bin | +|---|---|---|---|---|---|---| +| `x86_64-unknown-linux-gnu` | ubuntu-latest | `linux-x64` | linux | x64 | glibc | `comment-checker` | +| `aarch64-unknown-linux-gnu` | ubuntu-latest + cross linker | `linux-arm64` | linux | arm64 | glibc | `comment-checker` | +| `x86_64-apple-darwin` | macos-13 | `darwin-x64` | darwin | x64 | — | `comment-checker` | +| `aarch64-apple-darwin` | macos-14 | `darwin-arm64` | darwin | arm64 | — | `comment-checker` | +| `x86_64-pc-windows-msvc` | windows-2022 | `win32-x64` | win32 | x64 | — | `comment-checker.exe` | + +**Release flow** + +```mermaid +flowchart LR + T["vX.Y.Z tag"] --> M["matrix job × 5 targets"] + M --> B["cargo build --release --target"] + B --> G["gate: binary exists + executable + smoke run"] + G --> A["upload tar.gz artifact"] + G --> P["publish platform pkg --provenance"] + A --> U["upload-gh-release-assets"] + P --> N["publish-npm-main (needs matrix)"] + N --> S["sync version + optionalDeps to tag"] + S --> R["publish root pkg"] + R --> V2["verify: npm view + fresh install"] +``` + +**Consumer path** + +```mermaid +flowchart LR + I["npm/pnpm install root pkg"] --> M2["manager matches os/cpu/libc"] + M2 --> P["installs exactly one platform pkg"] + P --> L["node dist/index.mjs"] + L --> RC["require.resolve(platform pkg/package.json)"] + RC --> S2["spawn binary, inherit stdio"] + RC -. not installed .-> E["BinaryNotFound: platform + arch + package"] +``` + +### Assumptions + +- The npm org admin pre-creates trusted-publisher entries for all six packages and the five package names are available; missing setup fails fast at publish. +- The `systemfsoftware` npm identity is authorized to publish all six names. The human who cuts the tag is the authority gate per AGENTS.md. +- `pnpm-workspace.yaml` (packages `npm/packages/*`) stays unchanged; the launcher resolves Effect from the registry pinned at KTD1. +- Node `>=18` is the launcher floor (matches `@effect/platform-node` engines); CI gate runs node 20; publish jobs run Node 24 (npm ≥ 11.5.1 required for trusted publishing). + +--- + +## Implementation Units + +### U1. Launcher package manifest + workspace lockfile + +**Goal:** Give `npm/packages/comment-checker/` a valid publishable manifest and make pnpm workspace build/typecheck/install work. + +**Requirements:** R2, R3. + +**Dependencies:** none. + +**Files:** +- `npm/packages/comment-checker/package.json` — create +- `npm/packages/comment-checker/tsdown.config.ts` — confirm the single ESM output builds from the Effect source with `node:` built-ins external; adjust only if the Effect source breaks the single-file build +- `pnpm-lock.yaml` — generate and commit the root lockfile (none exists today; a first non-frozen `pnpm install` produces it, and the CI `--frozen-lockfile` steps only hold once it is committed) +- `package.json` (root) — unchanged + +**Approach:** +- Author the manifest (it does not exist in this worktree; the `feat/npm-optional-dependencies` variant is precedent only): `name: "@systemfsoftware/claude-code-comment-checker"`; `bin: { "comment-checker": "./dist/index.mjs" }`; `files: ["dist"]`; `scripts: { "build": "tsdown", "typecheck": "tsc -p tsconfig.json --noEmit" }` (satisfies the root `pnpm -r build`/`typecheck`); no `postinstall`/`prepare`; no `private` field (the workspace root's `private: true` does not propagate); `dependencies`: `effect` + `@effect/platform-node` pinned `4.0.0-rc.108` (KTD1); `@types/node` as devDependency; `engines: { "node": ">=18" }` (verified at rehearsal — the floor is asserted until `dist` runs on Node 18, not inherited); `publishConfig: { access: "public", provenance: true }`; `optionalDependencies` listing the five suffixed names at the package version. +- Replace the hardcoded `version: "0.1.0"` in the launcher with a runtime read of the launcher's own `package.json` (via `createRequire`) so `comment-checker --version` can never drift from the published manifest (KTD5); `src/index.ts` is edited in U1, `sync-root-version.mjs` stays the only external writer. +- Generate the lockfile with a first non-frozen `pnpm install` pinned to the canonical registry (`--registry https://registry.npmjs.org`) at the workspace root; commit it; thereafter frozen installs in CI only — the lockfile is never regenerated arbitrarily. +- Verify `pnpm -r build` emits `dist/index.mjs` and `pnpm -r typecheck` passes with the Effect language-service rules intact. + +**Approach detail:** the launcher source already implements `optionalDepName(platform, arch)` returning `@systemfsoftware/claude-code-comment-checker--` — keep it; the manifest must list exactly those names. + +**Test scenarios:** +- Manifest assertions: no `postinstall`; `files` = `["dist"]`; `optionalDependencies` has five entries equal to the launcher's name convention; `publishConfig.provenance` true; `engines.node` = `">=18"`. +- `pnpm -r build` and `pnpm typecheck` succeed; `dist/index.mjs` exists. +- Running `node dist/index.mjs` on a machine without the platform package exits non-zero and prints `BinaryNotFound` naming the expected package. + +**Verification:** `pnpm -r build` + the `BinaryNotFound` smoke (until the platform package exists, that is the expected launcher behavior; at release it must not trip). Also: `pnpm lint` stays green once the new `scripts/` and `tests/` files exist (U2/U4 own that surface), and the built `dist/index.mjs` runs on Node 18 (rehearsal proves this — engines is asserted, not inherited). + +### U2. Release scripts and unit tests + +**Goal:** Extract platform-manifest generation and version sync into tested scripts behind `targets.json`, so the workflow is thin and verifiable without a real release. + +**Requirements:** R1, R4, R7. + +**Dependencies:** U1 (manifest shape); defines the five names U1 must list. + +**Files:** +- `scripts/release/targets.json` — five entries `({ target, suffix, os, cpu, libc?, bin })`. +- `scripts/release/generate-platform-manifest.mjs` — given a suffix and version, emits the platform package.json: name, version, description, license, repository, os/cpu, libc when the table says so, files `[bin]`, and `publishConfig: { access: "public", provenance: true }` (mirrors the launcher manifest so provenance attaches even if an operator publishes without the `--provenance` flag). Supports `--dry-run` (prints, does not write). +- `scripts/release/sync-root-version.mjs` — rewrites the launcher manifest's `version` and all `optionalDependencies` values from a `VERSION` env; validates the value against `^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$` and errors before any write on a malformed tag (no inline `node -e` JSON concatenation anywhere — the script is the only writer). Dry-run prints the diff. +- `scripts/release/check-matrix.mjs` — asserts the five table entries and the workflow matrix agree (os/cpu derivable from suffix by identity). +- `tests/release/release-scripts.test.mjs` — `node:test` suite. + +**Approach:** +- Dependency-free Node scripts; `node:test` runner (Node 18+ built-in), no new toolchain. +- `targets.json` is the single source for suffix → os/cpu/libc; `check-matrix` and the unit tests keep it consistent with the launcher's identity naming (KTD2). +- Brownfield note: neither `scripts/` nor `tests/` exists at repo root today — the entire `scripts/release/` and `tests/release/` trees are net-new; no precedent path is reused from the un-merged branch. + +**Test scenarios:** +- For each of the five table entries: `generate-platform-manifest` emits a manifest with correct os/cpu/libc/bin, name `@systemfsoftware/claude-code-comment-checker-`, `libc` present only for linux entries, `.exe` bin on win32. +- `--dry-run` leaves no files changed (tree identical before/after). +- `sync-root-version` with `VERSION=0.1.0` sets `version` + all five optionalDependencies to `0.1.0`; with `0.2.0` the only diff is the version fields. +- Malformed tag values (`v0.1.0"`, `0.1.0\n--provenance=false`, `not-a-version`) → `sync-root-version` exits non-zero and writes nothing (semver gate). +- Unknown suffix → error listing the five supported suffixes. +- `check-matrix` fails when a table entry's os/cpu is inconsistent with the identity convention; passes otherwise. + +**Verification:** `node --test tests/release` green. + +### U3. Release workflow + +**Goal:** The GitHub Actions pipeline that builds, gates, publishes all six packages, and attaches tarballs. + +**Requirements:** R1–R7. + +**Dependencies:** U1, U2. + +**Files:** +- `.github/workflows/release.yml` — create (supersedes the un-merged draft on `feat/npm-optional-dependencies`). +- `.github/workflows/ci.yml` — unchanged; note in the new file that release flows from tags, not from CI (which gates `main`). + +**Approach:** +- Workflow-level `permissions: {}` — nothing inherited; each job grants only what it needs. `release` matrix jobs: `id-token: write` (npm OIDC); `upload-gh-release-assets`: `contents: write` via the default `GITHUB_TOKEN` only (no PAT, no per-job secrets — re-stated in the DoD); `publish-npm-main`: `id-token: write`. +- `concurrency: { group: release-${{ github.ref }}, cancel-in-progress: false }` at workflow level — two simultaneous tag pushes must never race the publish (npm versions are immutable; cancelling mid-publish would strand a half-published root). `cancel-in-progress: false` is deliberate. +- Publish jobs use `actions/setup-node@v4` with `node-version: 24` (npm ≥ 11.5.1 is required for trusted-publishing OIDC) and `registry-url: https://registry.npmjs.org` — the registry URL is what wires the npm OIDC exchange; do NOT set `NODE_AUTH_TOKEN` (an empty token line overriding OIDC is the documented breakage). `dtolnay/rust-toolchain@stable` with the target; linux-arm64 installs `gcc-aarch64-linux-gnu` and sets `CC_aarch64_unknown_linux_gnu`/`CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER`/`AR_aarch64_unknown_linux_gnu` (env-var approach keeps the `.cargo/config.toml` `[env]` intact for TSLP_LINK_MODE=static). Cargo cache key includes the matrix target (`${{ runner.os }}-${{ matrix.target }}-cargo-…`) so linux x64/arm64 do not thrash one cache. +- Action pinning: every third-party action is declared by tag name in this plan for identification, and pinned to its full commit SHA in the workflow file (per DoD); the SHA → tag mapping lives in the U5 maintenance duty alongside a dependabot `github-actions` group so pins are updated deliberately. +- Tag-gate before any publish (top of `publish-npm-main` and re-checked in the matrix): derive `VERSION` from `${GITHUB_REF#refs/tags/v}` and reject anything failing the semver pattern (U2's gate) — exit non-zero before any write or publish. Also verify the tag's commit is reachable from the default branch (`git merge-base --is-ancestor `) so a stray tag can't publish un-reviewed content. +- Gate before platform publish (per target): `check-matrix` assertion; binary exists; binary is executable (POSIX); smoke — pipe the README's clean payload (expect exit 0) and one flagged payload (expect exit 2); record `binarySha256` (from `sha256sum` of the exact staged binary) into the generated manifest and upload a sidecar artifact. +- Publish platform: stage in a temp dir **outside** the workspace (`$RUNNER_TEMP/…`) with `generate-platform-manifest` + the binary; dependency-free manifests need no `pnpm install`, and staging out-of-tree avoids workspace-context surprises; then `pnpm publish --provenance --access public` from that dir. +- After the matrix, before the root publish, `publish-npm-main` re-verifies the registry: `npm view -@ version os cpu libc` for all five suffixes — equality with `targets.json`, plus the `npm pack` + `sha256sum` cross-check against the recorded `binarySha256`. Any mismatch fails the run before the root publish (the root's `optionalDependencies` must not point at missing packages). +- Then: `pnpm install --frozen-lockfile` (registry pinned), `pnpm -r build`, `VERSION=… node scripts/release/sync-root-version.mjs` (tested script; no inline `node -e`), `pnpm publish --no-git-checks --provenance --access public` from the launcher dir → verify root via `npm view` (exact optional pins + provenance available). +- `upload-gh-release-assets` (`needs: release`, `permissions: { contents: write }`): `actions/download-artifact@v4` with `pattern: release-*`, `merge-multiple: true`; attach via `softprops/action-gh-release@v2` (SHA-pinned). `actions/attest-build-provenance` for the tarballs is deferrable hardening. +- Inline comment on the `on:` block: fork safety derives from `push: tags` (only repo writers push tags); `pull_request_target` is intentionally not used; the trusted-publisher record binds workflow filename + environment, not tag patterns (the `on:` filter is the tag gate — see U5). + +**Test scenarios:** (unit) none — pure CI config; the behavioral contract is the rehearsal and first release in the Verification Contract. +**Acceptance (non-unit):** the gate must fail fast when the binary is missing; a non-semver tag must fail before any publish; the root must never be published when any platform package is absent or when a `binarySha256` cross-check fails (ordering gate). + +**Verification:** rehearsal per Verification Contract; first real tag publishes and the `npm view`/fresh-install checks pass. The packed tarball — not the workspace build — is the surface consumers install, so the rehearsal verifies the `pnpm publish --dry-run`/`npm pack` output per package; provenance is only provable on the real publish. + +### U4. Launcher integration tests + +**Goal:** Prove resolver+spawn end-to-end against fixtures — and pin the platform-name convention shared with the release tooling. + +**Requirements:** R2, R3, R7. + +**Dependencies:** U1 (build output). + +**Files:** +- `npm/packages/comment-checker/src/platform.ts` — extract pure helpers `platform`/`arch`-derived names (`optionalDepName(platform, arch)`, `binaryFileName(platform)`) that `index.ts` uses; add a second tsdown entry so tests can import `dist/platform.mjs` without executing the CLI. The module must import no Effect client (`effect`, `@effect/*`) — the `@effect/language-service` floatingEffect rule is error in this tsconfig and the module must stay pure. +- `tests/npm-launcher/launcher.test.mjs` — `node:test` black-box suite driving `node dist/index.mjs`. + +**Approach:** +- Black-box: fixture dir `node_modules/@systemfsoftware/claude-code-comment-checker-/` with a fake `package.json` + a shim `comment-checker` script (echoes args, exits with a chosen code); run under `NODE_PATH=` so `createRequire` resolution finds it. +- Negative: no fixture → `BinaryNotFound` with the exact package-name suffix, os, arch; non-zero exit. + +**Test scenarios:** +- Happy path: `node dist/index.mjs --prompt hello` spawns the shim with `--prompt hello` and passes its exit code through (0 and non-zero). +- Missing fixture: stderr names the platform package (`…-linux-x64`-style), `process.platform` value, and exit non-zero. +- `optionalDepName('win32','x64')` equals the win32 entry of `targets.json`'s suffix; `binaryFileName('win32')` = `comment-checker.exe`; `binaryFileName('linux')` = `comment-checker`. +- The five table entries' suffixes round-trip through `optionalDepName`/`binaryFileName` (KTD2). + +**Verification:** `node --test tests/npm-launcher` after `pnpm -r build`. + +### U5. Docs, publish how-to, first-release checklist + +**Goal:** README claims match reality, publishing instructions and one-time org setup documented. + +**Requirements:** R8. + +**Files:** +- `README.md` — reword the "pre-release" status once live; add a "Publishing" section: tag flow, verification steps, and the trusted-publisher binding table: six packages (`@systemfsoftware/claude-code-comment-checker` + the five suffix names), each bound to this repo's `Organization`/`Repository`, `Workflow Filename` = `.github/workflows/release.yml`, and (recommended) `Environment` = `npm-release`. npm's trusted-publisher form has no tag-pattern field — `refs/tags/v*` is enforced by the workflow's `on: push: tags:` filter, never by the registry-side record; the registry-side guard is a fixed workflow filename (any other workflow in this repo is rejected). +- First-release checklist (docs): a brand-new package name may need a one-time seed publish before its trusted-publisher record can be configured (npm docs; confirm and budget a human seed per name if needed); create the six trusted-publisher entries with the exact bindings above; confirm GitHub repo default workflow permissions are read-only; recommended GitHub Environment `npm-release` with required reviewers on the publish jobs (deferrable — if skipped, the convention is exact semver tags only); record the action SHA→tag mapping as a dependabot `github-actions` group maintenance duty; confirm no PAT exists in any release job (default `GITHUB_TOKEN` only); record the manual macOS + Windows fresh-install runs. +- `AGENTS.md` — Locked surface: propose in the release PR a one-line directory-map addition naming the release workflow under `.github/workflows/`. Do not add rules or restate existing boundaries there — the Human Approval Boundaries contract already gates publishing. + +**Test scenarios:** none — docs + one-time admin setup; `Test expectation: none -- documentation; the release rehearsal + cross-platform install checks verify the claims.` + +**Verification:** README publish/install sections match the U3 rehearsal output; DoD cross-platform check recorded. + +--- + +## Verification Contract + +Run in this order: + +| # | Check | Command / outcome | +|---|---|---| +| 1 | Repo gate | `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --all-targets` (Rust side untouched) | +| 2 | JS lint | `pnpm lint` — new `scripts/`, `tests/` files stay lint-clean (no ignorePatterns widening) | +| 3 | Scripts unit | `node --test tests/release` (includes malformed-tag rejection) | +| 4 | Launcher build | `pnpm -r build` → `dist/index.mjs` + `dist/platform.mjs` | +| 5 | Launcher tests | `node --test tests/npm-launcher` | +| 6 | Frozen install | `pnpm install --frozen-lockfile` (root lockfile committed in U1; hash stable under frozen install) | +| 7 | Rehearsal | `generate-platform-manifest` per target (dry-run), `check-matrix`, engines floor on Node 18, `pnpm publish --dry-run`/`npm pack` in a copy of each platform dir + root (registry pinned); record binary `binarySha256` values; Node-24 OIDC path noted as provable only on the real publish | +| 8 | First publish | `v0.1.0` tag → six packages at `0.1.0`; `npm view` per suffix shows `version` + `os`/`cpu`/`libc` equal to `targets.json`, root shows exact optional pins; per-platform `npm pack` re-fetch + `sha256sum` cross-check passes; provenance visible on npm | +| 9 | Fresh install | `pnpm dlx`/`npm i -g` the published package and run the binary on Linux (CI) and manually on macOS + Windows (before DoD) | +| 10 | `--ignore-scripts` | install with scripts disabled; npm logs must not report any executed lifecycle script, and the binary still runs | + +## Definition of Done + +- [ ] `npm/packages/comment-checker/package.json` + lockfile live; frozen install and build green (U1). +- [ ] Release scripts + `targets.json` + green `node:test` (U2). +- [ ] `release.yml` replaces the draft; pre-publish gate + smoke in place (U3). +- [ ] Launcher tests green; `platform.ts` extracted (U4). +- [ ] Docs and first-release checklist up (U5). +- [ ] `v0.1.0` live: six packages with provenance, exact `optionalDependencies` for the tag; fresh install runs the real binary on Linux and on ≥1 of macOS/Windows (second platform recorded manually). +- [ ] No static token anywhere in CI assets; `id-token` only; workflow-level `permissions: {}` with per-job grants; every third-party action pinned by full commit SHA (SHA→tag mapping in U5); default `GITHUB_TOKEN` is the only repo token; no PAT. +- [ ] Publish jobs run Node 24 (npm ≥ 11.5.1) with `registry-url: https://registry.npmjs.org` and no `NODE_AUTH_TOKEN`; workflow `concurrency` group present; tag-reachability check in the release gate. +- [ ] Version sync runs only through `sync-root-version.mjs` (no inline `node -e`); malformed-tag rejection is tested; the launcher reads its version at runtime (no hardcoded literal). +- [ ] Per-suffix registry verification (`npm view` of version/os/cpu/libc) passes for all five platform packages before the root publish. +- [ ] Root lockfile committed in U1 (generated against the canonical registry) and stable under frozen install in CI. +- [ ] Binary `binarySha256` recorded pre-publish and cross-checked against the published packages (U3). +- [ ] Cleanup: no scratch repos/temp staging dirs (gitignored `npm/bin` and temp dirs) left in the diff. + +## Risks & Dependencies + +- **musl / Alpine loader crash** — without the `libc` tag (KTD4), musl machines install the glibc binary and crash instead of a clean error. Gated by a U2 test on the linux entries. +- **Non-atomic publish race** — platform-first ordering + `needs: release` on the root publish prevents the root depending on packages that did not publish; a workflow-level `concurrency` group (per tag) prevents two tags racing; `binarySha256` cross-check catches a wrong-binary publish within one run. On a wrong-binary catch or duplicate-version E409, the recovery is `npm deprecate @` + ship the fix as the next tag (versions are immutable; no re-publish). +- **Wrong-binary published despite checks** — the `npm pack` + `sha256sum` audit runs on the same runner that just published (self-trusting); recorded `binarySha256` sidecars mitigate drift, and a separate clean-runner audit job is hardening (decided later). Document the deprecate-and-bump escape hatch in the U5 checklist. +- **Malformed tag / version injection** — a hostile or mistyped tag is rejected by the semver gate before any write or publish (U2/U3); the version writer is `sync-root-version.mjs`, never inline JSON concatenation. +- **Stray tag on an unreviewed commit** — tag-reachability check (commit must be an ancestor of the default branch) in the release gate; mitigates a writer tagging an unreviewed commit. +- **Actions supply chain** — every third-party action pinned by full commit SHA; workflow defaults `permissions: {}`; GitHub repo default is read-only; SHA→tag mapping maintained via a dependabot `github-actions` group. +- **npm OIDC/provenance quirks** — trusted publishing requires npm ≥ 11.5.1 (publish jobs run Node 24) and `registry-url` set without `NODE_AUTH_TOKEN`; pnpm 11.21.0 pinned; rehearsal exercises every runner job; Windows-runner OIDC + pnpm is the least-validated path and gets a dedicated rehearsal note; macOS 13 runner deprecation would need a darwin-x64 build fallback (cross-compile from macos-14 or a large runner) — re-check before the release. +- **Trusted-publisher setup friction** — six records must exist; a brand-new package name may need a one-time seed publish first (npm docs); bindings are repo + workflow filename + environment (no tag field); wrong or missing setup fails fast at first publish. +- **Version drift committed-vs-published** — by design; the tag is truth (KTD5); the launcher reads its version at runtime so `--version` never drifts; documented in README publishing notes. +- **Registry-side mutation of the pinned Effect rc** — the committed root lockfile hash (generated once in U1, never regenerated arbitrarily) plus `--frozen-lockfile` and an explicit `--registry` pin are the defense; the pin must be bumped together with a `repos/effect` subtree bump. + +## System-Wide Impact + +- **Supply chain:** OIDC-only credentials; the only token in the pipeline is the default `GITHUB_TOKEN` (no PAT, no per-job secrets); every published package carries provenance; workflow default permissions are none; GitHub repo default read-only. +- **CI:** release runs cost 7 runner-jobs (5 matrix + publish-npm-main + upload-gh-release-assets); rehearsal adds none. +- **Org boundary:** tab-cutting is the human gate (AGENTS.md Human Approval Boundaries). +- **Git-flow:** release from tags; note that `ci.yml` triggers on `main` while AGENTS.md names `master` — deferred. + +## Deferred to Follow-Up Work + +- musl/Alpine platform packages (the `libc` field is already wired). +- winarm64 and FreeBSD targets. +- Automated version management (changesets) — the tag is the single source today. +- CI e2e fresh-install tests on every runner post-publish (manual today). +- A separate clean-runner publish-audit job (registers `binarySha256` verification off the publishing runner) — hardening. +- Re-review of maintained Rust→npm release tooling (cargo-dist, napi-rs) at the first new platform or version-automation ticket; the hand-rolled pipeline stays unless that review changes the calculus. +- Deprecating the direct GH tarball path once the npm path is proven. +- Reconciling `ci.yml` `main` trigger with AGENTS.md `master` naming — resolve in the first release PR (or the release review notes the mismatch). + +## Sources & Research + +- `README.md` — distribution intent, install and FAQ contract. +- Prior un-merged work: `feat/npm-optional-dependencies` (draft workflow at `.github/workflows/release.yml`, launcher manifest at `npm/packages/comment-checker/package.json`, plan at `docs/plans/2026-08-12-001-feat-modern-npm-binary-distribution-pnpm-11-plan.md`); `feat/modern-npm-distribution-pnpm-11`. +- Web: Sentry "publishing binaries on npm"; napi-rs release docs (publish order, immutability, gates); mux CLI dist plan; npm trusted-publishing docs; pnpm 11.11–11.14 release notes (`libc`/`os`/`cpu` filters, `--no-optional` semantics); esbuild `optionalDependencies` precedent. +- In-repo conventions: `.cargo/config.toml` (TSLP static env), `Cargo.toml` release profile (opt-level 3, lto=fat, strip='symbols', codegen-units=1, panic=abort), `pnpm-workspace.yaml` packages glob, `.gitignore` (`npm/bin`, `dist` already reserved; docs under `docs/` root per CE conventions). \ No newline at end of file From ff24932a17042d0a39659b7e3649ef89444d10ac Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:37:31 +0000 Subject: [PATCH 07/10] refactor(release): simplify per review passes (ce-simplify-code) - scripts/release/args.ts: shared flag parser (was triplicated; identical missing-value/unknown-arg semantics, no new deps) - release.yml: derive SUFFIXES + BINARY_NAME from targets.json (KTD2); merge registry gates into one npm view per package; one root verify view; use shasum -a 256 (macOS runners lack sha256sum) - launcher: drop unreachable fs.exists pre-check (single BinaryNotFound arm, removes TOCTOU window + message duplication) - launcher test: hostDep derived from optionalDepName - README: stop leaking plan-internal unit id --- .github/workflows/release.yml | 59 +++++++++++-------- README.md | 6 +- npm/packages/comment-checker/src/index.ts | 20 ++----- scripts/deno.lock | 4 ++ scripts/release/args.ts | 48 +++++++++++++++ scripts/release/check-matrix.ts | 36 +++-------- scripts/release/generate-platform-manifest.ts | 58 ++++++------------ scripts/release/sync-root-version.ts | 28 +++------ tests/npm-launcher/launcher.test.mjs | 2 +- 9 files changed, 133 insertions(+), 128 deletions(-) create mode 100644 scripts/release/args.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67ddc1d..9ec8340 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,12 +51,22 @@ jobs: - target: x86_64-pc-windows-msvc suffix: win32-x64 runner: windows-2022 - env: - BINARY_NAME: comment-checker${{ matrix.target == 'x86_64-pc-windows-msvc' && '.exe' || '' }} steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + # KTD2: the binary name comes from the canonical table, not a second + # copy of the win32->.exe rule. + - name: Resolve binary name from table + shell: bash + run: | + BINARY_NAME="$(jq -r --arg target "${{ matrix.target }}" '.[] | select(.target == $target) | .bin' scripts/release/targets.json)" + test -n "$BINARY_NAME" || { + echo "target ${{ matrix.target }} missing from scripts/release/targets.json" >&2 + exit 1 + } + echo "BINARY_NAME=$BINARY_NAME" >> "$GITHUB_ENV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: @@ -129,7 +139,7 @@ jobs: mkdir -p "$STAGE" BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" cp "$BIN" "$STAGE/${BINARY_NAME}" - SHA="$(sha256sum "$STAGE/${BINARY_NAME}" | cut -d' ' -f1)" + SHA="$(shasum -a 256 "$STAGE/${BINARY_NAME}" | cut -d' ' -f1)" deno run \ --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ --allow-write="$STAGE" \ @@ -214,39 +224,31 @@ jobs: merge-multiple: true path: sidecars - - name: "Gate: every platform package published before root" + - name: "Gate: every platform package published with table os/cpu/libc" shell: bash run: | - for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } + for SUFFIX in $SUFFIXES; do PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" - npm view "$PKG@${VERSION}" version --json | grep -q "\"$VERSION\"" || { + META="$(npm view "$PKG@$VERSION" version os cpu libc --json)" || { echo "platform package $PKG@$VERSION missing" >&2 exit 1 } - done - - - name: "Gate: published os/cpu/libc match the table" - shell: bash - run: | - for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do - PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" - META="$(npm view "$PKG@$VERSION" os cpu libc --json)" EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu], libc: (.libc // null)}' scripts/release/targets.json)" - echo "$META" | jq -e -c '{os, cpu, libc}' >/dev/null || { - echo "$PKG@$VERSION metadata malformed: $META" >&2 + echo "$META" | jq -e -c --arg v "$VERSION" '.version == $v and ({os, cpu, libc} == ($expect | fromjson))' --argjson expect "$EXPECTED" >/dev/null || { + echo "$PKG@$VERSION mismatch: $(echo "$META" | jq -c '{version, os, cpu, libc}') want $EXPECTED" >&2 exit 1 } - ACTUAL="$(echo "$META" | jq -c '{os, cpu, libc}')" - if [ "$ACTUAL" != "$EXPECTED" ]; then - echo "$PKG@$VERSION os/cpu/libc mismatch: got $ACTUAL want $EXPECTED" >&2 - exit 1 - fi + echo "$SUFFIX ok" done - name: Cross-check published tarballs vs recorded binary sha shell: bash run: | - for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } + for SUFFIX in $SUFFIXES; do SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")" PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" TARBALL="$(npm pack "$PKG@${VERSION}" --pack-destination "$RUNNER_TEMP" 2>/dev/null | tail -1)" @@ -284,10 +286,15 @@ jobs: - name: Verify root publish + exact pins shell: bash run: | - npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" version --json | grep -q "\"$VERSION\"" - for SUFFIX in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do - npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" optionalDependencies --json | \ - grep -q "\"@systemfsoftware/claude-code-comment-checker-${SUFFIX}\": \"$VERSION\"" || { + ROOT_META="$(npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" version optionalDependencies --json)" + echo "$ROOT_META" | jq -e -c --arg v "$VERSION" '.version == $v' >/dev/null || { + echo "root version mismatch: $(echo "$ROOT_META" | jq -c '.version')" >&2 + exit 1 + } + SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" + for SUFFIX in $SUFFIXES; do + echo "$ROOT_META" | jq -e --arg k "@systemfsoftware/claude-code-comment-checker-${SUFFIX}" --arg v "$VERSION" \ + '.optionalDependencies[$k] == $v' >/dev/null || { echo "optional pin missing for $SUFFIX@$VERSION" >&2 exit 1 } diff --git a/README.md b/README.md index ad9a855..17f5e22 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,10 @@ Releases are tag-triggered: pushing a tag `vX.Y.Z` to `main` runs [`.github/workflows/release.yml`](.github/workflows/release.yml), which builds all five target binaries, publishes the five platform packages and then the root launcher — all with npm OIDC trusted publishing and provenance, no static -tokens in CI. The exact step sequence lives in U3 of the release plan -(`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`). +tokens in CI. The exact step sequence and per-package trusted-publisher bindings are documented +in the release plan +(`docs/plans/2026-08-17-001-feat-npm-distribution-release-plan.md`) and the +first-release checklist (`docs/publishing/first-release-checklist.md`). To release: diff --git a/npm/packages/comment-checker/src/index.ts b/npm/packages/comment-checker/src/index.ts index e8bd280..bd855d5 100755 --- a/npm/packages/comment-checker/src/index.ts +++ b/npm/packages/comment-checker/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createRequire } from "node:module" -import { Data, Effect, FileSystem, Option, Path } from "effect" +import { Data, Effect, Option, Path } from "effect" import { NodeRuntime, NodeServices } from "@effect/platform-node" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Command, Flag } from "effect/unstable/cli" @@ -17,12 +17,15 @@ class BinaryNotFound extends Data.TaggedError("BinaryNotFound")<{ const getBinaryPath = Effect.gen(function* () { const path = yield* Path.Path - const fs = yield* FileSystem.FileSystem const platform = process.platform const arch = process.arch const pkg = optionalDepName(platform, arch) + // The platform package ships package.json and the binary together + // (files: [bin] in the generated manifest), so a failed require.resolve is + // the only missing-state to handle; a spawn-time ENOENT would be a corrupt + // install that npm would not have produced. const pkgJsonPath = yield* Effect.try({ try: () => require.resolve(`${pkg}/package.json`), catch: () => @@ -34,18 +37,7 @@ const getBinaryPath = Effect.gen(function* () { }), }) - const binaryPath = path.join(path.dirname(pkgJsonPath), binaryFileName(platform)) - - if (!(yield* fs.exists(binaryPath))) { - return yield* new BinaryNotFound({ - platform, - arch, - package: pkg, - message: `the npm platform package for ${platform}/${arch} (${pkg}) is not installed`, - }) - } - - return binaryPath + return path.join(path.dirname(pkgJsonPath), binaryFileName(platform)) }) const command = Command.make( diff --git a/scripts/deno.lock b/scripts/deno.lock index 42ce904..4da8e39 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -2,6 +2,7 @@ "version": "5", "specifiers": { "jsr:@std/assert@1.0.19": "1.0.19", + "jsr:@std/cli@1.0.15": "1.0.15", "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@1.1.6": "1.1.6" @@ -13,6 +14,9 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/cli@1.0.15": { + "integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f" + }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, diff --git a/scripts/release/args.ts b/scripts/release/args.ts new file mode 100644 index 0000000..bf015c6 --- /dev/null +++ b/scripts/release/args.ts @@ -0,0 +1,48 @@ +// Shared, dependency-free CLI flag parser for the release scripts. Each script +// used to duplicate this loop; one copy keeps missing-value / unknown-argument +// semantics identical across all three. +export interface FlagSpec { + /** flags that consume a value (--flag ) */ + string: string[] + /** flags that are pure booleans (presence = true) */ + boolean?: string[] + /** map canonical flag key -> returned key, e.g. binary-sha256 -> binarySha256 */ + rename?: Record +} + +export type ParsedArgs = Record + +// Parses a CLI argv. Unknown arguments and missing values exit(1) with the +// same messages the previous per-script parsers produced; tests never pin +// parser error text. +export function parseFlags(argv: string[], spec: FlagSpec): ParsedArgs { + const booleanFlags = spec.boolean ?? [] + const keyOf = (flag: string): string => spec.rename?.[flag] ?? flag + const out: ParsedArgs = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (!arg.startsWith('--')) { + console.error(`unknown argument: ${arg}`) + Deno.exit(1) + } + const body = arg.slice(2) + const eq = body.indexOf('=') + const name = eq === -1 ? body : body.slice(0, eq) + if (booleanFlags.includes(name) && eq === -1) { + out[keyOf(name)] = true + continue + } + if (spec.string.includes(name)) { + const value = eq === -1 ? argv[++i] : body.slice(eq + 1) + if (value === undefined) { + console.error(`missing value for --${name}`) + Deno.exit(1) + } + out[keyOf(name)] = value + continue + } + console.error(`unknown argument: ${arg}`) + Deno.exit(1) + } + return out +} diff --git a/scripts/release/check-matrix.ts b/scripts/release/check-matrix.ts index a9049a7..41b0d35 100755 --- a/scripts/release/check-matrix.ts +++ b/scripts/release/check-matrix.ts @@ -1,5 +1,6 @@ #!/usr/bin/env -S deno run --allow-read import { join, resolve } from '@std/path' +import { parseFlags } from './args.ts' const ROOT = join(import.meta.dirname!, '..', '..') const DEFAULT_TARGETS_PATH = join(ROOT, 'scripts', 'release', 'targets.json') @@ -15,33 +16,14 @@ const failures: string[] = [] const fail = (reason: string) => failures.push(reason) const note = (message: string) => console.error(`check-matrix: note: ${message}`) -// CLI: --targets overrides the packaged table (defaults to -// scripts/release/targets.json); --manifest-path overrides the launcher -// manifest (defaults to npm/packages/comment-checker/package.json). -let targetsPath = DEFAULT_TARGETS_PATH -let manifestPath = MANIFEST_PATH -const args = Deno.args -for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '--targets') { - const value = args[++i] - if (value === undefined) { - console.error('check-matrix: missing value for --targets') - Deno.exit(1) - } - targetsPath = resolve(value) - } else if (arg === '--manifest-path') { - const value = args[++i] - if (value === undefined) { - console.error('check-matrix: missing value for --manifest-path') - Deno.exit(1) - } - manifestPath = resolve(value) - } else { - console.error(`check-matrix: unknown argument: ${arg}`) - Deno.exit(1) - } -} +const flags = parseFlags(Deno.args, { + string: ['targets', 'manifest-path'], + rename: { 'manifest-path': 'manifestPath' }, +}) +const targetsArg = flags.targets +const manifestArg = flags.manifestPath +const targetsPath = typeof targetsArg === 'string' ? resolve(targetsArg) : DEFAULT_TARGETS_PATH +const manifestPath = typeof manifestArg === 'string' ? resolve(manifestArg) : MANIFEST_PATH let targets: Array< { target: string; suffix: string; os: string; cpu: string; libc?: string; bin: string } diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/release/generate-platform-manifest.ts index 4539e4a..3dbf549 100755 --- a/scripts/release/generate-platform-manifest.ts +++ b/scripts/release/generate-platform-manifest.ts @@ -1,5 +1,6 @@ #!/usr/bin/env -S deno run --allow-read --allow-write import { join } from '@std/path' +import { parseFlags } from './args.ts' const ROOT = join(import.meta.dirname!, '..', '..') @@ -19,49 +20,28 @@ const LAUNCHER: { name: string; repository: { type: string; url: string } } = JS await Deno.readTextFile(join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json')), ) -interface Options { - suffix?: string - version?: string - out?: string - binarySha256?: string - dryRun: boolean -} - // CLI: --suffix --version --out [--binary-sha256 ] [--dry-run] -const args = Deno.args -const opts: Options = { dryRun: false } -for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '--dry-run') { - opts.dryRun = true - } else if ( - arg === '--suffix' || arg === '--version' || arg === '--out' || arg === '--binary-sha256' - ) { - const value = args[++i] - if (value === undefined) { - console.error(`generate-platform-manifest: missing value for ${arg}`) - Deno.exit(1) - } - opts[ - arg === '--binary-sha256' ? 'binarySha256' : arg.slice(2) as 'suffix' | 'version' | 'out' - ] = value - } else { - console.error(`generate-platform-manifest: unknown argument: ${arg}`) - Deno.exit(1) - } -} +const args = parseFlags(Deno.args, { + string: ['suffix', 'version', 'out', 'binary-sha256'], + boolean: ['dry-run'], + rename: { 'binary-sha256': 'binarySha256', 'dry-run': 'dryRun' }, +}) for (const flag of ['suffix', 'version', 'out'] as const) { - if (opts[flag] === undefined) { + if (typeof args[flag] !== 'string') { console.error(`generate-platform-manifest: missing required --${flag}`) Deno.exit(1) } } +const suffix = args.suffix as string +const version = args.version as string +const out = args.out as string +const binarySha256 = args.binarySha256 -const entry = TARGETS.find((t) => t.suffix === opts.suffix) +const entry = TARGETS.find((t) => t.suffix === suffix) if (!entry) { console.error( - `generate-platform-manifest: unknown suffix "${opts.suffix}"; supported suffixes: ${ + `generate-platform-manifest: unknown suffix "${suffix}"; supported suffixes: ${ TARGETS.map((t) => t.suffix).join(', ') }`, ) @@ -70,7 +50,7 @@ if (!entry) { const pkg: Record = { name: `${LAUNCHER.name}-${entry.suffix}`, - version: opts.version, + version, description: `${LAUNCHER.name} ${entry.suffix} platform package`, license: 'Apache-2.0', repository: LAUNCHER.repository, @@ -83,15 +63,15 @@ const pkg: Record = { if (entry.libc !== undefined) { pkg.libc = [entry.libc] } -if (opts.binarySha256 !== undefined) { - pkg.binarySha256 = opts.binarySha256 +if (binarySha256 !== undefined) { + pkg.binarySha256 = binarySha256 as string } const output = JSON.stringify(pkg, null, 2) + '\n' -if (opts.dryRun) { +if (args.dryRun) { await Deno.stdout.write(new TextEncoder().encode(output)) } else { - await Deno.mkdir(opts.out!, { recursive: true }) - await Deno.writeTextFile(join(opts.out!, 'package.json'), output) + await Deno.mkdir(out, { recursive: true }) + await Deno.writeTextFile(join(out, 'package.json'), output) } diff --git a/scripts/release/sync-root-version.ts b/scripts/release/sync-root-version.ts index 47e9ab0..8ef40a5 100755 --- a/scripts/release/sync-root-version.ts +++ b/scripts/release/sync-root-version.ts @@ -1,5 +1,6 @@ #!/usr/bin/env -S deno run --allow-read --allow-write import { join, resolve } from '@std/path' +import { parseFlags } from './args.ts' const ROOT = join(import.meta.dirname!, '..', '..') const DEFAULT_MANIFEST_PATH = join(ROOT, 'npm', 'packages', 'comment-checker', 'package.json') @@ -19,25 +20,14 @@ if (!VERSION_RE.test(version) || version.includes('\n')) { // deps in a lockfile, so listing unpublished platform packages would break // `pnpm install --frozen-lockfile`); this script injects the five pins from // targets.json at publish time, when the platform packages already exist. -let manifestPath = DEFAULT_MANIFEST_PATH -const dryRun = Deno.args.includes('--dry-run') -const args = Deno.args -for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '--manifest-path') { - const value = args[++i] - if (value === undefined) { - console.error('sync-root-version: missing value for --manifest-path') - Deno.exit(1) - } - manifestPath = resolve(value) - } else if (arg === '--dry-run') { - // handled above - } else { - console.error(`sync-root-version: unknown argument: ${arg}`) - Deno.exit(1) - } -} +const flags = parseFlags(Deno.args, { + string: ['manifest-path'], + boolean: ['dry-run'], + rename: { 'dry-run': 'dryRun', 'manifest-path': 'manifestPath' }, +}) +const dryRun = flags.dryRun === true +const manifestArg = flags.manifestPath +const manifestPath = typeof manifestArg === 'string' ? resolve(manifestArg) : DEFAULT_MANIFEST_PATH const targets = JSON.parse(await Deno.readTextFile(TARGETS_PATH)) if (!Array.isArray(targets) || targets.length !== 5) { diff --git a/tests/npm-launcher/launcher.test.mjs b/tests/npm-launcher/launcher.test.mjs index 227fd87..4da09ba 100644 --- a/tests/npm-launcher/launcher.test.mjs +++ b/tests/npm-launcher/launcher.test.mjs @@ -15,7 +15,7 @@ const launcherPath = fileURLToPath( new URL("../../npm/packages/comment-checker/dist/index.mjs", import.meta.url) ) -const hostDep = `@systemfsoftware/claude-code-comment-checker-${process.platform}-${process.arch}` +const hostDep = optionalDepName(process.platform, process.arch) const skipNoPosixShim = process.platform === "win32" ? "cannot fabricate a .exe shim in a test fixture; the released binary covers win32" From e850ba69087c717966fc4b9564c3345d508079a7 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 04:38:08 +0000 Subject: [PATCH 08/10] style(release): format test suite per deno.jsonc (single quotes, no semicolons) --- tests/release/release-scripts.test.ts | 507 +++++++++++++------------- 1 file changed, 251 insertions(+), 256 deletions(-) diff --git a/tests/release/release-scripts.test.ts b/tests/release/release-scripts.test.ts index 2f247ee..1d140d9 100644 --- a/tests/release/release-scripts.test.ts +++ b/tests/release/release-scripts.test.ts @@ -1,44 +1,44 @@ -import { join } from "@std/path"; +import { join } from '@std/path' import { assertEquals, assertMatch, assertNotEquals, assertObjectMatch, assertRejects, -} from "@std/assert"; +} from '@std/assert' -const ROOT = join(import.meta.dirname!, "..", ".."); -const RELEASE = join(ROOT, "scripts", "release"); +const ROOT = join(import.meta.dirname!, '..', '..') +const RELEASE = join(ROOT, 'scripts', 'release') const MANIFEST_PATH = join( ROOT, - "npm", - "packages", - "comment-checker", - "package.json", -); -const TARGETS_PATH = join(RELEASE, "targets.json"); -const RELEASE_WORKFLOW_PATH = join(ROOT, ".github", "workflows", "release.yml"); + 'npm', + 'packages', + 'comment-checker', + 'package.json', +) +const TARGETS_PATH = join(RELEASE, 'targets.json') +const RELEASE_WORKFLOW_PATH = join(ROOT, '.github', 'workflows', 'release.yml') // The supported platform set (KD1) — the launcher resolves its platform package // by identity as --, so these five pairs are the // product contract the table must name exactly. const EXPECTED_SUFFIXES = [ - "linux-x64", - "linux-arm64", - "darwin-x64", - "darwin-arm64", - "win32-x64", -] as const; -const LAUNCHER_PREFIX = "@systemfsoftware/claude-code-comment-checker"; + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', +] as const +const LAUNCHER_PREFIX = '@systemfsoftware/claude-code-comment-checker' -const decode = (u8: Uint8Array) => new TextDecoder().decode(u8); +const decode = (u8: Uint8Array) => new TextDecoder().decode(u8) async function withTmp(fn: (dir: string) => Promise) { - const dir = await Deno.makeTempDir({ prefix: "release-scripts-" }); + const dir = await Deno.makeTempDir({ prefix: 'release-scripts-' }) try { - await fn(dir); + await fn(dir) } finally { - await Deno.remove(dir, { recursive: true }); + await Deno.remove(dir, { recursive: true }) } } @@ -48,19 +48,18 @@ async function withTmp(fn: (dir: string) => Promise) { function runScript( script: string, args: string[], - opts: { env?: Record; cwd?: string; writeAllow?: string } = - {}, + opts: { env?: Record; cwd?: string; writeAllow?: string } = {}, ): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> { - const { env = {}, cwd = ROOT, writeAllow = "unused" } = opts; - const callerPaths: string[] = []; + const { env = {}, cwd = ROOT, writeAllow = 'unused' } = opts + const callerPaths: string[] = [] for (let i = 0; i < args.length; i++) { - if (args[i] === "--suffix" || args[i] === "--version") { - i++; + if (args[i] === '--suffix' || args[i] === '--version') { + i++ } else if ( - args[i] === "--out" || args[i] === "--manifest-path" || - args[i] === "--targets" + args[i] === '--out' || args[i] === '--manifest-path' || + args[i] === '--targets' ) { - callerPaths.push(args[++i]); + callerPaths.push(args[++i]) } } const read = [ @@ -68,10 +67,10 @@ function runScript( MANIFEST_PATH, RELEASE_WORKFLOW_PATH, ...callerPaths, - ].join(","); - const cmd = new Deno.Command("deno", { + ].join(',') + const cmd = new Deno.Command('deno', { args: [ - "run", + 'run', `--allow-read=${read}`, `--allow-write=${writeAllow}`, `--allow-env`, @@ -80,291 +79,287 @@ function runScript( ], cwd, env: { ...Deno.env.toObject(), ...env }, - stdout: "piped", - stderr: "piped", - }); + stdout: 'piped', + stderr: 'piped', + }) return cmd.output().then((r) => ({ code: r.code, stdout: r.stdout, stderr: r.stderr, - })); + })) } const readTable = async () => JSON.parse(await Deno.readTextFile(TARGETS_PATH)) as Array<{ - target: string; - suffix: string; - os: string; - cpu: string; - libc?: string; - bin: string; - }>; + target: string + suffix: string + os: string + cpu: string + libc?: string + bin: string + }> -Deno.test("targets.json names exactly the five supported platform/arch pairs", async () => { - const table = await readTable(); - const suffixes = table.map((t) => t.suffix).sort(); - assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()); -}); +Deno.test('targets.json names exactly the five supported platform/arch pairs', async () => { + const table = await readTable() + const suffixes = table.map((t) => t.suffix).sort() + assertEquals(suffixes, [...EXPECTED_SUFFIXES].sort()) +}) -Deno.test("generate-platform-manifest emits the correct package.json for each targets.json entry", async () => { +Deno.test('generate-platform-manifest emits the correct package.json for each targets.json entry', async () => { await withTmp(async (tmp) => { - const table = await readTable(); - const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)); + const table = await readTable() + const launcher = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) const cases = table.map(async (entry) => { - const out = join(tmp, `gen-${entry.suffix}`); - const res = await runScript("generate-platform-manifest.ts", [ - "--suffix", + const out = join(tmp, `gen-${entry.suffix}`) + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', entry.suffix, - "--version", - "0.1.0", - "--out", + '--version', + '0.1.0', + '--out', out, - ], { writeAllow: out }); - assertEquals(res.code, 0, decode(res.stderr)); + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) const pkg = JSON.parse( - await Deno.readTextFile(join(out, "package.json")), - ); - assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`); - assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`); - assertEquals(pkg.version, "0.1.0"); - assertEquals(pkg.license, "Apache-2.0"); - assertObjectMatch(pkg.repository, launcher.repository); - assertEquals(pkg.os, [entry.os]); - assertEquals(pkg.cpu, [entry.cpu]); - assertEquals(pkg.files, [entry.bin]); - assertEquals(pkg.bin, { "comment-checker": `./${entry.bin}` }); - assertEquals(pkg.publishConfig, { access: "public", provenance: true }); + await Deno.readTextFile(join(out, 'package.json')), + ) + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-${entry.suffix}`) + assertEquals(pkg.name, `${launcher.name}-${entry.suffix}`) + assertEquals(pkg.version, '0.1.0') + assertEquals(pkg.license, 'Apache-2.0') + assertObjectMatch(pkg.repository, launcher.repository) + assertEquals(pkg.os, [entry.os]) + assertEquals(pkg.cpu, [entry.cpu]) + assertEquals(pkg.files, [entry.bin]) + assertEquals(pkg.bin, { 'comment-checker': `./${entry.bin}` }) + assertEquals(pkg.publishConfig, { access: 'public', provenance: true }) if (entry.libc !== undefined) { - assertEquals(pkg.libc, [entry.libc]); + assertEquals(pkg.libc, [entry.libc]) } else { - assertEquals(pkg.libc, undefined); + assertEquals(pkg.libc, undefined) } assertEquals( pkg.binarySha256, undefined, - "no binarySha256 without the flag", - ); - }); - await Promise.all(cases); - }); -}); + 'no binarySha256 without the flag', + ) + }) + await Promise.all(cases) + }) +}) -Deno.test("generate-platform-manifest records binarySha256 when passed", async () => { +Deno.test('generate-platform-manifest records binarySha256 when passed', async () => { await withTmp(async (tmp) => { - const out = join(tmp, "gen-sha"); - const res = await runScript("generate-platform-manifest.ts", [ - "--suffix", - "linux-x64", - "--version", - "0.1.0", - "--binary-sha256", - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "--out", + const out = join(tmp, 'gen-sha') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-x64', + '--version', + '0.1.0', + '--binary-sha256', + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + '--out', out, - ], { writeAllow: out }); - assertEquals(res.code, 0, decode(res.stderr)); - const pkg = JSON.parse(await Deno.readTextFile(join(out, "package.json"))); + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + const pkg = JSON.parse(await Deno.readTextFile(join(out, 'package.json'))) assertEquals( pkg.binarySha256, - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ); - }); -}); + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ) + }) +}) -Deno.test("generate-platform-manifest --dry-run leaves the filesystem untouched", async () => { +Deno.test('generate-platform-manifest --dry-run leaves the filesystem untouched', async () => { await withTmp(async (tmp) => { - const out = join(tmp, "dry-run-out"); - const res = await runScript("generate-platform-manifest.ts", [ - "--suffix", - "linux-x64", - "--version", - "0.1.0", - "--out", + const out = join(tmp, 'dry-run-out') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-x64', + '--version', + '0.1.0', + '--out', out, - "--dry-run", - ]); - assertEquals(res.code, 0, decode(res.stderr)); - await assertRejects(() => Deno.stat(out)); - const stdout = decode(res.stdout); + '--dry-run', + ]) + assertEquals(res.code, 0, decode(res.stderr)) + await assertRejects(() => Deno.stat(out)) + const stdout = decode(res.stdout) assertMatch( stdout, /"name": "@systemfsoftware\/claude-code-comment-checker-linux-x64"/, - ); - assertMatch(stdout, /"version": "0.1.0"/); - }); -}); + ) + assertMatch(stdout, /"version": "0.1.0"/) + }) +}) -Deno.test("generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes", async () => { +Deno.test('generate-platform-manifest: unknown suffix exits non-zero and lists the supported suffixes', async () => { await withTmp(async (tmp) => { - const out = join(tmp, "gen-unknown"); - const res = await runScript("generate-platform-manifest.ts", [ - "--suffix", - "linux-mips", - "--version", - "0.1.0", - "--out", + const out = join(tmp, 'gen-unknown') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix', + 'linux-mips', + '--version', + '0.1.0', + '--out', out, - ], { writeAllow: out }); - assertNotEquals(res.code, 0); - const stderr = decode(res.stderr); + ], { writeAllow: out }) + assertNotEquals(res.code, 0) + const stderr = decode(res.stderr) for (const suffix of EXPECTED_SUFFIXES) { - assertMatch(stderr, new RegExp(suffix)); + assertMatch(stderr, new RegExp(suffix)) } - await assertRejects(() => Deno.stat(join(out, "package.json"))); - }); -}); + await assertRejects(() => Deno.stat(join(out, 'package.json'))) + }) +}) -Deno.test("sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path", async () => { +Deno.test('sync-root-version: VERSION=0.1.0 writes version and optionalDependencies via --manifest-path', async () => { await withTmp(async (tmp) => { - const copy = join(tmp, "manifest-copy.json"); - await Deno.copyFile(MANIFEST_PATH, copy); + const copy = join(tmp, 'manifest-copy.json') + await Deno.copyFile(MANIFEST_PATH, copy) - const res = await runScript("sync-root-version.ts", [ - "--manifest-path", + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', copy, ], { - env: { VERSION: "0.1.0" }, + env: { VERSION: '0.1.0' }, writeAllow: copy, - }); - assertEquals(res.code, 0, decode(res.stderr)); + }) + assertEquals(res.code, 0, decode(res.stderr)) - const manifest = JSON.parse(await Deno.readTextFile(copy)); - assertEquals(manifest.version, "0.1.0"); + const manifest = JSON.parse(await Deno.readTextFile(copy)) + assertEquals(manifest.version, '0.1.0') assertEquals( Object.keys(manifest.optionalDependencies).sort(), [...EXPECTED_SUFFIXES].sort().map((s) => `${LAUNCHER_PREFIX}-${s}`), - ); + ) for (const value of Object.values(manifest.optionalDependencies)) { - assertEquals(value, "0.1.0"); + assertEquals(value, '0.1.0') } // The committed manifest is not touched by the release-time sync. - const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)); - assertEquals(committed.optionalDependencies, undefined); - }); -}); + const committed = JSON.parse(await Deno.readTextFile(MANIFEST_PATH)) + assertEquals(committed.optionalDependencies, undefined) + }) +}) -Deno.test("sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs", async () => { +Deno.test('sync-root-version: VERSION=0.2.0 dry-run shows only version-field diffs', async () => { await withTmp(async (tmp) => { - const copy = join(tmp, "manifest-copy2.json"); - await Deno.copyFile(MANIFEST_PATH, copy); + const copy = join(tmp, 'manifest-copy2.json') + await Deno.copyFile(MANIFEST_PATH, copy) // Seed the copy at 0.1.0 so the dry-run shift is a clean version bump. - await runScript("sync-root-version.ts", ["--manifest-path", copy], { - env: { VERSION: "0.1.0" }, + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, writeAllow: copy, - }); - const before = await Deno.readTextFile(copy); + }) + const before = await Deno.readTextFile(copy) - const res = await runScript("sync-root-version.ts", [ - "--manifest-path", + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', copy, - "--dry-run", + '--dry-run', ], { - env: { VERSION: "0.2.0" }, + env: { VERSION: '0.2.0' }, writeAllow: copy, - }); - assertEquals(res.code, 0, decode(res.stderr)); - assertEquals(await Deno.readTextFile(copy), before, "dry-run never writes"); + }) + assertEquals(res.code, 0, decode(res.stderr)) + assertEquals(await Deno.readTextFile(copy), before, 'dry-run never writes') - const stdout = decode(res.stdout); - const minus = stdout.split("\n").filter((line) => line.startsWith("- ")); - const plus = stdout.split("\n").filter((line) => line.startsWith("+ ")); - const manifest = JSON.parse(before); + const stdout = decode(res.stdout) + const minus = stdout.split('\n').filter((line) => line.startsWith('- ')) + const plus = stdout.split('\n').filter((line) => line.startsWith('+ ')) + const manifest = JSON.parse(before) const changedLineCount = Object.keys(manifest.optionalDependencies).length + - 1; // version + five deps - assertEquals(minus.length, changedLineCount); - assertEquals(plus.length, changedLineCount); - assertMatch(minus[0], /- {3}"version": "0.1.0",/); - assertMatch(plus[0], /\+ {3}"version": "0.2.0",/); + 1 // version + five deps + assertEquals(minus.length, changedLineCount) + assertEquals(plus.length, changedLineCount) + assertMatch(minus[0], /- {3}"version": "0.1.0",/) + assertMatch(plus[0], /\+ {3}"version": "0.2.0",/) for (let i = 0; i < minus.length; i++) { - const minusLine = minus[i].replace(/^- /, ""); - const plusLine = plus[i].replace(/^\+ /, ""); + const minusLine = minus[i].replace(/^- /, '') + const plusLine = plus[i].replace(/^\+ /, '') assertEquals( plusLine, minusLine.replace('"0.1.0"', '"0.2.0"'), - `each diff pair changes only the version value (line ${i}: ${ - minus[i] - })`, - ); + `each diff pair changes only the version value (line ${i}: ${minus[i]})`, + ) } - }); -}); + }) +}) -Deno.test("sync-root-version: malformed VERSION exits non-zero and writes nothing", async () => { +Deno.test('sync-root-version: malformed VERSION exits non-zero and writes nothing', async () => { const malformed = [ - "v0.1.0", + 'v0.1.0', 'v0.1.0"', - "0.1.0\n--provenance=false", - "not-a-version", - ]; + '0.1.0\n--provenance=false', + 'not-a-version', + ] const cases = malformed.map((version) => withTmp(async (tmp) => { - const copy = join(tmp, "manifest-malformed.json"); - await Deno.copyFile(MANIFEST_PATH, copy); - const before = await Deno.readTextFile(copy); - const res = await runScript("sync-root-version.ts", [ - "--manifest-path", + const copy = join(tmp, 'manifest-malformed.json') + await Deno.copyFile(MANIFEST_PATH, copy) + const before = await Deno.readTextFile(copy) + const res = await runScript('sync-root-version.ts', [ + '--manifest-path', copy, ], { env: { VERSION: version }, writeAllow: copy, - }); + }) assertNotEquals( res.code, 0, `VERSION=${JSON.stringify(version)} must fail`, - ); - const stderr = decode(res.stderr); + ) + const stderr = decode(res.stderr) assertMatch( stderr, /invalid VERSION/, - `stderr reports the invalid value for ${ - JSON.stringify(version) - }: ${stderr}`, - ); + `stderr reports the invalid value for ${JSON.stringify(version)}: ${stderr}`, + ) assertEquals( await Deno.readTextFile(copy), before, `no write for VERSION=${JSON.stringify(version)}`, - ); + ) }) - ); - await Promise.all(cases); -}); + ) + await Promise.all(cases) +}) -Deno.test("check-matrix: passes on the packaged table and pre-publish launcher manifest", async () => { - const res = await runScript("check-matrix.ts", []); - assertEquals(res.code, 0, decode(res.stderr)); - const stderr: string = decode(res.stderr); +Deno.test('check-matrix: passes on the packaged table and pre-publish launcher manifest', async () => { + const res = await runScript('check-matrix.ts', []) + assertEquals(res.code, 0, decode(res.stderr)) + const stderr: string = decode(res.stderr) assertMatch( stderr, /no optionalDependencies \(pre-publish\)/, - "absence of pins is noted, not failed", - ); -}); + 'absence of pins is noted, not failed', + ) +}) -Deno.test("check-matrix: fails on a manifest with wrong pin", async () => { +Deno.test('check-matrix: fails on a manifest with wrong pin', async () => { await withTmp(async (tmp) => { - const copy = join(tmp, "manifest-wrongpin.json"); - await Deno.copyFile(MANIFEST_PATH, copy); - await runScript("sync-root-version.ts", ["--manifest-path", copy], { - env: { VERSION: "0.1.0" }, + const copy = join(tmp, 'manifest-wrongpin.json') + await Deno.copyFile(MANIFEST_PATH, copy) + await runScript('sync-root-version.ts', ['--manifest-path', copy], { + env: { VERSION: '0.1.0' }, writeAllow: copy, - }); + }) // Corrupt one pin to a different exact version after the sync. - const manifest = JSON.parse(await Deno.readTextFile(copy)); - manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = "0.2.0"; - await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + "\n"); + const manifest = JSON.parse(await Deno.readTextFile(copy)) + manifest.optionalDependencies[`${LAUNCHER_PREFIX}-linux-x64`] = '0.2.0' + await Deno.writeTextFile(copy, JSON.stringify(manifest, null, 2) + '\n') - const res = await runScript("check-matrix.ts", ["--manifest-path", copy]); - assertNotEquals(res.code, 0); - assertMatch(decode(res.stderr), /FAIL/, "stderr names the failure"); - }); -}); + const res = await runScript('check-matrix.ts', ['--manifest-path', copy]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/, 'stderr names the failure') + }) +}) -Deno.test("check-matrix: fails on a reversed os/cpu identity violation", async () => { +Deno.test('check-matrix: fails on a reversed os/cpu identity violation', async () => { await withTmp(async (tmp) => { - const table = await readTable(); + const table = await readTable() const reversed = table.map(({ target, suffix, os, cpu, libc, bin }) => ({ target, suffix, @@ -372,62 +367,62 @@ Deno.test("check-matrix: fails on a reversed os/cpu identity violation", async ( cpu: os, bin, ...(libc !== undefined ? { libc } : {}), - })); - const targetsPath = join(tmp, "targets.json"); + })) + const targetsPath = join(tmp, 'targets.json') await Deno.writeTextFile( targetsPath, - JSON.stringify(reversed, null, 2) + "\n", - ); - const res = await runScript("check-matrix.ts", ["--targets", targetsPath]); - assertNotEquals(res.code, 0); - assertMatch(decode(res.stderr), /FAIL/); - }); -}); + JSON.stringify(reversed, null, 2) + '\n', + ) + const res = await runScript('check-matrix.ts', ['--targets', targetsPath]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + }) +}) -Deno.test("check-matrix: fails on a missing or extra table target", async () => { +Deno.test('check-matrix: fails on a missing or extra table target', async () => { await withTmp(async (tmp) => { - const table = await readTable(); + const table = await readTable() - const missingPath = join(tmp, "missing.json"); + const missingPath = join(tmp, 'missing.json') await Deno.writeTextFile( missingPath, JSON.stringify( - table.filter((entry) => entry.target !== "x86_64-apple-darwin"), + table.filter((entry) => entry.target !== 'x86_64-apple-darwin'), null, 2, ) + - "\n", - ); - const resMissing = await runScript("check-matrix.ts", [ - "--targets", + '\n', + ) + const resMissing = await runScript('check-matrix.ts', [ + '--targets', missingPath, - ]); - assertNotEquals(resMissing.code, 0); - assertMatch(decode(resMissing.stderr), /FAIL/); + ]) + assertNotEquals(resMissing.code, 0) + assertMatch(decode(resMissing.stderr), /FAIL/) - const extraPath = join(tmp, "extra.json"); + const extraPath = join(tmp, 'extra.json') await Deno.writeTextFile( extraPath, JSON.stringify( [ ...table, { - target: "x86_64-unknown-freebsd", - suffix: "freebsd-x64", - os: "freebsd", - cpu: "x64", - bin: "comment-checker", + target: 'x86_64-unknown-freebsd', + suffix: 'freebsd-x64', + os: 'freebsd', + cpu: 'x64', + bin: 'comment-checker', }, ], null, 2, - ) + "\n", - ); - const resExtra = await runScript("check-matrix.ts", [ - "--targets", + ) + '\n', + ) + const resExtra = await runScript('check-matrix.ts', [ + '--targets', extraPath, - ]); - assertNotEquals(resExtra.code, 0); - assertMatch(decode(resExtra.stderr), /FAIL/); - }); -}); + ]) + assertNotEquals(resExtra.code, 0) + assertMatch(decode(resExtra.stderr), /FAIL/) + }) +}) From 23dedfd5abbbdfd35c8e883252b4055235b55654 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 05:00:47 +0000 Subject: [PATCH 09/10] fix(release): apply review findings F1-F10 Review 20260817-043848-ac825c53 found 3 P0 pipeline blockers plus silent-pass gates; all fixed: - P0 matrix: linux-arm64 builds on ubuntu-24.04-arm (native aarch64); cross-linker now only for x64 runners - P0 registry gate: jq deep-equality now shape-normalizes both sides (libc array vs bare string, absent vs null) and no longer feeds an --argjson object to fromjson; Dar'in/windows rows verified - P0 sync-root-version needs --allow-env (env VERSION): workflow step and deno.jsonc task both declare it - P1 portable sha256_of helper (sha256sum first, shasum fallback) in stage and cross-check steps - P1 npm pack retry (5x/5s) after publish read-after-write race - P1 platform packages no longer declare a bin (esbuild precedent); test asserts pkg.bin === undefined - P1 ci.yml npm job gates lint, build, typecheck, node + deno suites, and check-matrix on push/PR - P2 check-matrix --workflow-path + tests for missing/extra/swapped matrix rows - P2 parseFlags rejects flag-shaped/missing values; eq-form covered; shebang + exec bit pinned on dist/index.mjs - P3 comment fix (platforms.json -> targets.json) Gates: deno task lint/test (23/23), check-matrix ok, pnpm lint/build/ typecheck, node suite 16/16, frozen install ok. --- .github/workflows/ci.yml | 34 ++++ .github/workflows/release.yml | 59 ++++++- scripts/deno.jsonc | 2 +- scripts/release/args.ts | 13 +- scripts/release/check-matrix.ts | 53 +++++-- scripts/release/generate-platform-manifest.ts | 4 +- tests/npm-launcher/launcher.test.mjs | 11 +- tests/npm-launcher/manifest.test.mjs | 2 +- tests/release/release-scripts.test.ts | 148 +++++++++++++++++- 9 files changed, 294 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60f5fe1..1f7029b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,40 @@ jobs: - name: Test (unit + property + composition + F1) run: cargo test --all-targets + npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.21.0 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - uses: denoland/setup-deno@v2 + + - name: Install JS deps (frozen) + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Build + typecheck (launcher) + run: pnpm -r build && pnpm -r typecheck + + - name: Launcher black-box suite (node) + run: node --test "tests/npm-launcher/*.test.mjs" + + - name: Release scripts suite + matrix agreement gate (deno) + run: | + cd scripts + deno task lint + deno task test + deno task check-matrix + mutation: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ec8340..06c7b94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: runner: ubuntu-latest - target: aarch64-unknown-linux-gnu suffix: linux-arm64 - runner: ubuntu-latest + runner: ubuntu-24.04-arm - target: x86_64-apple-darwin suffix: darwin-x64 runner: macos-13 @@ -73,7 +73,9 @@ jobs: targets: ${{ matrix.target }} - name: Linux arm64 cross-linker - if: matrix.target == 'aarch64-unknown-linux-gnu' + # Only needed when building aarch64 from an x64 runner. The native + # ubuntu-24.04-arm runner builds aarch64 without a cross toolchain. + if: matrix.target == 'aarch64-unknown-linux-gnu' && matrix.runner == 'ubuntu-latest' shell: bash run: | sudo apt-get update @@ -135,11 +137,20 @@ jobs: - name: Stage platform package (outside workspace) + record binarySha256 shell: bash run: | + # Portable sha256: sha256sum (Linux/Windows Git Bash), else shasum + # (macOS). Must agree with the cross-check step's digest. + sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi + } STAGE="$RUNNER_TEMP/platform-${{ matrix.suffix }}" mkdir -p "$STAGE" BIN="target/${{ matrix.target }}/release/${BINARY_NAME}" cp "$BIN" "$STAGE/${BINARY_NAME}" - SHA="$(shasum -a 256 "$STAGE/${BINARY_NAME}" | cut -d' ' -f1)" + SHA="$(sha256_of "$STAGE/${BINARY_NAME}")" deno run \ --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ --allow-write="$STAGE" \ @@ -227,6 +238,12 @@ jobs: - name: "Gate: every platform package published with table os/cpu/libc" shell: bash run: | + # Shape-normalize BOTH sides: npm view reports libc as an array + # ("libc":["glibc"]) while the table stores a bare string, and + # darwin/win32 rows have no libc at all. Comparing through the same + # normalization (libc as array, absent when null) makes the deep + # equality meaningful instead of always-true or always-false. + libc_norm='{os, cpu} + (if (.libc // null) != null then {libc} else {} end)' SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } for SUFFIX in $SUFFIXES; do @@ -235,8 +252,9 @@ jobs: echo "platform package $PKG@$VERSION missing" >&2 exit 1 } - EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu], libc: (.libc // null)}' scripts/release/targets.json)" - echo "$META" | jq -e -c --arg v "$VERSION" '.version == $v and ({os, cpu, libc} == ($expect | fromjson))' --argjson expect "$EXPECTED" >/dev/null || { + EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu]} + (if (.libc // null) != null then {libc: [.libc]} else {} end)' scripts/release/targets.json)" + echo "$META" | jq -e -c --arg v "$VERSION" --argjson want "$EXPECTED" \ + '.version == $v and ('"$libc_norm"') == $want' >/dev/null || { echo "$PKG@$VERSION mismatch: $(echo "$META" | jq -c '{version, os, cpu, libc}') want $EXPECTED" >&2 exit 1 } @@ -246,17 +264,40 @@ jobs: - name: Cross-check published tarballs vs recorded binary sha shell: bash run: | + # npm registry read-after-write is eventually consistent: the + # platform publish finished only seconds ago, so a first `npm pack` + # may still 404. Retry a bounded number of times before failing. + sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi + } SUFFIXES="$(jq -r '.[].suffix' scripts/release/targets.json)" test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } for SUFFIX in $SUFFIXES; do SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")" PKG="@systemfsoftware/claude-code-comment-checker-${SUFFIX}" - TARBALL="$(npm pack "$PKG@${VERSION}" --pack-destination "$RUNNER_TEMP" 2>/dev/null | tail -1)" + TARBALL="" + for attempt in 1 2 3 4 5; do + TARBALL="$(npm pack "$PKG@${VERSION}" --pack-destination "$RUNNER_TEMP" 2>/dev/null | tail -1)" + if [ -n "$TARBALL" ] && [ -f "$RUNNER_TEMP/$TARBALL" ]; then + break + fi + echo "npm pack $PKG@$VERSION attempt $attempt/5 failed; retrying" >&2 + TARBALL="" + sleep 5 + done + if [ -z "$TARBALL" ]; then + echo "$PKG@$VERSION not retrievable from registry after 5 attempts" >&2 + exit 1 + fi rm -rf "$RUNNER_TEMP/unpack" mkdir -p "$RUNNER_TEMP/unpack" tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP/unpack" BIN="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | head -1)" - SHA_PUBLISHED="$(sha256sum "$BIN" | cut -d' ' -f1)" + SHA_PUBLISHED="$(sha256_of "$BIN")" if [ "$SHA_RECORDED" != "$SHA_PUBLISHED" ]; then echo "binary sha mismatch for $SUFFIX: recorded $SHA_RECORDED, got $SHA_PUBLISHED" >&2 exit 1 @@ -272,7 +313,11 @@ jobs: - name: Sync root version + optionalDependencies from tag shell: bash run: | + # --allow-env required: VERSION arrives via the environment (KTD5: + # the git tag is the single version source). The deno.jsonc + # manifest:sync-root task declares the identical permission set. VERSION="$VERSION" deno run \ + --allow-env \ --allow-read=scripts/release/targets.json,npm/packages/comment-checker/package.json \ --allow-write=npm/packages/comment-checker/package.json \ scripts/release/sync-root-version.ts diff --git a/scripts/deno.jsonc b/scripts/deno.jsonc index 31ef1c1..e3dbd2d 100644 --- a/scripts/deno.jsonc +++ b/scripts/deno.jsonc @@ -5,7 +5,7 @@ }, "tasks": { "manifest:generate": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json release/generate-platform-manifest.ts", - "manifest:sync-root": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json --allow-write=../npm/packages/comment-checker/package.json release/sync-root-version.ts", + "manifest:sync-root": "deno run --allow-env --allow-read=release/targets.json,../npm/packages/comment-checker/package.json --allow-write=../npm/packages/comment-checker/package.json release/sync-root-version.ts", "check-matrix": "deno run --allow-read=release/targets.json,../npm/packages/comment-checker/package.json,../.github/workflows/release.yml release/check-matrix.ts", "test": "deno test --allow-read --allow-write --allow-env --allow-run=deno ../tests/release", "lint": "deno lint --config ./deno.jsonc . ../tests/release" diff --git a/scripts/release/args.ts b/scripts/release/args.ts index bf015c6..47fd921 100644 --- a/scripts/release/args.ts +++ b/scripts/release/args.ts @@ -33,12 +33,21 @@ export function parseFlags(argv: string[], spec: FlagSpec): ParsedArgs { continue } if (spec.string.includes(name)) { - const value = eq === -1 ? argv[++i] : body.slice(eq + 1) - if (value === undefined) { + if (eq !== -1) { + out[keyOf(name)] = body.slice(eq + 1) + continue + } + const value = argv[i + 1] + // A consumed value that itself looks like a flag is a missing value for + // the current flag — `--version --dry-run` must not silently take + // `--dry-run` as the version. The `--flag=value` form still admits + // values starting with `-`. + if (value === undefined || value.startsWith('--')) { console.error(`missing value for --${name}`) Deno.exit(1) } out[keyOf(name)] = value + i++ continue } console.error(`unknown argument: ${arg}`) diff --git a/scripts/release/check-matrix.ts b/scripts/release/check-matrix.ts index 41b0d35..2b74201 100755 --- a/scripts/release/check-matrix.ts +++ b/scripts/release/check-matrix.ts @@ -17,13 +17,15 @@ const fail = (reason: string) => failures.push(reason) const note = (message: string) => console.error(`check-matrix: note: ${message}`) const flags = parseFlags(Deno.args, { - string: ['targets', 'manifest-path'], - rename: { 'manifest-path': 'manifestPath' }, + string: ['targets', 'manifest-path', 'workflow-path'], + rename: { 'manifest-path': 'manifestPath', 'workflow-path': 'workflowPath' }, }) const targetsArg = flags.targets const manifestArg = flags.manifestPath +const workflowArg = flags.workflowPath const targetsPath = typeof targetsArg === 'string' ? resolve(targetsArg) : DEFAULT_TARGETS_PATH const manifestPath = typeof manifestArg === 'string' ? resolve(manifestArg) : MANIFEST_PATH +const workflowPath = typeof workflowArg === 'string' ? resolve(workflowArg) : RELEASE_WORKFLOW_PATH let targets: Array< { target: string; suffix: string; os: string; cpu: string; libc?: string; bin: string } @@ -136,28 +138,45 @@ if (declaredNames.length === 0) { // name exactly the triples in the table, and vice versa. Absent workflow is // a skip (with a note), not a failure. try { - await Deno.lstat(RELEASE_WORKFLOW_PATH) - const content = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) - // Only literal matrix include rows (target: ) — env-var names like - // CC_aarch64_unknown_linux_gnu or gcc-aarch64-linux-gnu are not rows. - const workflowTargets = new Set( - [...content.matchAll(/^\s*-\s*target:\s*((?:x86_64|aarch64)-[a-z0-9-]+)\s*$/gm)].map((m) => - m[1] - ), + await Deno.lstat(workflowPath) + const content = await Deno.readTextFile(workflowPath) + // Rows carry their suffix one line below the target (matrix:include order); + // capture target->suffix pairs from the workflow. + const workflowPairs = new Map( + [...content.matchAll( + /^\s*-\s*target:\s*((?:x86_64|aarch64)-[a-z0-9-]+)\s*\n\s*suffix:\s*([a-z0-9-]+)\s*$/gm, + )].map((m) => [m[1], m[2]]), ) - const tableTargets = new Set(targets.map((t) => t.target)) - for (const target of tableTargets) { - if (!workflowTargets.has(target)) { + const tablePairs = new Map(targets.map((t) => [t.target, t.suffix])) + for (const [target, suffix] of tablePairs) { + if (!workflowPairs.has(target)) { fail(`release.yml does not list release target ${target}`) + } else if (workflowPairs.get(target) !== suffix) { + fail( + `release.yml lists ${target} with suffix ${workflowPairs.get(target)}, ` + + `table says ${suffix}`, + ) } } - for (const target of workflowTargets) { - if (!tableTargets.has(target)) { + for (const [target, suffix] of workflowPairs) { + if (!tablePairs.has(target)) { fail(`release.yml lists ${target}, which is not a row in targets.json`) + } else if (suffix !== tablePairs.get(target)) { + fail( + `release.yml lists ${target} with suffix ${suffix}, table says ${ + tablePairs.get( + target, + ) + }`, + ) } } -} catch { - note(`skipped: ${RELEASE_WORKFLOW_PATH} not found (workflow agreement not checked)`) +} catch (error) { + if (error instanceof Deno.errors.NotFound) { + note(`skipped: ${workflowPath} not found (workflow agreement not checked)`) + } else { + fail(`cannot check workflow ${workflowPath}: ${String(error)}`) + } } if (failures.length > 0) { diff --git a/scripts/release/generate-platform-manifest.ts b/scripts/release/generate-platform-manifest.ts index 3dbf549..326957e 100755 --- a/scripts/release/generate-platform-manifest.ts +++ b/scripts/release/generate-platform-manifest.ts @@ -57,7 +57,9 @@ const pkg: Record = { os: [entry.os], cpu: [entry.cpu], files: [entry.bin], - bin: { 'comment-checker': `./${entry.bin}` }, + // No `bin` field on platform packages (esbuild precedent): installing one + // would create a top-level `comment-checker` shim that collides with the + // launcher's own bin of the same name. publishConfig: { access: 'public', provenance: true }, } if (entry.libc !== undefined) { diff --git a/tests/npm-launcher/launcher.test.mjs b/tests/npm-launcher/launcher.test.mjs index 4da09ba..b8eccbf 100644 --- a/tests/npm-launcher/launcher.test.mjs +++ b/tests/npm-launcher/launcher.test.mjs @@ -2,7 +2,7 @@ // `pnpm -r build`: the launcher entry and the extracted platform module are // imported from the built dist/ output below. import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { fileURLToPath } from "node:url" @@ -15,6 +15,15 @@ const launcherPath = fileURLToPath( new URL("../../npm/packages/comment-checker/dist/index.mjs", import.meta.url) ) +// The published `bin` contract is the shebang + exec bit on dist/index.mjs; +// without them npm's POSIX bin shim fails with ENOEXEC for every consumer. +test("built launcher carries the node shebang and is executable", () => { + const head = readFileSync(launcherPath, "utf8").split("\n", 1)[0] + assert.equal(head, "#!/usr/bin/env node") + const { mode } = statSync(launcherPath) + assert.notEqual(mode & 0o111, 0, "dist/index.mjs must be executable") +}) + const hostDep = optionalDepName(process.platform, process.arch) const skipNoPosixShim = process.platform === "win32" diff --git a/tests/npm-launcher/manifest.test.mjs b/tests/npm-launcher/manifest.test.mjs index 129baa1..bf86cd3 100644 --- a/tests/npm-launcher/manifest.test.mjs +++ b/tests/npm-launcher/manifest.test.mjs @@ -21,7 +21,7 @@ test("launcher manifest ships only the built launcher", () => { test("committed manifest carries no optionalDependencies (pre-publish)", () => { // pnpm cannot record unresolvable optional deps in a lockfile, so the // committed manifest must not name the unpublished platform packages; - // sync-root-version.ts injects the platforms.json pins at publish time. + // sync-root-version.ts injects the targets.json pins at publish time. assert.equal(manifest.optionalDependencies, undefined) assert.equal(manifest.version, "0.1.0") }) diff --git a/tests/release/release-scripts.test.ts b/tests/release/release-scripts.test.ts index 1d140d9..380160e 100644 --- a/tests/release/release-scripts.test.ts +++ b/tests/release/release-scripts.test.ts @@ -1,6 +1,7 @@ import { join } from '@std/path' import { assertEquals, + assertFalse, assertMatch, assertNotEquals, assertObjectMatch, @@ -57,7 +58,7 @@ function runScript( i++ } else if ( args[i] === '--out' || args[i] === '--manifest-path' || - args[i] === '--targets' + args[i] === '--targets' || args[i] === '--workflow-path' ) { callerPaths.push(args[++i]) } @@ -132,7 +133,11 @@ Deno.test('generate-platform-manifest emits the correct package.json for each ta assertEquals(pkg.os, [entry.os]) assertEquals(pkg.cpu, [entry.cpu]) assertEquals(pkg.files, [entry.bin]) - assertEquals(pkg.bin, { 'comment-checker': `./${entry.bin}` }) + assertEquals( + pkg.bin, + undefined, + 'platform packages must not declare a bin (would collide with the launcher shim)', + ) assertEquals(pkg.publishConfig, { access: 'public', provenance: true }) if (entry.libc !== undefined) { assertEquals(pkg.libc, [entry.libc]) @@ -426,3 +431,142 @@ Deno.test('check-matrix: fails on a missing or extra table target', async () => assertMatch(decode(resExtra.stderr), /FAIL/) }) }) + +// --- Workflow-agreement failure arms (F9): every gate fails loudly; a green +// check-matrix must imply table <-> workflow agreement in BOTH directions, +// including target/suffix pairing (a swapped suffix must not pass). + +const WORKFLOW_ROW = (target: string, suffix: string) => + ` - target: ${target}\n suffix: ${suffix}\n` + +Deno.test('check-matrix: fails when the workflow omits a table target', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-missing.yml') + const table = await readTable() + const kept = table.filter((t) => t.target !== 'x86_64-apple-darwin') + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + kept.map((t) => WORKFLOW_ROW(t.target, t.suffix)).join('') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + assertMatch(decode(res.stderr), /x86_64-apple-darwin/) + }) +}) + +Deno.test('check-matrix: fails when the workflow names an extra target', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-extra.yml') + const table = await readTable() + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + table.map((t) => WORKFLOW_ROW(t.target, t.suffix)).join('') + + WORKFLOW_ROW('x86_64-unknown-freebsd', 'freebsd-x64') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + assertMatch(decode(res.stderr), /x86_64-unknown-freebsd/) + }) +}) + +Deno.test('check-matrix: fails on a swapped target/suffix pair', async () => { + await withTmp(async (tmp) => { + const wf = join(tmp, 'release-swapped.yml') + const table = await readTable() + const rows = table.map((t) => [ + t.target, + t.target === 'x86_64-unknown-linux-gnu' ? 'linux-arm64' : t.suffix, + ]) + await Deno.writeTextFile( + wf, + ' strategy:\n matrix:\n include:\n' + + rows.map(([t, s]) => WORKFLOW_ROW(t, s)).join('') + + '\n', + ) + const res = await runScript('check-matrix.ts', ['--workflow-path', wf]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), /FAIL/) + }) +}) + +// --- parseFlags error paths (F10): malformed CLIs must exit non-zero before +// any write, never silently coerce a flag into a value slot. --- +const BAD_ARGS: Array<[string, string[], RegExp]> = [ + ['missing value', ['--suffix'], /missing value for --suffix/], + [ + 'flag-shaped value', + ['--suffix', '--dry-run'], + /missing value for --suffix/, + ], + ['unknown flag', ['--nope'], /unknown argument: --nope/], + ['positional argument', ['naked'], /unknown argument: naked/], +] + +for (const [label, args, pattern] of BAD_ARGS) { + Deno.test(`parseFlags: ${label} exits non-zero without writing`, async () => { + await withTmp(async (tmp) => { + const res = await runScript('generate-platform-manifest.ts', [ + ...args, + '--version', + '0.1.0', + '--out', + tmp, + ]) + assertNotEquals(res.code, 0) + assertMatch(decode(res.stderr), pattern) + // No package.json may be produced by a failed parse. + await assertRejects(() => Deno.lstat(join(tmp, 'package.json'))) + }) + }) +} + +Deno.test('parseFlags: --flag=value form still admits dash-leading values', async () => { + await withTmp(async (tmp) => { + const out = join(tmp, 'eq-form') + const res = await runScript('generate-platform-manifest.ts', [ + '--suffix=linux-x64', + '--version=0.1.0', + '--out', + out, + ], { writeAllow: out }) + assertEquals(res.code, 0, decode(res.stderr)) + const pkg = JSON.parse( + await Deno.readTextFile(join(out, 'package.json')), + ) + assertEquals(pkg.name, `${LAUNCHER_PREFIX}-linux-x64`) + }) +}) + +// --- Workflow/deno-task fidelity (F7): the exact permission surface the +// release pipeline declares must keep matching the scripts' real needs. +// These assertions replay the declared flags from the committed files — any +// drift (e.g. an env read without --allow-env) fails here, not at a tag. --- +Deno.test('fidelity: release.yml sync-root step declares --allow-env', async () => { + const workflow = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) + const syncBlock = + /- name: Sync root version[^]*?deno run[^\n]*\n((?:.*?\n)*?)\s*scripts\/release\/sync-root-version\.ts/ + const match = syncBlock.exec(workflow) + assertNotEquals(match, null, 'sync-root step not found') + assertMatch(match![1], /--allow-env/) +}) + +Deno.test('fidelity: manifest:sync-root task declares --allow-env', async () => { + const config = await Deno.readTextFile( + join(import.meta.dirname!, '..', '..', 'scripts', 'deno.jsonc'), + ) + assertMatch(config, /"manifest:sync-root":[^\n]*--allow-env/) +}) + +Deno.test('fidelity: registry gate never regresses to the fromjson bug', async () => { + const workflow = await Deno.readTextFile(RELEASE_WORKFLOW_PATH) + assertFalse( + workflow.includes('fromjson'), + 'jq gate must compare --argjson objects directly', + ) +}) From 448416b1d1518e052a27a7da41252c7ead116f29 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 17 Aug 2026 05:11:33 +0000 Subject: [PATCH 10/10] docs(compound): capture npm platform-package distribution learning Solution doc (docs/solutions/architecture-patterns/rust-cli-npm-distribution.md) grounds the launcher + five per-platform package pattern, the pnpm#3960 constraint that keeps optionalDependencies out of the committed manifest, the inject-at-publish sync, and the tag-triggered OIDC release pipeline with its observed silent-gate failure modes. CONCEPTS.md seeds the npm distribution vocabulary (launcher, platform package, release lane). All doc citations validated by validate-doc-claims.py (0 flags). --- CONCEPTS.md | 24 ++ .../rust-cli-npm-distribution.md | 236 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 docs/solutions/architecture-patterns/rust-cli-npm-distribution.md diff --git a/CONCEPTS.md b/CONCEPTS.md index 17c2077..a5ba0ce 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -42,6 +42,30 @@ A per-kind/precision-recall gate on the corpus that trips when a kind's classifier weakens — including a single-case kind that goes wrong — so a weakness in one kind cannot hide inside an aggregate F1 score. +## npm distribution + +### Launcher +The root npm package (`@systemfsoftware/claude-code-comment-checker`) whose +`bin` is the `comment-checker` shim. It resolves the host platform package by +identity at runtime and spawns the binary — the only package that declares a +bin. + +### Platform package +One per os-cpu pair (`-linux-x64`, `-darwin-arm64`, …), generated from +`scripts/release/targets.json`: ships only the compiled binary and its +manifest (`os`/`cpu`/`libc` fields, no `bin`). The launcher's +`optionalDependencies` pins all five to the release version. + +The committed launcher manifest never lists these packages as +`optionalDependencies` — pnpm cannot lock unpublished platform packages +(pnpm#3960), so the pins are injected from the targets table at publish +time; absence in-tree is expected, not a defect. + +### Release lane +A matrix row in the release workflow: one platform/arch build, gate, smoke, +and publish run on its native runner. Platforms publish before the launcher, +and the release cannot proceed if any lane fails. + ## Flagged ambiguities - "context" had been used for both the language (scope/position) and the diff --git a/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md new file mode 100644 index 0000000..d8514ae --- /dev/null +++ b/docs/solutions/architecture-patterns/rust-cli-npm-distribution.md @@ -0,0 +1,236 @@ +--- +title: Distributing a compiled Rust CLI as per-platform npm packages +date: 2026-08-17 +category: architecture-patterns +module: npm distribution (npm/packages/comment-checker + scripts/release + .github/workflows/release.yml) +problem_type: architecture_pattern +component: tooling +severity: medium +applies_when: + - Distributing a CLI compiled from Rust (or another compiled language) as npm packages to Linux, macOS, and Windows consumers + - The binary must arrive as a plain dependency with no postinstall build or download step + - Consumers or CI install with a frozen lockfile (pnpm) while the platform packages are unpublished until tag time + - The npm org supports trusted publishing so release credentials can be OIDC-only + - Native runners are available in CI for each platform/arch lane +tags: + - npm-distribution + - optional-dependencies + - platform-packages + - rust-cli + - oidc-provenance + - github-actions + - pnpm + - release-pipeline +--- + +# Distributing a compiled Rust CLI as per-platform npm packages + +## Context + +comment-checker is a Rust CLI shipped as a Claude Code hook; the npm +distribution must drop a working binary on every consumer's machine with +`npm i -g` / `npx` — no postinstall build, no download step. One package cannot +serve linux (x64 + arm64, glibc), darwin (x64 + arm64), and win32 x64 from a +single artifact, so the release surface is six packages: a root launcher plus +five per-platform binary packages. That shape creates two hard constraints: + +1. **pnpm cannot lock unresolvable optional deps (pnpm#3960).** The platform + packages do not exist in the registry until publish time, so a committed + launcher manifest that names them in `optionalDependencies` breaks + `pnpm install --frozen-lockfile` for every developer and CI run. The + committed manifest must stay clean; the pins are injected at publish time + (`scripts/release/sync-root-version.ts:18-22`). +2. **Six packages by hand is exactly what a human gets wrong.** The pipeline + must be tag-triggered (version = tag), run the same build → gate → smoke → + publish sequence on every tag, publish platforms before the root, and fail + loudly instead of shipping an absent or wrong-arch binary. + +## Guidance + +1. **Launcher resolves its platform package by identity at runtime.** + `npm/packages/comment-checker/src/platform.ts` defines two pure helpers: + `optionalDepName(platform, arch)` returns + `--`; `binaryFileName(platform)` returns + `comment-checker.exe` on win32, else `comment-checker`. The launcher + (`npm/packages/comment-checker/src/index.ts`) resolves the platform + package's own `package.json` via `createRequire` and joins the binary name + to its directory. Missing package surfaces as a + typed `BinaryNotFound` naming the package; a spawn-time ENOENT would be a + corrupt install npm would not have produced. +2. **One canonical targets table.** `scripts/release/targets.json` is the + single source of truth: five entries, each `{target, suffix, os, cpu, + libc?, bin}`. Everything else consumes the table instead of re-deriving + the platform set — the workflow resolves the per-lane binary name with + `jq` rather than duplicating the win32→`.exe` rule, + `generate-platform-manifest.ts` rejects unknown suffixes against the table, + and `check-matrix.ts` builds the agreement tests from it. +3. **Platform manifests are generated, cheap, and carry no `bin`.** + `generate-platform-manifest.ts` renders each platform `package.json`: name + = launcher name + `-`, `os`/`cpu`/`libc` from the table, + `files: [entry.bin]`, and **no `bin` field** — a platform-level bin would + create a top-level `comment-checker` shim colliding with the launcher's own + (esbuild precedent, comment at lines 60-62). `binarySha256` is recorded + into the manifest when the caller passes it. +4. **The committed launcher manifest carries NO `optionalDependencies`.** + pnpm cannot record unresolvable optional deps in a lockfile, so listing + unpublished platform packages breaks frozen installs. `sync-root-version.ts` + validates `VERSION` (strict semver regex, before any write), then injects + `version` plus the five pins from `targets.json`, preserving the manifest's + own formatting so an unchanged sync is byte-identical; `--dry-run` prints an + LCS diff. +5. **Gate the matrix, not the script.** `check-matrix.ts` names the product + platform set (`EXPECTED_SUFFIXES`, five entries — the known set, not a copy + of the table), then checks three agreements: the table names exactly that + set; the launcher manifest pins match the table exactly when present; and + the workflow matrix rows match the table triples in both directions — + missing, extra, and swapped `target`/`suffix` pairs are all failures, and + the failure arms are tested (`tests/release/release-scripts.test.ts`). +6. **Release pipeline: one lane per platform, platforms before root.** + `.github/workflows/release.yml` triggers only on `push: tags: v*` with + `permissions: {}` at the top. Five matrix lanes, `fail-fast: false`, each: + build → `check-matrix` gate → binary-exists gate → in-lane smoke (exit 0 + for a clean payload, 2 for a flagged one) → stage the platform package + outside the workspace in `$RUNNER_TEMP` plus a binary sha256 sidecar → + `pnpm publish --provenance` (OIDC, no `NODE_AUTH_TOKEN`, npm ≥ 11.5.1) → + upload tarball + sha sidecar. The root job `publish-npm-main` needs all + lanes, re-derives `VERSION` from the tag, requires the tag commit to be an + ancestor of the default branch, verifies every published platform + package's `version`/`os`/`cpu`/`libc` against the table, cross-checks the + published tarballs' binary sha against the recorded sidecars, builds + frozen, runs `sync-root-version.ts` with `VERSION` from the environment, + publishes the root, and verifies the root's five pins are exact version + pins. A final job attaches the tarballs to the GitHub release. +7. **Humans own one-time trust setup only.** OIDC trusted publishing is the + no-token story: the npm trusted-publisher record binds workflow filename + + environment (the npm form has no tag-pattern field), so the `tags: v*` + filter is the tag gate and `pull_request_target` is deliberately unused. + `docs/publishing/first-release-checklist.md` covers the six trusted- + publisher records and post-publish manual spot checks. + +## Why This Matters + +- **The pnpm failure mode is a landmine, not an annoyance.** The moment + someone adds `optionalDependencies` naming the platform packages to the + committed manifest, every `pnpm install --frozen-lockfile` — developers and + CI alike — breaks because the packages don't exist yet (pnpm#3960). It is + caught by `tests/npm-launcher/manifest.test.mjs` (asserts + `optionalDependencies` undefined pre-publish) and by `check-matrix.ts`'s + absence-is-expected branch. +- **Version skew is structurally impossible at the consumer.** The root pins + each platform package to the exact tag version (verified against the + registry at release time). Because the root is published after the + platforms, a consumer's install either gets the pinned, gated binary or + fails to resolve — no in-between state. +- **The silent gate failure modes were observed, so the gates are shaped + against them.** (a) `jq` libc shape: `npm view` reports `libc` as an array + (`["glibc"]`) while the table stores a bare string, and darwin/win32 rows + have no `libc` at all — a naive compare is always-true or always-false, so + the workflow normalizes both sides before deep equality. (b) Cross-arch + smoke: the smoke only proves anything on the lane's own native runner; + each matrix row maps target→runner (arm64 lanes use an ARM runner). (c) + `--allow-env`: `VERSION` arrives via the environment, and `deno run` is + deny-by-default, so a dropped flag fails at tag time, not at PR time; the + release-scripts suite replays the declared permission flags from the + committed files so drift fails in tests, not on a release. +- **No static token exists anywhere.** Publishing is OIDC-only; exit-code + propagation from the spawned binary is contract-tested, including the + shebang + exec bit npm's POSIX bin shim requires. + +## When to Apply + +- **Apply:** any compiled CLI (Rust, Go, C) distributed as an npm `bin` to + heterogeneous consumers — especially when you want `npm i -g` / `npx` to + just work, you have native CI runners per platform, and the npm org supports + trusted publishing. +- **Avoid when:** single-platform or single-arch tooling (one package with + `files`, no matrix); N-API addons (in-process bindings via `process.dlopen` + are a different architecture — no launcher spawn, no platform shim); a + binary that must be compiled on the consumer machine (postinstall builds + are their own failure mode). +- **Trust prerequisites:** OIDC trusted publishing is a hard dependency of + the no-token story, and the npm org needs one trusted-publisher record per + package name; brand-new names may require a seed publish before the record + can be configured (`docs/publishing/first-release-checklist.md`). + +## Examples + +`npm/packages/comment-checker/src/platform.ts` — the platform surface is two +pure helpers: + +```ts +export const binaryFileName = (platform: string): string => + platform === "win32" ? "comment-checker.exe" : "comment-checker" + +export const optionalDepName = (platform: string, arch: string): string => + `@systemfsoftware/claude-code-comment-checker-${platform}-${arch}` +``` + +`scripts/release/targets.json` — the table is the platform contract; every +entry carries `os`/`cpu`/`libc` consumed by manifest generation, the +workflow's binary-name resolution, and the registry gate: + +```json +{ + "target": "x86_64-unknown-linux-gnu", + "suffix": "linux-x64", + "os": "linux", + "cpu": "x64", + "libc": "glibc", + "bin": "comment-checker" +} +``` + +`scripts/release/sync-root-version.ts` — the inject-at-publish move that +keeps the committed manifest frozen-install-clean while the published root is +fully pinned: + +```ts +manifest.optionalDependencies = Object.fromEntries( + targets.map((entry) => [`${manifest.name}-${entry.suffix}`, version]), +) +``` + +`scripts/release/check-matrix.ts` — the product policy the table must name: + +```ts +const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'] +``` + +`.github/workflows/release.yml` — version comes only from the tag, and the +tag commit must be an ancestor of the default branch before anything +publishes: + +```yaml +- name: "Tag gate: derive VERSION from tag" + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "invalid tag semver: '$VERSION'" >&2 + exit 1 + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" +``` + +The binary-sha cross-check records a sha256 sidecar per lane at build time, +then re-packs the published tarball from the registry and recomputes the +digest — the gate that catches a wrong or swapped binary being published. + +## Related + +- Pipeline: `.github/workflows/release.yml` +- Platform table: `scripts/release/targets.json` +- Scripts: `scripts/release/generate-platform-manifest.ts`, + `scripts/release/sync-root-version.ts`, `scripts/release/check-matrix.ts` +- Human gate: `docs/publishing/first-release-checklist.md` +- Tests: `tests/release/release-scripts.test.ts` (targets, manifest emission, + sync, check-matrix failure arms incl. missing/extra/swapped workflow rows, + permission fidelity); `tests/npm-launcher/manifest.test.mjs` (no committed + optionalDependencies); `tests/npm-launcher/launcher.test.mjs` (black-box + spawn, exit-code propagation) +- pnpm#3960 — the constraint that makes listing optional deps a + frozen-lockfile landmine +- Residual advisories (open GitHub issues on this repo): #3 force-pushed tag + gate; #4 platform peerDependencies cross-link; #5 concurrency group vs + force-moved tags; #6 smoke exit-code contract; #7 sha sidecar self-trust; + #8 check-matrix regex-scrape fragility; #9 no actionlint / workflow YAML + validation in CI \ No newline at end of file